@volter/supercode-orchestrator 0.5.207 → 0.5.209
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 +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +125 -91
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +354 -84
- package/board/engine.mjs +77 -50
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +3 -1
- package/board/listing.mjs +13 -11
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +2 -2
- package/board/published.mjs +124 -33
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +95 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +58 -16
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +119 -48
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +108 -30
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +2 -2
- package/source-lock.mjs +7 -2
- package/worker-route.mjs +31 -3
package/board/ztrack.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fillReadableIds, validateReadableId } from './readable-id.mjs';
|
|
1
2
|
// The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
|
|
2
3
|
// and an index of the document's card spans. ADR 0001 and 0003.
|
|
3
4
|
// All markdown interpretation belongs to ztrack's public codec and kanban preset.
|
|
@@ -18,7 +19,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
18
19
|
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
21
|
import { DatabaseSync } from 'node:sqlite';
|
|
21
|
-
import { actorOf, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
|
+
import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
22
23
|
import { GitDocument } from './git-document.mjs';
|
|
23
24
|
import { unimported } from './legacy-store.mjs';
|
|
24
25
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -104,9 +105,10 @@ const ARCHIVE_LINES = 2000;
|
|
|
104
105
|
// document changed while it was read (readSnapshot).
|
|
105
106
|
const SNAPSHOT_TRIES = 8;
|
|
106
107
|
const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
|
|
107
|
-
// The open document's record of the numbers the board has given (numberMark), and a task line's
|
|
108
|
-
|
|
109
|
-
const
|
|
108
|
+
// The open document's record of the numbers the board has given (numberMark: its key, its number), and a task line's
|
|
109
|
+
// number (`- [ ] VO-2836 c3`); either written keyed, or with `#` as before a board read its numbers with a key.
|
|
110
|
+
const NUMBER_MARK = /^The board has given card numbers up to (?:#|([A-Z][A-Z0-9]+)-)(\d+)\.[ \t]*$/m;
|
|
111
|
+
const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+(?:#|[A-Z][A-Z0-9]+-)(\d+)\s+[a-z]+\d+\s/gm;
|
|
110
112
|
|
|
111
113
|
const BOARD_TABLES = `
|
|
112
114
|
CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
@@ -136,7 +138,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
136
138
|
}
|
|
137
139
|
const config = JSON.parse(read(marker));
|
|
138
140
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
139
|
-
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
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,
|
|
140
142
|
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
141
143
|
}
|
|
142
144
|
|
|
@@ -213,7 +215,7 @@ class ZtrackBoard {
|
|
|
213
215
|
}
|
|
214
216
|
// A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
|
|
215
217
|
// a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
|
|
216
|
-
if (seed || this.git?.underived) this.tx(() => {});
|
|
218
|
+
if (seed || this.git?.underived || this.get('rebuild_from_documents') || !this.get('task_identities_written') || !this.get('readable_ids_written')) this.tx(() => {});
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
// A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
|
|
@@ -267,7 +269,7 @@ class ZtrackBoard {
|
|
|
267
269
|
// state, so a rotation committed but not yet written is still a document of the board).
|
|
268
270
|
get docs() {
|
|
269
271
|
let rotated = [];
|
|
270
|
-
try { rotated = this.conn ? this.get('archives', []) : []; } catch { rotated = []; }
|
|
272
|
+
try { rotated = this.conn ? this.get('archives', []) : this.rebuildArchives ?? []; } catch { rotated = []; }
|
|
271
273
|
// recorded by file name, beside the document, so a board moved with its documents still finds them
|
|
272
274
|
return [...this.baseDocs, ...rotated.map(({ role, name }) => ({ role, path: resolve(dirname(this.path), name) }))];
|
|
273
275
|
}
|
|
@@ -276,6 +278,14 @@ class ZtrackBoard {
|
|
|
276
278
|
board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
|
|
277
279
|
close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
|
|
278
280
|
|
|
281
|
+
// Import pending document edits through the backing, then copy the committed
|
|
282
|
+
// projection outside its write wrapper: SQLite forbids VACUUM inside a tx.
|
|
283
|
+
snapshot(path) {
|
|
284
|
+
if (this.active || this.readOnly) throw new Error('board export needs a writable backing outside a transaction');
|
|
285
|
+
this.tx(() => {});
|
|
286
|
+
this.conn.prepare('VACUUM INTO ?').run(path);
|
|
287
|
+
}
|
|
288
|
+
|
|
279
289
|
statement(sql) {
|
|
280
290
|
let statement = this.statements.get(sql);
|
|
281
291
|
if (!statement) {
|
|
@@ -351,6 +361,8 @@ class ZtrackBoard {
|
|
|
351
361
|
|
|
352
362
|
// A board created from the previous runtime store imports its rows once; the old file stays as it was.
|
|
353
363
|
create(legacy) {
|
|
364
|
+
const named = `${basename(this.path)}.archive-`;
|
|
365
|
+
this.rebuildArchives = existsSync(dirname(this.path)) ? readdirSync(dirname(this.path)).filter(name => name.startsWith(named) && name.endsWith('.md')).sort().map(name => ({ role: `archived:${name.slice(named.length, -3)}`, name })) : [];
|
|
354
366
|
const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
355
367
|
const db = new DatabaseSync(tmp);
|
|
356
368
|
try {
|
|
@@ -362,6 +374,13 @@ class ZtrackBoard {
|
|
|
362
374
|
this.init(db, { fresh: true });
|
|
363
375
|
db.exec(BOARD_TABLES);
|
|
364
376
|
if (has('runtime_rows')) this.importRuntime(db, old, legacy);
|
|
377
|
+
// Runtime history is retained, but an old cache is never the current document state. Persist the recovery
|
|
378
|
+
// marker before installing the store so a crash/reopen also finishes this first full reconciliation.
|
|
379
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('archives', JSON.stringify(this.rebuildArchives));
|
|
380
|
+
if (!this.seed) {
|
|
381
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('rebuild_from_documents', 'true');
|
|
382
|
+
db.prepare('INSERT OR REPLACE INTO board_state VALUES (?, ?)').run('published', '{}');
|
|
383
|
+
}
|
|
365
384
|
} finally { old?.close(); }
|
|
366
385
|
db.close();
|
|
367
386
|
} catch (error) {
|
|
@@ -564,14 +583,25 @@ class ZtrackBoard {
|
|
|
564
583
|
}
|
|
565
584
|
if (this.seed) this.seedRows();
|
|
566
585
|
timedStep('board tx snapshot', () => this.snapshotHeld());
|
|
586
|
+
fillReadableIds(this.db);
|
|
567
587
|
const result = timedStep('board tx body', () => fn(this.db));
|
|
588
|
+
fillReadableIds(this.db);
|
|
568
589
|
if (this.contended) throw RETRY;
|
|
569
|
-
// The first write made with a codec that renders numbers (numbersRendered)
|
|
570
|
-
// (store.mjs layOutNumbers) writes every card of the open document again, with its number in its
|
|
571
|
-
// open subtask's on its task line
|
|
572
|
-
|
|
590
|
+
// The first write made with a codec that renders keyed numbers (numbersRendered) once the store numbered its
|
|
591
|
+
// cards (store.mjs layOutNumbers) writes every card of the open document again, with its keyed number in its
|
|
592
|
+
// heading and each open subtask's on its task line, and again whenever the board's key changes (a renamed home);
|
|
593
|
+
// a card made since is written with its number when it is made. `numbers_written` is the key last written.
|
|
594
|
+
if (this.numbersRendered && (this.numberForm === 'keyed' ? this.get('numbers_written') !== this.cardKey : !this.get('numbers_written'))) {
|
|
573
595
|
this.statement('INSERT INTO temp.board_touched SELECT value FROM json_each(?)').run(JSON.stringify(this.entries('open').filter((e) => e.id).map((e) => nativeId(e.id))));
|
|
574
|
-
this.put('numbers_written', true);
|
|
596
|
+
this.put('numbers_written', this.numberForm === 'keyed' ? this.cardKey : true);
|
|
597
|
+
}
|
|
598
|
+
if (!this.get('readable_ids_written')) {
|
|
599
|
+
this.conn.exec('INSERT INTO temp.board_touched SELECT id FROM main.tasks');
|
|
600
|
+
this.put('readable_ids_written', true);
|
|
601
|
+
}
|
|
602
|
+
if (!this.get('task_identities_written')) {
|
|
603
|
+
this.conn.exec('INSERT INTO temp.board_touched SELECT DISTINCT subtask_of FROM main.supercode_cards WHERE subtask_of IS NOT NULL');
|
|
604
|
+
this.put('task_identities_written', true);
|
|
575
605
|
}
|
|
576
606
|
const files = timedStep('board tx render', () => this.render());
|
|
577
607
|
const intent = files.length ? randomBytes(16).toString('hex') : null;
|
|
@@ -797,10 +827,9 @@ class ZtrackBoard {
|
|
|
797
827
|
// while its card was not rendered, as under a recorded refusal): it takes the store's value, and the card's
|
|
798
828
|
// render writes that back into its lines (t_f3f102db).
|
|
799
829
|
checkControls(plan, resolveRef) {
|
|
800
|
-
if (!this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
|
|
830
|
+
if (this.get('rebuild_from_documents') || !this.one('SELECT 1 FROM main.tasks LIMIT 1')) return;
|
|
801
831
|
let fields = [];
|
|
802
832
|
try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
|
|
803
|
-
if (!fields.length) return;
|
|
804
833
|
const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
|
|
805
834
|
: field === 'blocked_by' ? json(state?.blocked_by, []).map((b) => [b.kind, b.id]).sort()
|
|
806
835
|
: field === 'paused' ? json(state?.paused, null)
|
|
@@ -832,15 +861,20 @@ class ZtrackBoard {
|
|
|
832
861
|
};
|
|
833
862
|
for (const [id, rows] of plan.rows) {
|
|
834
863
|
const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id), state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id);
|
|
864
|
+
// Stale status recovery is independent of a home's document-control policy. In particular, render() re-reads a
|
|
865
|
+
// refused span after a verb completes its card: its unchanged running line must not undo that completion.
|
|
866
|
+
// Reopening an ended card requires its workflow verb, even in a home that otherwise permits document edits.
|
|
867
|
+
const controlled = new Set(fields);
|
|
868
|
+
if (task && ENDED.has(value(task, state, 'status'))) controlled.add('status');
|
|
835
869
|
// A new display author is not an authenticated creator.
|
|
836
|
-
const differ =
|
|
870
|
+
const differ = [...new Set(['status', ...controlled])].filter((field) => (task || field !== 'created_by') && !same(value(rows.task, rows.state, field), value(task, state, field)));
|
|
837
871
|
const prior = task && differ.length ? rowsBefore(rows.state.subtask_of ?? id).get(id) : null;
|
|
838
872
|
const stale = prior ? differ.filter((field) => same(value(prior.task, prior.state, field), value(rows.task, rows.state, field))) : [];
|
|
839
873
|
for (const field of stale) adopt(rows, task, state, field);
|
|
840
|
-
const forbidden = differ.filter((field) => !stale.includes(field));
|
|
874
|
+
const forbidden = differ.filter((field) => controlled.has(field) && !stale.includes(field));
|
|
841
875
|
if (forbidden.length) plan.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
|
|
842
876
|
}
|
|
843
|
-
for (const id of plan.remove) {
|
|
877
|
+
for (const id of fields.length ? plan.remove : []) {
|
|
844
878
|
// A task line already absent from the replaced span was not removed by this edit: the store keeps it.
|
|
845
879
|
const parent = this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of;
|
|
846
880
|
const prior = parent && !plan.remove.has(parent) ? rowsBefore(parent) : null;
|
|
@@ -856,9 +890,17 @@ class ZtrackBoard {
|
|
|
856
890
|
this.dropOverlay();
|
|
857
891
|
this.conn.exec('CREATE TEMP TABLE tasks AS SELECT * FROM main.tasks; CREATE TEMP TABLE supercode_cards AS SELECT * FROM main.supercode_cards; CREATE TEMP TABLE task_links AS SELECT * FROM main.task_links');
|
|
858
892
|
this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
|
|
893
|
+
this.conn.exec("CREATE UNIQUE INDEX temp.supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
|
|
859
894
|
this.overlay = true; this.statements.clear();
|
|
860
895
|
}
|
|
861
|
-
const
|
|
896
|
+
const rebuilding = !overlay && this.get('rebuild_from_documents');
|
|
897
|
+
const initial = !overlay && (rebuilding || !this.one('SELECT 1 FROM main.tasks LIMIT 1'));
|
|
898
|
+
if (rebuilding) {
|
|
899
|
+
if (plan.errors.size) throw new Error(`cannot rebuild board from invalid documents: ${[...plan.errors].map(([id, why]) => `${id}: ${why}`).join('; ')}`);
|
|
900
|
+
// The imported cache can predate cards added, edited or removed in the documents. Keep its history, not its
|
|
901
|
+
// obsolete card state. Every document was re-read, including unchanged archive spans.
|
|
902
|
+
for (const { id } of this.statement('SELECT id FROM main.tasks').all()) if (!plan.rows.has(id)) plan.remove.add(id);
|
|
903
|
+
}
|
|
862
904
|
const by = 'document-editor';
|
|
863
905
|
const events = [];
|
|
864
906
|
const fingerprintOf = (id) => {
|
|
@@ -875,6 +917,7 @@ class ZtrackBoard {
|
|
|
875
917
|
// heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
|
|
876
918
|
// the store's numbers: a number typed into a heading or pasted with a section changes nothing.
|
|
877
919
|
if (initial) {
|
|
920
|
+
if (rebuilding) this.conn.exec(`DELETE FROM ${CARD_NUMBERS}`);
|
|
878
921
|
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
879
922
|
raiseCardNumbers(this.conn, this.documentNumbers().highest);
|
|
880
923
|
}
|
|
@@ -884,6 +927,10 @@ class ZtrackBoard {
|
|
|
884
927
|
const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
|
|
885
928
|
this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
|
|
886
929
|
this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
|
|
930
|
+
if (rebuilding && ENDED.has(rows.state.stage ?? rows.task.status) && oldTask.current_run_id) {
|
|
931
|
+
this.statement("UPDATE main.task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ? AND ended_at IS NULL").run(now(), oldTask.current_run_id);
|
|
932
|
+
this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?').run(id);
|
|
933
|
+
}
|
|
887
934
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
888
935
|
for (const b of json(rows.state.blocked_by, []).filter((b) => b.kind === 'card')) this.statement(`INSERT OR IGNORE INTO ${schema}.task_links (parent_id, child_id) VALUES (?, ?)`).run(b.id, id);
|
|
889
936
|
const after = fingerprintOf(id);
|
|
@@ -894,7 +941,11 @@ class ZtrackBoard {
|
|
|
894
941
|
}
|
|
895
942
|
for (const id of plan.checked) this.errors.delete(id);
|
|
896
943
|
for (const [id, reason] of plan.errors) this.errors.set(id, reason);
|
|
944
|
+
// A read overlays new document cards before the owner commits them. Give that
|
|
945
|
+
// connection-local view the same names as the subsequent write.
|
|
946
|
+
fillReadableIds(this.conn);
|
|
897
947
|
if (overlay) return;
|
|
948
|
+
if (rebuilding) this.drop('rebuild_from_documents');
|
|
898
949
|
for (const id of plan.errors.keys()) this.refused.add(this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id);
|
|
899
950
|
for (const [tid, { key, sources }] of plan.tasks) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key, sources) VALUES (?, ?, ?)').run(tid, key, sources?.length ? JSON.stringify(sources) : null);
|
|
900
951
|
for (const id of [...plan.checked, ...plan.rows.keys()]) if (!plan.errors.has(id)) this.statement('DELETE FROM main.board_errors WHERE id = ?').run(id);
|
|
@@ -941,7 +992,7 @@ class ZtrackBoard {
|
|
|
941
992
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
942
993
|
const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
|
|
943
994
|
const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
|
|
944
|
-
const task = { ...meta, ...runtime, id, title: card.title, body: stored.body,
|
|
995
|
+
const task = { ...meta, ...runtime, id, title: card.title, readable_id: meta.readable_id == null ? old.readable_id ?? null : validateReadableId(meta.readable_id), body: stored.body,
|
|
945
996
|
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
|
|
946
997
|
workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: runtime.workspace_path ?? (/^(\/|~|[A-Za-z]:[\\/])/.test(workspace?.[2] ?? '') ? workspace[2] : null), branch_name: card.branch ?? null };
|
|
947
998
|
const state = { task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
@@ -950,6 +1001,7 @@ class ZtrackBoard {
|
|
|
950
1001
|
// carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
|
|
951
1002
|
paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
|
|
952
1003
|
archived: (meta.archived ?? card.archived) === undefined ? Number(this.one('SELECT archived FROM main.supercode_cards WHERE task_id = ?', id)?.archived ?? 0) : ((meta.archived ?? card.archived) ? 1 : 0) };
|
|
1004
|
+
if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.status;
|
|
953
1005
|
return { task, state };
|
|
954
1006
|
}
|
|
955
1007
|
|
|
@@ -958,17 +1010,17 @@ class ZtrackBoard {
|
|
|
958
1010
|
return card.acceptanceCriteria.map((task) => {
|
|
959
1011
|
const key = `${id}:${task.id}`;
|
|
960
1012
|
const tid = task.metadata?.id ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', key)?.id ?? `t_${randomBytes(4).toString('hex')}`;
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
const stored =
|
|
964
|
-
const storedState =
|
|
1013
|
+
// The native identity and declared state travel with the line. Runtime history stays in the store, joined by
|
|
1014
|
+
// this same identity, including when that store was rebuilt from a cache older than the subtask.
|
|
1015
|
+
const stored = this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
|
|
1016
|
+
const storedState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
|
|
965
1017
|
const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
|
|
966
|
-
const taskCard = { ...
|
|
1018
|
+
const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
|
|
967
1019
|
id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
|
|
968
1020
|
status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
|
|
969
1021
|
metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
|
|
970
|
-
assignee: task.metadata
|
|
971
|
-
machine: task.metadata
|
|
1022
|
+
assignee: task.metadata && 'assignee' in task.metadata ? task.metadata.assignee : stored?.assignee ?? card.assignee,
|
|
1023
|
+
machine: task.metadata && 'machine' in task.metadata ? task.metadata.machine : storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
972
1024
|
relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
|
|
973
1025
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
974
1026
|
return [tid, taskCard, key, task.sources, task.number];
|
|
@@ -1156,9 +1208,13 @@ class ZtrackBoard {
|
|
|
1156
1208
|
const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
|
|
1157
1209
|
const blocks = json(s.blocked_by, []);
|
|
1158
1210
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1159
|
-
//
|
|
1160
|
-
// output
|
|
1211
|
+
// Keep the identity-to-number binding and declared state durable with its line; a stale runtime cache cannot
|
|
1212
|
+
// supply these for a newer subtask. Run output, clocks and active claims remain the runtime store's.
|
|
1161
1213
|
card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, ...this.numberOf(r.id), status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
|
|
1214
|
+
metadata: { id: r.id, status, assignee: r.assignee, machine: s.machine,
|
|
1215
|
+
properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => declared(k, v))),
|
|
1216
|
+
card: { priority: r.priority ?? 0, workspace: r.workspace_kind + (r.workspace_kind !== 'scratch' && (s.workspace_anchor ?? r.workspace_path) ? `:${s.workspace_anchor ?? r.workspace_path}` : ''),
|
|
1217
|
+
branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, every: s.every || undefined } },
|
|
1162
1218
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1163
1219
|
waitingOn: messages[0]?.id,
|
|
1164
1220
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1172,27 +1228,36 @@ class ZtrackBoard {
|
|
|
1172
1228
|
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1173
1229
|
}
|
|
1174
1230
|
|
|
1175
|
-
// A card's number, shown first in its heading (`##
|
|
1176
|
-
// heading's number changes nothing; a store made again from its documents takes
|
|
1177
|
-
// record keeps the key where the document codec reads
|
|
1231
|
+
// A card's number, shown first in its heading with its board's key (`## VO-42 t_… — …`): the store's (store.mjs
|
|
1232
|
+
// layOutNumbers, boardKey). An edit to a heading's number changes nothing; a store made again from its documents takes
|
|
1233
|
+
// it back (numberFromHeadings). A card's record keeps the number and key where the document codec reads them, after
|
|
1234
|
+
// the id.
|
|
1178
1235
|
numberOf(id) {
|
|
1179
1236
|
if (!this.numbersRendered) return {};
|
|
1180
1237
|
const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
|
|
1181
|
-
return number ? { number } : {};
|
|
1238
|
+
return number ? { number, ...(this.numberForm === 'keyed' ? { key: this.cardKey } : {}) } : {};
|
|
1182
1239
|
}
|
|
1183
1240
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1241
|
+
/** The board's key (store.mjs boardKey). */
|
|
1242
|
+
get cardKey() { return this._cardKey ??= boardKey(this.root, this.slug); }
|
|
1243
|
+
|
|
1244
|
+
// How the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task line:
|
|
1245
|
+
// `keyed` (`VO-42`), `plain` (`#42`, a codec from before numbers were keyed), or null (none). One that writes none
|
|
1246
|
+
// renders, reads and indexes cards as it always did; the open document is written with the numbers in the codec's form
|
|
1247
|
+
// by the first write made with it (tx), whichever was installed first.
|
|
1248
|
+
get numberForm() {
|
|
1249
|
+
if (this._numberForm === undefined) {
|
|
1250
|
+
const writes = (key, mark) => {
|
|
1251
|
+
try {
|
|
1252
|
+
const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, ...(key ? { key } : {}), status: 'pending', evidence: [], text: 'number' }] }).body;
|
|
1253
|
+
return this.codec.renderBoardCard({ id: 't_00000000', number: 1, ...(key ? { key } : {}), title: 'number', status: 'todo', body: '' }, 2).startsWith(`## ${mark}1 t_00000000 `) && task.includes(`- [ ] ${mark}2 c1 number`);
|
|
1254
|
+
} catch { return false; }
|
|
1255
|
+
};
|
|
1256
|
+
this._numberForm = writes('KB', 'KB-') ? 'keyed' : writes(null, '#') ? 'plain' : null;
|
|
1193
1257
|
}
|
|
1194
|
-
return this.
|
|
1258
|
+
return this._numberForm;
|
|
1195
1259
|
}
|
|
1260
|
+
get numbersRendered() { return this.numberForm !== null; }
|
|
1196
1261
|
|
|
1197
1262
|
// Give each card the number its document gives it (its heading, or its task line), where no other card holds that
|
|
1198
1263
|
// number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
|
|
@@ -1200,7 +1265,12 @@ class ZtrackBoard {
|
|
|
1200
1265
|
numberFromHeadings(numbers, db = this.conn) {
|
|
1201
1266
|
if (!numbers.length) return;
|
|
1202
1267
|
const give = db.prepare(`INSERT INTO ${CARD_NUMBERS} (number, task_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = ? OR number = ?)`);
|
|
1203
|
-
|
|
1268
|
+
const holder = db.prepare(`SELECT task_id FROM ${CARD_NUMBERS} WHERE number = ?`);
|
|
1269
|
+
for (const [id, number] of numbers) {
|
|
1270
|
+
const other = holder.get(number)?.task_id;
|
|
1271
|
+
if (other && other !== id) throw new Error(`cannot rebuild card numbers: ${number} names both ${other} and ${id}`);
|
|
1272
|
+
give.run(number, id, id, number);
|
|
1273
|
+
}
|
|
1204
1274
|
raiseCardNumbers(db);
|
|
1205
1275
|
}
|
|
1206
1276
|
|
|
@@ -1216,7 +1286,7 @@ class ZtrackBoard {
|
|
|
1216
1286
|
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1217
1287
|
const text = lf(read(path));
|
|
1218
1288
|
for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
|
|
1219
|
-
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[
|
|
1289
|
+
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
|
|
1220
1290
|
let records = [];
|
|
1221
1291
|
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1222
1292
|
for (const record of records) {
|
|
@@ -1234,16 +1304,17 @@ class ZtrackBoard {
|
|
|
1234
1304
|
numberMark() {
|
|
1235
1305
|
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1236
1306
|
if (!given) return null;
|
|
1237
|
-
const
|
|
1307
|
+
const keyed = this.numberForm === 'keyed';
|
|
1308
|
+
const line = (top) => `The board has given card numbers up to ${keyed ? `${this.cardKey}-` : '#'}${top}.`;
|
|
1238
1309
|
const first = this.entries('open')[0];
|
|
1239
1310
|
if (first && first.id === null) {
|
|
1240
1311
|
const text = this.texts([first])[0].text;
|
|
1241
1312
|
const said = NUMBER_MARK.exec(text);
|
|
1242
|
-
if (said &&
|
|
1243
|
-
const next = said ? text.replace(NUMBER_MARK, line) : `${text.replace(/\n*$/, '')}\n\n${line}\n\n`;
|
|
1313
|
+
if (said && (keyed ? said[1] === this.cardKey : said[1] === undefined) && Number(said[2]) >= given) return null;
|
|
1314
|
+
const next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
|
|
1244
1315
|
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1245
1316
|
}
|
|
1246
|
-
const text = `${line}\n\n`;
|
|
1317
|
+
const text = `${line(given)}\n\n`;
|
|
1247
1318
|
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1248
1319
|
}
|
|
1249
1320
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { projectCodexPaneResume } from '@volter/supercode-teams/codex-pane-resume';
|
package/connection.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The kit's connection coordinator. Module manifests and programs belong to their application publisher.
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -7,7 +7,15 @@ import { spawn } from 'node:child_process';
|
|
|
7
7
|
import { fromContext } from '@volter/teams/context';
|
|
8
8
|
import { supercodeHome } from '@volter/teams/home';
|
|
9
9
|
|
|
10
|
+
// RH2's modules, each its own program under --modules-root.
|
|
10
11
|
export const MODULE_APPS = ['arcs-rooms','orchestration-rh2'];
|
|
12
|
+
// supercode's Workplace service's registrations (supercode ADR 0041 decision 3; runhuman-2 ADR 0055 decision 3): one program,
|
|
13
|
+
// sdk/teams/workplace/main.mjs, hosts them all, each with its own credentials on both sides. Their manifests ship here, so an
|
|
14
|
+
// organization's file selects them by these names and `rh2 apply-org` reads them with --modules-root SERVICE_REGISTRATIONS.
|
|
15
|
+
export const SERVICE_APPS = ['agents','cards','machines'];
|
|
16
|
+
export const SERVICE_REGISTRATIONS = fileURLToPath(new URL('../teams/workplace/registrations/', import.meta.url));
|
|
17
|
+
export const SERVICE_PROGRAM = fileURLToPath(new URL('../teams/workplace/main.mjs', import.meta.url));
|
|
18
|
+
const SERVICE_TOKENS = { agents: ['AGENTS_WORKPLACE_TOKEN','AGENTS_TEAMS_TOKEN'], cards: ['CARDS_WORKPLACE_TOKEN','CARDS_TEAMS_TOKEN'], machines: ['MACHINES_WORKPLACE_TOKEN','MACHINES_TEAMS_TOKEN'] };
|
|
11
19
|
export function privateJson(path, value) {
|
|
12
20
|
mkdirSync(dirname(path),{recursive:true,mode:0o700});
|
|
13
21
|
const pending=`${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
@@ -16,37 +24,30 @@ export function privateJson(path, value) {
|
|
|
16
24
|
export async function connectionCall(record, side, method, path, body, token) {
|
|
17
25
|
const origin=side==='teams'?record.teams.server:record.origin;
|
|
18
26
|
// Registration, consent, exchange and renewal have receiver-staged random values in their bodies:
|
|
19
|
-
// their digest is one operation's key, retained on a retry.
|
|
20
|
-
// with the same keys/results. Keep its key in custody until an answer is received.
|
|
21
|
-
const lease=side==='teams'&&method==='POST'&&path.endsWith('/issuer-keyring');
|
|
22
|
-
let operationKey;
|
|
23
|
-
if(lease){
|
|
24
|
-
const digest=createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
|
25
|
-
if(record.issuerKeyOperation?.digest!==digest)record.issuerKeyOperation={digest,key:`connect-${randomBytes(24).toString('hex')}`};
|
|
26
|
-
operationKey=record.issuerKeyOperation.key;
|
|
27
|
-
privateJson(connectionPath(record.root,record.id),record);
|
|
28
|
-
}
|
|
27
|
+
// their digest is one operation's key, retained on a retry.
|
|
29
28
|
const began=Date.now();
|
|
30
|
-
const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(
|
|
29
|
+
const response=await fetch(`${origin}${path}`,{method,redirect:'manual',signal:AbortSignal.timeout(9000),
|
|
31
30
|
headers:{accept:'application/json',...(token?{authorization:`Bearer ${token}`} : {}),
|
|
32
31
|
...(side==='rh2'?{origin:record.origin,'x-rh2-organization':record.organization}:{}),
|
|
33
32
|
...(body===undefined?{}:{'content-type':'application/json'}),
|
|
34
|
-
...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key'
|
|
33
|
+
...(['POST','PATCH','DELETE'].includes(method)?{'idempotency-key':`connect-${createHash('sha256').update(`${method}\n${path}\n${JSON.stringify(body)}`).digest('hex')}`}:{})},
|
|
35
34
|
...(body===undefined?{}:{body:JSON.stringify(body)})});
|
|
36
35
|
const text=await response.text();
|
|
37
36
|
record.calls??=[];record.calls.push({side,method,path,status:response.status,ms:Date.now()-began,bytes:Buffer.byteLength(text)});record.calls=record.calls.slice(-200);
|
|
38
|
-
let payload;try{payload=JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
|
|
37
|
+
let payload;try{payload=text===''&&response.status===204?{}:JSON.parse(text);}catch{throw new Error(`${side} ${method} ${path}: invalid JSON (${response.status})`);}
|
|
39
38
|
if(!response.ok)throw Object.assign(new Error(`${side} ${method} ${path}: ${payload.error?.code??response.status}`),{code:payload.error?.code,status:response.status});
|
|
40
|
-
if(lease){delete record.issuerKeyOperation;privateJson(connectionPath(record.root,record.id),record);}
|
|
41
39
|
return payload;
|
|
42
40
|
}
|
|
43
41
|
export function connectionPath(root,id){return join(root,'apps','connections',id,'connection.json');}
|
|
44
42
|
export function generateModuleConfig(root, record, grant) {
|
|
43
|
+
if(SERVICE_APPS.includes(grant.name))return generateServiceConfig(root,record,grant);
|
|
45
44
|
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,grant.name);
|
|
46
45
|
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
47
46
|
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
48
47
|
writeFileSync(custody,`RH2_TOKEN=${grant.rh2.accessToken}\n${grant.teams?`TEAMS_TOKEN=${grant.teams.credential.token}\n`:''}`,{mode:0o600});chmodSync(custody,0o600);
|
|
49
|
-
|
|
48
|
+
// The arcs module seats card agents by resolving them in the registration that provides them: the connection's agents.
|
|
49
|
+
const agents=record.grants.find(g=>g.name==='agents'&&g.clientId), cards=record.grants.find(g=>g.name==='cards'&&g.rh2);
|
|
50
|
+
const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,principalId:grant.rh2.principalId,tokenEnv:'RH2_TOKEN',...(agents?{agentsRegistration:agents.clientId}:{}),...(cards?{cardsPrincipalId:cards.rh2.principalId}:{})},
|
|
50
51
|
teams:{server:record.teams.server,team:record.teams.team,source:record.source,tokenEnv:'TEAMS_TOKEN'},
|
|
51
52
|
envFile:custody,stateFile:join(dir,'state.json'),defaultFloor:record.floor,
|
|
52
53
|
source:{key:`connection-${record.scope}`,namespace:`cx${record.scope}`,name:`${record.organization} / ${record.source}`,home:root,...(record.teamsContext?{teamsContext:record.teamsContext}:{}),...(process.env.SUPERCODE_BIN?{program:process.env.SUPERCODE_BIN}:{}),harness:record.harness??'hermes'},
|
|
@@ -56,6 +57,30 @@ export function generateModuleConfig(root, record, grant) {
|
|
|
56
57
|
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
57
58
|
return configPath;
|
|
58
59
|
}
|
|
60
|
+
/** The Workplace service's config and custody, written from the connection alone (no hand-written config): every installed
|
|
61
|
+
* service registration's credentials, ids and state, and the home's registry entry for `grant`. One config serves all. */
|
|
62
|
+
export function generateServiceConfig(root, record, grant) {
|
|
63
|
+
const folder=dirname(connectionPath(root,record.id)), dir=join(folder,'service');
|
|
64
|
+
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
65
|
+
const custody=join(dir,'credentials.env'), configPath=join(dir,'config.json');
|
|
66
|
+
const held=Object.fromEntries(record.grants.filter(g=>SERVICE_APPS.includes(g.name)&&g.rh2&&g.teams).map(g=>[g.name,g]));
|
|
67
|
+
writeFileSync(custody,Object.entries(held).map(([name,g])=>`${SERVICE_TOKENS[name][0]}=${g.rh2.accessToken}\n${SERVICE_TOKENS[name][1]}=${g.teams.credential.token}\n`).join(''),{mode:0o600});chmodSync(custody,0o600);
|
|
68
|
+
const agents=held.agents;
|
|
69
|
+
if(agents){
|
|
70
|
+
const section=name=>({registration:held[name].clientId,principal:held[name].rh2.principalId,tokenEnv:SERVICE_TOKENS[name][0],teamsTokenEnv:SERVICE_TOKENS[name][1],stateFile:join(dir,`${name}.state.json`)});
|
|
71
|
+
const config={
|
|
72
|
+
teams:{server:record.teams.server,team:record.teams.team,installation:agents.teams.installation.id,source:record.source,tokenEnv:SERVICE_TOKENS.agents[1]},
|
|
73
|
+
rh2:{baseUrl:record.origin,organization:record.organization,registration:agents.clientId,...(record.issuer!==record.origin?{issuer:record.issuer}:{}),tokenEnv:SERVICE_TOKENS.agents[0],principal:agents.rh2.principalId},
|
|
74
|
+
source:{namespace:`cx${record.scope}`},envFile:custody,stateFile:join(dir,'agents.state.json'),
|
|
75
|
+
...(record.webhook?{webhook:record.webhook}:{}),
|
|
76
|
+
...(held.cards?{cards:section('cards')}:{}),...(held.machines?{machines:section('machines')}:{}),
|
|
77
|
+
};
|
|
78
|
+
privateJson(configPath,config);
|
|
79
|
+
}
|
|
80
|
+
privateJson(join(root,'apps',`${grant.name}.${record.id}.json`),{app:grant.name,connection:record.id,configPath,program:SERVICE_PROGRAM,
|
|
81
|
+
origin:record.origin,organizationId:record.organization,principalId:grant.rh2.principalId,installationId:grant.rh2.installationId,expiresAt:grant.rh2.expiresAt});
|
|
82
|
+
return configPath;
|
|
83
|
+
}
|
|
59
84
|
export function startConnectionRunner(root,id) {
|
|
60
85
|
const recordPath=connectionPath(root,id);
|
|
61
86
|
if(!existsSync(recordPath)||!connectionCanRun(JSON.parse(readFileSync(recordPath,'utf8'))))return null;
|
|
@@ -63,21 +88,35 @@ export function startConnectionRunner(root,id) {
|
|
|
63
88
|
const child=spawn(process.execPath,[fileURLToPath(new URL('./bin/connection-runner.mjs',import.meta.url)),'--root',root,'--connection',id],{detached:true,stdio:['ignore',fd,fd],env:process.env});
|
|
64
89
|
closeSync(fd);child.unref();return child.pid;
|
|
65
90
|
}
|
|
66
|
-
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string';}
|
|
91
|
+
export function connectionCanRun(record){return record.preparing===false&&record.paused===false&&typeof record.connectedAt==='string'&&!record.disconnectedAt;}
|
|
67
92
|
export async function connectWorkplace(flags) {
|
|
68
|
-
if(!flags.root||!flags['ticket-file']||!flags
|
|
93
|
+
if(!flags.root||!flags['ticket-file']||!flags.source)throw new Error('connect needs --root, --ticket-file and --source (one board event source)');
|
|
69
94
|
const root=resolve(flags.root), ticket=JSON.parse(readFileSync(flags['ticket-file'],'utf8'));
|
|
70
95
|
if(!/^[0-9a-f]{24}$/.test(ticket.scope??''))throw new Error('connection ticket needs a source namespace; apply to a new private output path');
|
|
71
|
-
if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(
|
|
96
|
+
if(ticket.version!==1||!Array.isArray(ticket.grants)||!ticket.grants.length||ticket.grants.some(g=>!MODULE_APPS.includes(g.name)&&!SERVICE_APPS.includes(g.name))||new Set(ticket.grants.map(g=>g.name)).size!==ticket.grants.length)throw new Error(`unsupported or duplicate connection apps (connect takes ${[...MODULE_APPS,...SERVICE_APPS].join(', ')})`);
|
|
97
|
+
const names=ticket.grants.map(g=>g.name);
|
|
98
|
+
// The service is the agents registration's host: its other registrations run only inside it.
|
|
99
|
+
if(names.some(name=>name!=='agents'&&SERVICE_APPS.includes(name))&&!names.includes('agents'))throw new Error('cards and machines run in the Workplace service, which the agents registration hosts: select agents too');
|
|
100
|
+
if(names.some(name=>MODULE_APPS.includes(name))&&!flags['modules-root'])throw new Error(`--modules-root names where ${names.filter(name=>MODULE_APPS.includes(name)).join(' and ')} are`);
|
|
101
|
+
if(Boolean(flags['webhook-url'])!==Boolean(flags['webhook-listen']))throw new Error('--webhook-url and --webhook-listen go together: where the Workplace sends the agents registration\'s events, and the loopback address the service listens on');
|
|
102
|
+
const moduleRoot=flags['modules-root']?resolve(flags['modules-root']):null;
|
|
72
103
|
// An explicit local credential input is useful for an owned stack; a normal connect uses its existing Teams context.
|
|
73
|
-
let teams,owner;
|
|
104
|
+
let teams,owner,teamsContext=flags.context??process.env.SUPERCODE_CONTEXT;
|
|
74
105
|
if(flags['teams-token-file']){
|
|
75
106
|
if(!flags['teams-url']||!flags.team)throw new Error('--teams-token-file needs --teams-url and --team');
|
|
76
107
|
teams={server:new URL(flags['teams-url']).origin,team:flags.team};owner=readFileSync(flags['teams-token-file'],'utf8').trim();
|
|
77
108
|
}else{
|
|
78
|
-
|
|
109
|
+
// An agent session connects as its agent: its delegated context, never the person's who signed the box in (the
|
|
110
|
+
// Teams verbs' rule, agentContextName). A person's context named from an agent session is refused, as a grant is.
|
|
79
111
|
const {WorkspaceContexts}=await import('@volter/teams/context');
|
|
80
|
-
|
|
112
|
+
const {agentSessionEnvironment,agentSessionHint,agentContextName}=await import('@volter/supercode-teams/agent-session');
|
|
113
|
+
const contexts=new WorkspaceContexts(supercodeHome());
|
|
114
|
+
// Asked of the daemon whatever the environment says: a resumed session can lack its launch's SUPERCODE_AGENT_ID,
|
|
115
|
+
// and the daemon's retained binding is what names its agent (workspace.mjs does the same for every Teams verb).
|
|
116
|
+
const env=await agentSessionEnvironment(process.env,agentSessionHint(process.env));
|
|
117
|
+
teamsContext=agentContextName(contexts,env,flags.context??null);
|
|
118
|
+
const {context}=fromContext({supercodeHome:supercodeHome(),name:teamsContext});
|
|
119
|
+
teams={server:context.origin,team:context.team_id};owner=contexts.credential(context.credential_ref).token;
|
|
81
120
|
}
|
|
82
121
|
for(const origin of [ticket.origin,ticket.issuer,teams.server]){
|
|
83
122
|
const url=new URL(origin);if(url.origin!==origin||url.username||url.password||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('connection endpoints must be exact HTTPS origins or loopback HTTP');
|
|
@@ -85,10 +124,14 @@ export async function connectWorkplace(flags) {
|
|
|
85
124
|
const id=createHash('sha256').update(`${ticket.scope}\0${ticket.issuer}\0${ticket.organization}\0${teams.server}\0${teams.team}\0${flags.source}`).digest('hex').slice(0,24);
|
|
86
125
|
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
87
126
|
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
88
|
-
const record=prior??{version:1,id,scope:ticket.scope,teamsContext
|
|
89
|
-
selection:ticket.grants.map(g=>g.name),moduleRoot
|
|
127
|
+
const record=prior??{version:1,id,scope:ticket.scope,teamsContext,origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,source:flags.source,
|
|
128
|
+
selection:ticket.grants.map(g=>g.name),moduleRoot,...(flags['webhook-url']?{webhook:{url:flags['webhook-url'],listen:flags['webhook-listen']}}:{}),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
|
|
90
129
|
record.root=root;
|
|
91
|
-
if(prior
|
|
130
|
+
if(prior?.disconnectedAt)throw new Error('this connection was disconnected; apply the organization file again for a new ticket');
|
|
131
|
+
// The service's event address is part of the connection: a different one is a new connection, never silently dropped.
|
|
132
|
+
const webhook=flags['webhook-url']?{url:flags['webhook-url'],listen:flags['webhook-listen']}:undefined;
|
|
133
|
+
if(prior&&webhook&&JSON.stringify(prior.webhook??null)!==JSON.stringify(webhook))throw new Error('the existing connection has a different --webhook-url/--webhook-listen; use a new connection ticket before replacing it');
|
|
134
|
+
if(prior&&(record.source!==flags.source||(record.moduleRoot??null)!==moduleRoot||record.selection.join(',')!==ticket.grants.map(g=>g.name).join(',')))throw new Error('the existing connection has different module/source settings; use a new connection ticket before replacing it');
|
|
92
135
|
const lock=join(dirname(path),'runner.lock');
|
|
93
136
|
if(prior&&existsSync(lock)) {
|
|
94
137
|
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
@@ -106,7 +149,8 @@ export async function connectWorkplace(flags) {
|
|
|
106
149
|
for(const selected of ticket.grants){
|
|
107
150
|
let grant=record.grants.find(g=>g.name===selected.name);
|
|
108
151
|
if(grant && JSON.stringify([grant.manifest,grant.teamsManifest,grant.rooms])!==JSON.stringify([selected.manifest,selected.teamsManifest,selected.rooms]))throw new Error('existing connection consent differs from the ticket');
|
|
109
|
-
if(!grant){grant={name:selected.name,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
|
|
152
|
+
if(!grant){grant={name:selected.name,clientId:selected.clientId,rooms:selected.rooms,manifest:selected.manifest,teamsManifest:selected.teamsManifest};record.grants.push(grant);privateJson(path,record);}
|
|
153
|
+
if(SERVICE_APPS.includes(grant.name)&&!selected.teamsManifest)throw new Error(`${grant.name}: its Teams manifest is missing from the ticket (apply with --modules-root naming ${SERVICE_REGISTRATIONS})`);
|
|
110
154
|
if(!grant.rh2){
|
|
111
155
|
const recovering=typeof grant.redemption==='string';
|
|
112
156
|
grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
|
|
@@ -136,13 +180,13 @@ export async function connectWorkplace(flags) {
|
|
|
136
180
|
grant.teams=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:grant.registration.id,app_secret:grant.registration.secret,code:grant.consent.code,redirect_url:selected.teamsManifest.redirect_urls[0],credential_token:grant.stagedCredential});privateJson(path,record);
|
|
137
181
|
delete grant.consent;delete grant.registration.secret;privateJson(path,record);
|
|
138
182
|
}
|
|
139
|
-
generateModuleConfig(root,record,grant);
|
|
183
|
+
if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
|
|
140
184
|
}
|
|
141
|
-
|
|
185
|
+
// The service's config names every one of its registrations, so it is written once all of them hold credentials.
|
|
186
|
+
for(const grant of record.grants.filter(g=>SERVICE_APPS.includes(g.name)))generateServiceConfig(root,record,grant);
|
|
187
|
+
if(!record.issuerRegistration){record.issuerStagedSecret??=`sca_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerRegistration=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps`,{name:`Workplace issuer (${id.slice(0,8)})`,description:'The Workplace as a delegated issuer, verified against its JWKS',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
|
|
142
188
|
if(!record.issuerConsent&&!record.issuerCredential){record.issuerStagedCode??=`sci_${randomBytes(32).toString('base64url')}`;privateJson(path,record);record.issuerConsent=(await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/apps/${record.issuerRegistration.id}/installations`,{redirect_url:'http://127.0.0.1/connected',state:id,code:record.issuerStagedCode},owner)).data;privateJson(path,record);}
|
|
143
189
|
if(!record.issuerCredential){record.issuerStagedCredential??=newInstallationToken();privateJson(path,record);record.issuerCredential=await connectionCall(record,'teams','POST','/api/v1/apps/token',{app_id:record.issuerRegistration.id,app_secret:record.issuerStagedSecret,code:record.issuerStagedCode,redirect_url:'http://127.0.0.1/connected',credential_token:record.issuerStagedCredential});privateJson(path,record);delete record.issuerConsent;delete record.issuerRegistration.secret;privateJson(path,record);}
|
|
144
|
-
const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
|
|
145
|
-
await connectionCall(record,'teams','POST',`/api/v1/teams/${encodeURIComponent(teams.team)}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results:[]},record.issuerCredential.credential.token);
|
|
146
190
|
privateJson(join(dirname(path),'standing.json'),[]);
|
|
147
191
|
record.connectedAt??=new Date().toISOString();
|
|
148
192
|
record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
|
|
@@ -150,4 +194,38 @@ export async function connectWorkplace(flags) {
|
|
|
150
194
|
return {connection:id,root,modules:record.grants.map(g=>g.name),runner:pid,calls:record.calls.map(({side,method,path,status,ms,bytes})=>({side,method,path,status,ms,bytes}))};
|
|
151
195
|
}
|
|
152
196
|
|
|
197
|
+
/** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
|
|
198
|
+
* installation it holds leaves both sides by itself (each Workplace installation through its own leave door, each
|
|
199
|
+
* Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
|
|
200
|
+
* consented apps with no installation; an admin deregisters them. The custody record stays, marked disconnected, for
|
|
201
|
+
* audit; it can never run again. */
|
|
202
|
+
export async function disconnectWorkplace(flags) {
|
|
203
|
+
if(!flags.root||!flags.connection)throw new Error('disconnect needs --root and --connection (the id connect printed)');
|
|
204
|
+
const root=resolve(flags.root), path=connectionPath(root,flags.connection);
|
|
205
|
+
if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
|
|
206
|
+
const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
|
|
207
|
+
record.paused=true;privateJson(path,record);
|
|
208
|
+
const lock=join(dirname(path),'runner.lock');
|
|
209
|
+
if(existsSync(lock)){
|
|
210
|
+
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
211
|
+
try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
212
|
+
for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
|
|
213
|
+
}
|
|
214
|
+
const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
|
|
215
|
+
const leave=async(name,side,method,door,token)=>{
|
|
216
|
+
try{await connectionCall(record,side,method,door,side==='rh2'?{}:undefined,token);left.push({name,side,left:true});}
|
|
217
|
+
catch(error){if(error.status===404||error.code==='installation-unknown'){left.push({name,side,left:true,already:true});}else left.push({name,side,left:false,code:error.code??error.status??error.message});}
|
|
218
|
+
privateJson(path,record);
|
|
219
|
+
};
|
|
220
|
+
for(const grant of record.grants){
|
|
221
|
+
if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
|
|
222
|
+
if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/leave`,grant.rh2.accessToken);
|
|
223
|
+
const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
|
|
224
|
+
}
|
|
225
|
+
// The token exchange answers {installation, credential: {token, …}} (renewTeams reads the same shape).
|
|
226
|
+
if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.credential?.token);
|
|
227
|
+
record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
|
|
228
|
+
return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
|
|
229
|
+
}
|
|
230
|
+
|
|
153
231
|
export function newInstallationToken(){return `sct_cred_${randomBytes(16).toString('hex')}.${randomBytes(32).toString('base64url')}`;}
|