1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
//! Sync board facade shared by the file-backed and socket-backed clients.
use vissue_core::config::Layout;
use vissue_core::error::Error;
use vissue_core::views::{
AgendaRow, ClaimRow, Excerpt, IssueDetail, ListQuery, Recall, RelatedHit, SearchHit, TreeNode,
};
/// Which store the board is talking to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendKind {
/// File-backed [`crate::CoreBackend`].
Core,
/// Socket-backed `ControlBackend`.
Control,
}
/// One page of list/ready rows.
#[derive(Debug, Clone, Default)]
pub struct ListPage {
/// Rows for this page. Empty when [`Self::unchanged`].
pub issues: Vec<vissue_core::views::IssueRow>,
/// Total issues in the catalog on serve; core repeats [`Self::matched`].
pub total: u64,
/// Rows that matched the query.
pub matched: u64,
/// Serve catalog revision. Core is 0.
pub revision: u64,
/// File-watcher generation.
pub generation: u64,
/// Serve says the catalog is unchanged since `since_revision`.
pub unchanged: bool,
}
/// Outcome of claim, note, or update.
#[derive(Debug, Clone)]
pub struct MutResult {
/// Mutation succeeded.
pub ok: bool,
/// Status text from the op. Core includes a trailing newline.
pub report: String,
/// Issue after the write, when the backend returns one.
pub issue: Option<IssueDetail>,
/// Serve revision after the write. Core is 0.
pub revision: u64,
/// File-watcher generation after the write.
pub generation: u64,
}
/// Fields `issue/update` accepts.
#[derive(Debug, Clone, Default)]
pub struct UpdateReq {
/// Issue to change.
pub id: String,
/// New org TODO state, if any.
pub state: Option<String>,
/// New priority letter, if any.
pub priority: Option<char>,
/// Blocker id to add.
pub block: Option<String>,
/// Blocker id to drop.
pub unblock: Option<String>,
/// Refuse unless the heading is still this state.
pub if_state: Option<String>,
/// Refuse unless the corpus generation is still this value.
pub if_gen: Option<u64>,
}
/// Drops `since_revision` for one fetch after attach.
#[derive(Debug)]
pub struct SinceGate {
skip_once: std::sync::atomic::AtomicBool,
}
impl SinceGate {
/// After `initialize`, the next list must not send a core generation.
pub fn after_attach() -> Self {
Self {
skip_once: std::sync::atomic::AtomicBool::new(true),
}
}
/// Consume the skip flag, or return `Some(revision)` when `revision > 0`.
pub fn next(&self, revision: u64) -> Option<u64> {
if self
.skip_once
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
None
} else if revision > 0 {
Some(revision)
} else {
None
}
}
/// Next list/ready must omit `since_revision` (pane or query changed).
pub fn invalidate(&self) {
self.skip_once
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
/// Read and mutate the board. Implementations are `CoreBackend` and
/// `ControlBackend`.
pub trait BoardBackend: Send + Sync + std::fmt::Debug {
/// Vault this backend reads and writes.
fn layout(&self) -> &Layout;
/// File-watcher generation.
fn generation(&self) -> u64;
/// Serve catalog revision. Core is always 0.
fn revision(&self) -> u64;
/// Which store this backend is.
fn live(&self) -> BackendKind;
/// Claim and update identity (core: constructor; control: serve `initialize`).
fn identity(&self) -> &str;
/// Filtered issue list for the List pane.
///
/// # Errors
///
/// Returns an error if the list cannot be loaded.
fn list(&self, q: ListQuery) -> Result<ListPage, Error>;
/// Actionable ready queue, optionally scoped to `project`.
///
/// # Errors
///
/// Returns an error if the ready list cannot be loaded.
fn ready(&self, project: Option<&str>) -> Result<ListPage, Error>;
/// Full metadata for one issue.
///
/// # Errors
///
/// Returns an error if the issue does not exist or cannot be fetched.
fn get(&self, id: &str) -> Result<IssueDetail, Error>;
/// On-disk heading range, capped and screened for secrets.
///
/// # Errors
///
/// Returns an error if the issue does not exist or its file cannot be read.
fn excerpt(&self, id: &str) -> Result<Excerpt, Error>;
/// Title and body search hits, capped at `limit`.
///
/// # Errors
///
/// Returns an error if search cannot run.
fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error>;
/// Open claims, optionally filtered by holder and project.
///
/// # Errors
///
/// Returns an error if claims cannot be listed.
fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error>;
/// Deadlines and scheduled dates inside `days`.
///
/// # Errors
///
/// Returns an error if the agenda cannot be listed.
fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error>;
/// Parent and child tree rooted at `id`.
///
/// # Errors
///
/// Returns an error if the issue does not exist or the tree cannot be built.
fn tree(&self, id: &str) -> Result<TreeNode, Error>;
/// Related issues by graph walk and text overlap.
///
/// # Errors
///
/// Returns an error if the issue does not exist or related hits cannot be scored.
fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error>;
/// The working set for `id`: plan, declared inputs and their deeds.
///
/// # Errors
///
/// Returns an error if the issue does not exist or the blocker graph cannot
/// be built.
fn recall(&self, id: &str, depth: usize) -> Result<Recall, Error>;
/// Cite deed accessions on `id`.
///
/// # Errors
///
/// Returns an error if the issue does not exist, a value is not a deed
/// accession, or the file cannot be rewritten.
fn deed(&self, id: &str, add: &[String]) -> Result<MutResult, Error>;
/// Project names under the layout prefix.
///
/// # Errors
///
/// Returns an error if the project list cannot be read.
fn projects(&self) -> Result<Vec<String>, Error>;
/// Claim `id` as [`Self::identity`]. `force` takes over another holder.
///
/// # Errors
///
/// Returns an error if the issue does not exist, is DONE or CANCELLED, is
/// held by another identity without `force`, or the write fails.
fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error>;
/// Append a one-line logbook note.
///
/// # Errors
///
/// Returns an error if the issue does not exist, the text is empty, or the
/// write fails.
fn note(&self, id: &str, text: &str) -> Result<MutResult, Error>;
/// Change state, priority, or blocker edges.
///
/// # Errors
///
/// Returns an error if the issue does not exist, the change is refused, or
/// the write fails.
fn update(&self, req: UpdateReq) -> Result<MutResult, Error>;
/// Create a TODO in `project` with `title`.
///
/// # Errors
///
/// Returns an error if the project does not exist, the title is empty, or
/// the write fails.
fn create(&self, project: &str, title: &str) -> Result<MutResult, Error>;
/// Same metadata as [`Self::get`]; control also marks the issue opened.
///
/// # Errors
///
/// Returns an error if the issue does not exist or cannot be fetched.
fn open(&self, id: &str) -> Result<IssueDetail, Error>;
/// Core: wait on the file generation. Control: wait for `vault/changed`.
///
/// # Errors
///
/// Returns an error if the wait cannot be started or a newer catalog cannot
/// be re-read.
fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error>;
/// Last `since_revision` sent on list/ready. `None` means the field was
/// omitted. Default is "not recorded".
fn last_since_revision(&self) -> Option<Option<u64>> {
None
}
/// Drop `since_revision` on the next list/ready. Serve `unchanged` is
/// catalog-wide, so a pane or project change must fetch a full page.
fn invalidate_since(&self) {}
/// Re-read the files. Core uses this after an out-of-band write. Control
/// is a no-op; serve sees the file event.
///
/// # Errors
///
/// Returns an error if the catalog cannot be re-read. Control never fails.
fn refresh(&self) -> Result<(), Error> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::SinceGate;
#[test]
fn after_attach_first_list_omits_since_revision() {
let gate = SinceGate::after_attach();
assert_eq!(gate.next(7), None);
assert_eq!(gate.next(7), Some(7));
assert_eq!(gate.next(8), Some(8));
}
#[test]
fn a_zero_revision_never_sends_since() {
let gate = SinceGate::after_attach();
assert_eq!(gate.next(0), None);
assert_eq!(gate.next(0), None);
}
#[test]
fn invalidate_omits_the_next_since() {
let gate = SinceGate::after_attach();
assert_eq!(gate.next(7), None);
assert_eq!(gate.next(7), Some(7));
gate.invalidate();
assert_eq!(gate.next(7), None);
assert_eq!(gate.next(7), Some(7));
}
}