@volter/supercode-orchestrator 0.5.226 → 0.5.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -18
- package/adapters/rh2.mjs +6 -5
- package/bin/connection-runner.mjs +46 -12
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +6 -1
- package/board/agent-declarations.mjs +1 -1
- package/board/args.mjs +8 -1
- package/board/call.mjs +2 -2
- package/board/ci.mjs +158 -54
- package/board/cli.mjs +175 -53
- package/board/custody.mjs +5 -1
- package/board/dispatch.mjs +969 -256
- package/board/doors.mjs +7 -2
- package/board/engine.mjs +264 -48
- package/board/event-stream.mjs +3 -7
- package/board/files.mjs +4 -1
- package/board/fleet-listeners.mjs +3 -1
- package/board/fleet-observer.mjs +3 -2
- package/board/generation-owner.mjs +40 -10
- package/board/generation.mjs +17 -4
- package/board/machine-fleet.mjs +33 -9
- package/board/notices.mjs +10 -2
- package/board/published.mjs +107 -2
- package/board/readable-id.mjs +25 -2
- package/board/runtime-door.mjs +2 -2
- package/board/session-work.mjs +15 -1
- package/board/storage-contract.mjs +41 -3
- package/board/store.mjs +49 -7
- package/board/tools.mjs +1 -1
- package/board/workflow.mjs +197 -70
- package/board/workspace.mjs +99 -13
- package/board/ztrack.mjs +42 -14
- package/connection.mjs +252 -32
- package/index.d.ts +5 -1
- package/installed-apps.mjs +11 -5
- package/loop.mjs +6 -0
- package/mcp/hermes-kanban-tools.json +10 -1
- package/package.json +6 -6
- package/reducer.mjs +31 -2
package/board/workspace.mjs
CHANGED
|
@@ -97,19 +97,85 @@ export async function checkoutOf(machine, repository, branch) {
|
|
|
97
97
|
return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
|
|
98
98
|
}
|
|
99
99
|
// A card's solo worktree folder under the repository's `.worktrees`: the one it was made in, while its path is recorded
|
|
100
|
-
// (a card running keeps its folder)
|
|
101
|
-
//
|
|
100
|
+
// (a card running keeps its folder); else, for the branch the board last named for it (`task.named_branch`, its latest
|
|
101
|
+
// `branch_named`: `voice-calls`, store.mjs cardBranch), that branch's last part, whatever the card is called now; else its
|
|
102
|
+
// handle (`vo-856`, store.mjs cardHandle; `task.handle`), for a branch named by its number; else its id. Its branch is its
|
|
103
|
+
// own, else `wt/<id>`, as the folder named for its id had.
|
|
102
104
|
const worktreeFolder = (task) => {
|
|
103
105
|
if (task.workspace_path && basename(dirname(task.workspace_path)) === '.worktrees') return basename(task.workspace_path);
|
|
104
106
|
const branch = task.branch_name?.split('/').at(-1);
|
|
107
|
+
if (task.named_branch && task.branch_name === task.named_branch) return branch;
|
|
105
108
|
return task.handle && (branch === task.handle || branch?.startsWith(`${task.handle}-`)) ? task.handle : task.id;
|
|
106
109
|
};
|
|
107
110
|
const worktreeBranch = (task) => task.branch_name ?? `wt/${task.id}`;
|
|
108
|
-
|
|
111
|
+
/** The card's latest `branch_named` event (the branch the board named for it, and its repository), else null. */
|
|
112
|
+
const lastNamed = (db, id) => {
|
|
113
|
+
const row = db.prepare("SELECT id,json_extract(payload,'$.branch') AS branch,json_extract(payload,'$.repository') AS repository FROM task_events WHERE task_id=? AND kind='branch_named' ORDER BY id DESC LIMIT 1").get(id);
|
|
114
|
+
return row?.branch ? row : null;
|
|
115
|
+
};
|
|
116
|
+
/** The card's latest naming (lastNamed) while it names `branch` and no `worktree_made` follows it, else null. */
|
|
117
|
+
const unmadeNaming = (db, id, branch) => {
|
|
118
|
+
const named = lastNamed(db, id);
|
|
119
|
+
if (!branch || named?.branch !== branch) return null;
|
|
120
|
+
return db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND kind='worktree_made' AND id>? LIMIT 1").get(id, named.id) ? null : named;
|
|
121
|
+
};
|
|
122
|
+
/** Card `task`'s claim on its branch (its native id) while the branch the board last named for it (`branch_named`) is
|
|
123
|
+
* its branch and no worktree of it has been made since (`worktree_made`, a later event, or a recorded worktree path:
|
|
124
|
+
* unrecordedMaking): its machine's resolve then takes no branch or folder of that name that already exists (teams
|
|
125
|
+
* workspaces.mjs). A retarget to another repository names the branch again, and claims it again there. Null otherwise. */
|
|
126
|
+
export function branchClaim(db, id, branch) {
|
|
127
|
+
if (!id || !unmadeNaming(db, id, branch)) return null;
|
|
128
|
+
return db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(id)?.workspace_path ? null : id;
|
|
129
|
+
}
|
|
130
|
+
/** The making of card `id`'s named branch that no `worktree_made` records: the worktree path the card has recorded while
|
|
131
|
+
* the branch the board last named for it is its branch and no making follows the naming. Every naming (createCard, a
|
|
132
|
+
* retarget) leaves the card no path, and a launch records one only once its machine made the worktree, so a recorded
|
|
133
|
+
* path is a making. A generation whose format this one's READABLE_BY names (storage-contract.mjs) records only the path
|
|
134
|
+
* when it makes a worktree the board named here. { branch, repository, path }, else null. */
|
|
135
|
+
export function unrecordedMaking(db, id) {
|
|
136
|
+
const task = db.prepare('SELECT branch_name,workspace_kind,workspace_path FROM tasks WHERE id=?').get(id);
|
|
137
|
+
if (task?.workspace_kind !== 'worktree' || !task.workspace_path) return null;
|
|
138
|
+
const named = unmadeNaming(db, id, task.branch_name);
|
|
139
|
+
return named ? { branch: named.branch, repository: named.repository ?? null, path: task.workspace_path } : null;
|
|
140
|
+
}
|
|
141
|
+
/** Card `task` as its workspace is resolved: its handle, the branch the board last named for it and, when `claims`, its
|
|
142
|
+
* claim on that branch (branchClaim). */
|
|
143
|
+
export const workspaceTask = (board, task, { claims = false } = {}) => ({ ...task, handle: task.handle ?? cardHandleOf(board, task.id),
|
|
144
|
+
named_branch: task.id ? lastNamed(board.db, task.id)?.branch ?? null : null,
|
|
145
|
+
...(claims ? { branch_claim: task.workspace_kind === 'worktree' ? branchClaim(board.db, task.id, task.branch_name) : null } : {}) });
|
|
146
|
+
/** A `dir` workspace declared as a folder its machine answers is no git checkout at all (git's "not a git repository (or
|
|
147
|
+
* any of the parent directories)"; t_d424b9a3's volter folder on volter-desktop holds game-std and helldiver-lowpoly)
|
|
148
|
+
* is that folder: its own root, with no repository or branch (`folder`). Every resolution takes it from here: a run's
|
|
149
|
+
* claim (checkWorkspace) and its launch directory (dispatch resolveLocalWorkspace) are the folder, and its review reads
|
|
150
|
+
* the arc's recorded repositories (ci repositoryWorkspace). Any other failure still throws. */
|
|
151
|
+
const plainFolder = (declared, error) => {
|
|
152
|
+
if (!/not a git repository \(or any of the parent directories\)/.test(error?.message ?? '')) throw error;
|
|
153
|
+
const path = declared.replace(/[\\/]+$/, '');
|
|
154
|
+
return { path, root: path, linked: false, repository: null, branch: null, folder: true };
|
|
155
|
+
};
|
|
156
|
+
/** Its machine's git answered that the folder is not there (git's "cannot change to '<path>': No such file or directory"). */
|
|
157
|
+
const folderGone = (error) => /cannot change to .*: No such file or directory/.test(error?.message ?? '');
|
|
158
|
+
/** A card's worktree folder deleted from disk (removed through git, or by hand) is made again from the card's branch by
|
|
159
|
+
* its machine's resolve door (teams workspaces.mjs resolve: its stale registration forgotten, the branch's pushed head
|
|
160
|
+
* fetched), at the folder it had: a card whose recorded worktree is gone is re-prepared at launch, never guarded. A
|
|
161
|
+
* `dir` declaration naming such a folder (`<checkout>/.worktrees/<card>`, with the card's branch) is re-prepared the
|
|
162
|
+
* same way from the repository its checkout declares; any folder elsewhere, or a checkout that resolves to another
|
|
163
|
+
* folder, still throws the machine's own answer. */
|
|
164
|
+
async function remadeDirWorktree(task, declared, error, create) {
|
|
165
|
+
const path = declared.replace(/[\\/]+$/, '');
|
|
166
|
+
if (!folderGone(error) || !task.branch_name || basename(dirname(path)) !== '.worktrees') throw error;
|
|
167
|
+
const checkout = await workspaceDoor(task.machine, 'inspect', dirname(dirname(path))).catch(() => null);
|
|
168
|
+
if (!checkout?.repository || checkout.linked) throw error;
|
|
169
|
+
const made = await workspaceDoor(task.machine, 'resolve', checkout.repository, { branch: task.branch_name, solo: true, card: basename(path), create });
|
|
170
|
+
if (made.path !== path) throw error;
|
|
171
|
+
return { ...made, root: made.path };
|
|
172
|
+
}
|
|
173
|
+
export async function resolveWorkspace(task, create = false, { move = false } = {}) {
|
|
109
174
|
const declared = task.workspace_anchor ?? task.workspace_path;
|
|
110
175
|
if (isWorkspacePath(declared)) {
|
|
176
|
+
if (task.workspace_kind === 'dir') return workspaceDoor(task.machine, 'inspect', declared)
|
|
177
|
+
.catch((error) => folderGone(error) ? remadeDirWorktree(task, declared, error, create) : plainFolder(declared, error));
|
|
111
178
|
const info = await workspaceDoor(task.machine, 'inspect', declared);
|
|
112
|
-
if (task.workspace_kind === 'dir') return info;
|
|
113
179
|
if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
|
|
114
180
|
throw new Error('legacy worktree path needs registration and a repository declaration before launch');
|
|
115
181
|
}
|
|
@@ -127,29 +193,49 @@ export async function resolveWorkspace(task, create = false) {
|
|
|
127
193
|
if (task.workspace_kind === 'worktree' && task.workspace_path &&
|
|
128
194
|
basename(dirname(task.workspace_path)) === '.worktrees' &&
|
|
129
195
|
(!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
|
|
130
|
-
|
|
196
|
+
// Another machine's folder is read there, and one its git answers is gone is made again through that machine's
|
|
197
|
+
// resolve door at the same folder, as a missing folder on this machine is by the solo resolve after this branch.
|
|
198
|
+
const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path).catch((error) => { if (folderGone(error)) return null; throw error; });
|
|
199
|
+
if (!cached) return workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create });
|
|
131
200
|
if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
|
|
132
|
-
cached.repository === repositoryId(declared)
|
|
133
|
-
if (
|
|
134
|
-
|
|
201
|
+
cached.repository === repositoryId(declared)) {
|
|
202
|
+
if (cached.branch === worktreeBranch(task)) {
|
|
203
|
+
if (create) await workspaceDoor(task.machine, 'register', cached.root);
|
|
204
|
+
return { ...cached, root: dirname(dirname(cached.path)) };
|
|
205
|
+
}
|
|
206
|
+
// Off its branch, it is put on it only for a start of this card that no other current run shares (`move`), by its
|
|
207
|
+
// machine's resolve door when that loses nothing, which otherwise answers why not. Every other read refuses as before.
|
|
208
|
+
if (move) {
|
|
209
|
+
const made = await workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create, move });
|
|
210
|
+
if (made.path === cached.path) return { ...made, root: dirname(dirname(made.path)) };
|
|
211
|
+
}
|
|
135
212
|
}
|
|
136
213
|
throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
|
|
137
214
|
}
|
|
138
215
|
const solo = task.workspace_kind === 'worktree';
|
|
139
|
-
return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create });
|
|
216
|
+
return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create, ...(solo && task.branch_claim ? { claim: task.branch_claim } : {}) });
|
|
217
|
+
}
|
|
218
|
+
/** Whether another card's current run holds `task`'s recorded worktree folder on the same machine. */
|
|
219
|
+
function heldByAnotherRun(db, task) {
|
|
220
|
+
if (task.workspace_kind !== 'worktree' || !task.workspace_path) return false;
|
|
221
|
+
const local = isLocalWorkspace(task), mine = workspacePath(task.workspace_path, local);
|
|
222
|
+
return db.prepare(`SELECT t.id,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='worktree' AND t.id!=?`).all(task.id)
|
|
223
|
+
.some(row => normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && workspacePath(row.workspace_path, local) === mine);
|
|
140
224
|
}
|
|
141
225
|
export async function checkWorkspace(board, task) {
|
|
142
226
|
if (task.workspace_kind === 'scratch') return null;
|
|
143
|
-
task =
|
|
144
|
-
|
|
227
|
+
task = workspaceTask(board, task, { claims: true });
|
|
228
|
+
// Every caller starts this card (a claim, its launch attempt, a person's start), so its own worktree may be put on its
|
|
229
|
+
// branch, unless another card's current run holds the same folder on this machine; a peer is only ever read.
|
|
230
|
+
const facts = await resolveWorkspace(task, false, { move: !heldByAnotherRun(board.db, task) }), peers = [];
|
|
145
231
|
const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
|
|
146
232
|
// New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
|
|
147
233
|
// once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
|
|
148
234
|
{
|
|
149
|
-
for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
|
|
235
|
+
for (const row of board.db.prepare(`SELECT t.id,t.readable_id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
|
|
150
236
|
if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
|
|
151
237
|
let other;
|
|
152
|
-
try { other = await resolveWorkspace(
|
|
238
|
+
try { other = await resolveWorkspace(workspaceTask(board, row)); } catch { continue; } // Its retained path/reservation is still checked below.
|
|
153
239
|
if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
|
|
154
240
|
}
|
|
155
241
|
}
|
package/board/ztrack.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
19
19
|
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
21
21
|
import { DatabaseSync } from 'node:sqlite';
|
|
22
|
-
import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
|
+
import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, unnumberTasks, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
23
23
|
import { GitDocument } from './git-document.mjs';
|
|
24
24
|
import { unimported } from './legacy-store.mjs';
|
|
25
25
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -108,6 +108,11 @@ const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith
|
|
|
108
108
|
// The open document's record of the numbers the board has given (numberMark: its key, its number), and a task line's
|
|
109
109
|
// number (`- [ ] VO-2836 c3`); either written keyed, or with `#` as before a board read its numbers with a key.
|
|
110
110
|
const NUMBER_MARK = /^The board has given card numbers up to (?:#|([A-Z][A-Z0-9]+)-)(\d+)\.[ \t]*$/m;
|
|
111
|
+
// The open document's record of every card's number (VO-3578: a store made again from its documents keeps every card's
|
|
112
|
+
// number). A heading carries no number (a 2.x codec reads `## VO-154 t_… — title` as a card whose id is VO-154), so the
|
|
113
|
+
// numbers live in the preamble, which every reader of the documents, of any release, reads as text: `<id> <KEY-N>, …`.
|
|
114
|
+
const NUMBERS_KEPT = /^The board keeps its card numbers: (.*)\.[ \t]*$/m;
|
|
115
|
+
const NUMBER_KEPT = /([^\s,]+) (?:#|[A-Z][A-Z0-9]+-)(\d+)/g;
|
|
111
116
|
const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+(?:#|[A-Z][A-Z0-9]+-)(\d+)\s+[a-z]+\d+\s/gm;
|
|
112
117
|
|
|
113
118
|
const BOARD_TABLES = `
|
|
@@ -138,7 +143,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
138
143
|
}
|
|
139
144
|
const config = JSON.parse(read(marker));
|
|
140
145
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
141
|
-
return { db: backing.db, snapshot: (path) => backing.snapshot(path), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
146
|
+
return { db: backing.db, snapshot: (path) => backing.snapshot(path), vacuum: (sql) => backing.vacuum(sql), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
142
147
|
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
143
148
|
}
|
|
144
149
|
|
|
@@ -286,6 +291,13 @@ class ZtrackBoard {
|
|
|
286
291
|
this.conn.prepare('VACUUM INTO ?').run(path);
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
// Run SQL on the store's own connection outside its write wrapper (a rebuild: SQLite forbids VACUUM inside a tx).
|
|
295
|
+
vacuum(sql) {
|
|
296
|
+
if (this.active || this.readOnly) throw new Error('rebuilding the board needs a writable backing outside a transaction');
|
|
297
|
+
this.tx(() => {});
|
|
298
|
+
this.conn.exec(sql);
|
|
299
|
+
}
|
|
300
|
+
|
|
289
301
|
statement(sql) {
|
|
290
302
|
let statement = this.statements.get(sql);
|
|
291
303
|
if (!statement) {
|
|
@@ -462,13 +474,14 @@ class ZtrackBoard {
|
|
|
462
474
|
put(`chunks:${role}`, entries);
|
|
463
475
|
}
|
|
464
476
|
put('published', published);
|
|
465
|
-
// The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the
|
|
466
|
-
// are numbered in the order they were made, after every number the documents name (documentNumbers).
|
|
477
|
+
// The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the other
|
|
478
|
+
// cards are numbered in the order they were made, after every number the documents name (documentNumbers); a task is not.
|
|
467
479
|
db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
|
|
468
480
|
const named = this.documentNumbers();
|
|
469
481
|
this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
|
|
470
482
|
raiseCardNumbers(db, named.highest);
|
|
471
|
-
db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS})
|
|
483
|
+
db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS})
|
|
484
|
+
AND id NOT IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL) ORDER BY created_at, id`);
|
|
472
485
|
db.exec('COMMIT');
|
|
473
486
|
}
|
|
474
487
|
|
|
@@ -919,8 +932,14 @@ class ZtrackBoard {
|
|
|
919
932
|
if (initial) {
|
|
920
933
|
if (rebuilding) this.conn.exec(`DELETE FROM ${CARD_NUMBERS}`);
|
|
921
934
|
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
922
|
-
|
|
935
|
+
const named = this.documentNumbers();
|
|
936
|
+
this.numberFromHeadings([...named.kept].filter(([id]) => plan.rows.has(id) || this.one('SELECT 1 FROM main.tasks WHERE id = ?', id)));
|
|
937
|
+
raiseCardNumbers(this.conn, named.highest);
|
|
923
938
|
}
|
|
939
|
+
// What is numbered before the cards load: a task the document or the store had numbered keeps it, and a task row the
|
|
940
|
+
// load inserts gives back the number the insert trigger hands it (only cards are numbered).
|
|
941
|
+
const numbered = overlay ? null : new Set(this.statement(`SELECT task_id FROM main.${CARD_NUMBERS}`).all().map((row) => row.task_id));
|
|
942
|
+
const handedOut = overlay ? 0 : this.one(`SELECT seq FROM main.sqlite_sequence WHERE name = '${CARD_NUMBERS}'`)?.seq ?? 0;
|
|
924
943
|
for (const [id, rows] of plan.rows) {
|
|
925
944
|
const before = fingerprintOf(id);
|
|
926
945
|
const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
|
|
@@ -939,6 +958,7 @@ class ZtrackBoard {
|
|
|
939
958
|
events.push({ id, kind, payload: { by, source: 'document', status: after.status, assignee: after.assignee, author: by }, before, after });
|
|
940
959
|
}
|
|
941
960
|
}
|
|
961
|
+
if (!overlay) unnumberTasks(this.conn, numbered, handedOut);
|
|
942
962
|
for (const id of plan.checked) this.errors.delete(id);
|
|
943
963
|
for (const [id, reason] of plan.errors) this.errors.set(id, reason);
|
|
944
964
|
// A read overlays new document cards before the owner commits them. Give that
|
|
@@ -1043,7 +1063,7 @@ class ZtrackBoard {
|
|
|
1043
1063
|
return parents;
|
|
1044
1064
|
};
|
|
1045
1065
|
let parents = cardsTouched();
|
|
1046
|
-
const mark = this.
|
|
1066
|
+
const mark = this.numberMark();
|
|
1047
1067
|
if (!parents.size && !mark) return [];
|
|
1048
1068
|
const heldBy = (id) => this.errors.has(id) || this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', id);
|
|
1049
1069
|
// A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
|
|
@@ -1278,7 +1298,7 @@ class ZtrackBoard {
|
|
|
1278
1298
|
// `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
|
|
1279
1299
|
// heading, a task line, or the open document's record of the numbers given, numberMark).
|
|
1280
1300
|
documentNumbers() {
|
|
1281
|
-
const headings = new Map();
|
|
1301
|
+
const headings = new Map(), kept = new Map();
|
|
1282
1302
|
let highest = 0;
|
|
1283
1303
|
const dir = dirname(this.path), named = basename(this.path);
|
|
1284
1304
|
let rotated = [];
|
|
@@ -1286,7 +1306,10 @@ class ZtrackBoard {
|
|
|
1286
1306
|
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1287
1307
|
const text = lf(read(path));
|
|
1288
1308
|
for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
|
|
1289
|
-
if (path === this.path)
|
|
1309
|
+
if (path === this.path) {
|
|
1310
|
+
highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
|
|
1311
|
+
for (const [, id, number] of (NUMBERS_KEPT.exec(text)?.[1] ?? '').matchAll(NUMBER_KEPT)) { kept.set(nativeId(id), Number(number)); highest = Math.max(highest, Number(number)); }
|
|
1312
|
+
}
|
|
1290
1313
|
let records = [];
|
|
1291
1314
|
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1292
1315
|
for (const record of records) {
|
|
@@ -1295,26 +1318,31 @@ class ZtrackBoard {
|
|
|
1295
1318
|
if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
|
|
1296
1319
|
}
|
|
1297
1320
|
}
|
|
1298
|
-
|
|
1321
|
+
for (const [id, number] of kept) if (!headings.has(id)) headings.set(id, number);
|
|
1322
|
+
return { headings, kept, highest };
|
|
1299
1323
|
}
|
|
1300
1324
|
|
|
1301
1325
|
// The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
|
|
1302
1326
|
// made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
|
|
1303
|
-
// a card the documents no longer show
|
|
1327
|
+
// a card the documents no longer show; and every card's number (NUMBERS_KEPT), which that store gives back. The edit
|
|
1328
|
+
// that brings the two lines up to the store, or null. Only the preamble is written: no card is written again for it.
|
|
1304
1329
|
numberMark() {
|
|
1305
1330
|
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1306
1331
|
if (!given) return null;
|
|
1307
1332
|
const keyed = this.numberForm === 'keyed';
|
|
1308
1333
|
const line = (top) => `The board has given card numbers up to ${keyed ? `${this.cardKey}-` : '#'}${top}.`;
|
|
1334
|
+
const cards = this.statement(`SELECT n.task_id, n.number FROM main.${CARD_NUMBERS} n JOIN main.supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NULL ORDER BY n.number`).all();
|
|
1335
|
+
const kept = `The board keeps its card numbers: ${cards.map((row) => `${row.task_id} ${this.cardKey}-${row.number}`).join(', ')}.`;
|
|
1309
1336
|
const first = this.entries('open')[0];
|
|
1310
1337
|
if (first && first.id === null) {
|
|
1311
1338
|
const text = this.texts([first])[0].text;
|
|
1312
1339
|
const said = NUMBER_MARK.exec(text);
|
|
1313
|
-
|
|
1314
|
-
|
|
1340
|
+
let next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
|
|
1341
|
+
next = NUMBERS_KEPT.test(next) ? next.replace(NUMBERS_KEPT, () => kept) : next.replace(NUMBER_MARK, (mark) => `${mark}\n${kept}`);
|
|
1342
|
+
if (next === text) return null;
|
|
1315
1343
|
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1316
1344
|
}
|
|
1317
|
-
const text = `${line(given)}\n\n`;
|
|
1345
|
+
const text = `${line(given)}\n${kept}\n\n`;
|
|
1318
1346
|
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1319
1347
|
}
|
|
1320
1348
|
|