@volter/supercode-orchestrator 0.5.207 → 0.5.208
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 +119 -32
- 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 +111 -44
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +95 -26
- 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 +5 -4
- 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,7 +827,7 @@ 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
833
|
if (!fields.length) return;
|
|
@@ -856,9 +886,17 @@ class ZtrackBoard {
|
|
|
856
886
|
this.dropOverlay();
|
|
857
887
|
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
888
|
this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
|
|
889
|
+
this.conn.exec("CREATE UNIQUE INDEX temp.supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
|
|
859
890
|
this.overlay = true; this.statements.clear();
|
|
860
891
|
}
|
|
861
|
-
const
|
|
892
|
+
const rebuilding = !overlay && this.get('rebuild_from_documents');
|
|
893
|
+
const initial = !overlay && (rebuilding || !this.one('SELECT 1 FROM main.tasks LIMIT 1'));
|
|
894
|
+
if (rebuilding) {
|
|
895
|
+
if (plan.errors.size) throw new Error(`cannot rebuild board from invalid documents: ${[...plan.errors].map(([id, why]) => `${id}: ${why}`).join('; ')}`);
|
|
896
|
+
// The imported cache can predate cards added, edited or removed in the documents. Keep its history, not its
|
|
897
|
+
// obsolete card state. Every document was re-read, including unchanged archive spans.
|
|
898
|
+
for (const { id } of this.statement('SELECT id FROM main.tasks').all()) if (!plan.rows.has(id)) plan.remove.add(id);
|
|
899
|
+
}
|
|
862
900
|
const by = 'document-editor';
|
|
863
901
|
const events = [];
|
|
864
902
|
const fingerprintOf = (id) => {
|
|
@@ -875,6 +913,7 @@ class ZtrackBoard {
|
|
|
875
913
|
// heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
|
|
876
914
|
// the store's numbers: a number typed into a heading or pasted with a section changes nothing.
|
|
877
915
|
if (initial) {
|
|
916
|
+
if (rebuilding) this.conn.exec(`DELETE FROM ${CARD_NUMBERS}`);
|
|
878
917
|
this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
|
|
879
918
|
raiseCardNumbers(this.conn, this.documentNumbers().highest);
|
|
880
919
|
}
|
|
@@ -884,6 +923,10 @@ class ZtrackBoard {
|
|
|
884
923
|
const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
|
|
885
924
|
this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
|
|
886
925
|
this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
|
|
926
|
+
if (rebuilding && ENDED.has(rows.state.stage ?? rows.task.status) && oldTask.current_run_id) {
|
|
927
|
+
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);
|
|
928
|
+
this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?').run(id);
|
|
929
|
+
}
|
|
887
930
|
this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
|
|
888
931
|
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
932
|
const after = fingerprintOf(id);
|
|
@@ -894,7 +937,11 @@ class ZtrackBoard {
|
|
|
894
937
|
}
|
|
895
938
|
for (const id of plan.checked) this.errors.delete(id);
|
|
896
939
|
for (const [id, reason] of plan.errors) this.errors.set(id, reason);
|
|
940
|
+
// A read overlays new document cards before the owner commits them. Give that
|
|
941
|
+
// connection-local view the same names as the subsequent write.
|
|
942
|
+
fillReadableIds(this.conn);
|
|
897
943
|
if (overlay) return;
|
|
944
|
+
if (rebuilding) this.drop('rebuild_from_documents');
|
|
898
945
|
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
946
|
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
947
|
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 +988,7 @@ class ZtrackBoard {
|
|
|
941
988
|
const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
|
|
942
989
|
const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
|
|
943
990
|
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,
|
|
991
|
+
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
992
|
status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? old.created_at ?? now(), priority: card.priority ?? 0,
|
|
946
993
|
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
994
|
const state = { task_id: id, workspace_anchor: workspace?.[2] ?? null, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
|
|
@@ -950,6 +997,7 @@ class ZtrackBoard {
|
|
|
950
997
|
// carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
|
|
951
998
|
paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
|
|
952
999
|
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) };
|
|
1000
|
+
if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.status;
|
|
953
1001
|
return { task, state };
|
|
954
1002
|
}
|
|
955
1003
|
|
|
@@ -958,17 +1006,17 @@ class ZtrackBoard {
|
|
|
958
1006
|
return card.acceptanceCriteria.map((task) => {
|
|
959
1007
|
const key = `${id}:${task.id}`;
|
|
960
1008
|
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 =
|
|
1009
|
+
// The native identity and declared state travel with the line. Runtime history stays in the store, joined by
|
|
1010
|
+
// this same identity, including when that store was rebuilt from a cache older than the subtask.
|
|
1011
|
+
const stored = this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
|
|
1012
|
+
const storedState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
|
|
965
1013
|
const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
|
|
966
|
-
const taskCard = { ...
|
|
1014
|
+
const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
|
|
967
1015
|
id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
|
|
968
1016
|
status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
|
|
969
1017
|
metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
|
|
970
|
-
assignee: task.metadata
|
|
971
|
-
machine: task.metadata
|
|
1018
|
+
assignee: task.metadata && 'assignee' in task.metadata ? task.metadata.assignee : stored?.assignee ?? card.assignee,
|
|
1019
|
+
machine: task.metadata && 'machine' in task.metadata ? task.metadata.machine : storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
|
|
972
1020
|
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
1021
|
if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
|
|
974
1022
|
return [tid, taskCard, key, task.sources, task.number];
|
|
@@ -1156,9 +1204,13 @@ class ZtrackBoard {
|
|
|
1156
1204
|
const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
|
|
1157
1205
|
const blocks = json(s.blocked_by, []);
|
|
1158
1206
|
const messages = blocks.filter((block) => block.kind === 'message');
|
|
1159
|
-
//
|
|
1160
|
-
// output
|
|
1207
|
+
// Keep the identity-to-number binding and declared state durable with its line; a stale runtime cache cannot
|
|
1208
|
+
// supply these for a newer subtask. Run output, clocks and active claims remain the runtime store's.
|
|
1161
1209
|
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,
|
|
1210
|
+
metadata: { id: r.id, status, assignee: r.assignee, machine: s.machine,
|
|
1211
|
+
properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => declared(k, v))),
|
|
1212
|
+
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}` : ''),
|
|
1213
|
+
branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, every: s.every || undefined } },
|
|
1162
1214
|
...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
|
|
1163
1215
|
waitingOn: messages[0]?.id,
|
|
1164
1216
|
blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
|
|
@@ -1172,27 +1224,36 @@ class ZtrackBoard {
|
|
|
1172
1224
|
return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
1173
1225
|
}
|
|
1174
1226
|
|
|
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
|
|
1227
|
+
// A card's number, shown first in its heading with its board's key (`## VO-42 t_… — …`): the store's (store.mjs
|
|
1228
|
+
// layOutNumbers, boardKey). An edit to a heading's number changes nothing; a store made again from its documents takes
|
|
1229
|
+
// it back (numberFromHeadings). A card's record keeps the number and key where the document codec reads them, after
|
|
1230
|
+
// the id.
|
|
1178
1231
|
numberOf(id) {
|
|
1179
1232
|
if (!this.numbersRendered) return {};
|
|
1180
1233
|
const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
|
|
1181
|
-
return number ? { number } : {};
|
|
1234
|
+
return number ? { number, ...(this.numberForm === 'keyed' ? { key: this.cardKey } : {}) } : {};
|
|
1182
1235
|
}
|
|
1183
1236
|
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1237
|
+
/** The board's key (store.mjs boardKey). */
|
|
1238
|
+
get cardKey() { return this._cardKey ??= boardKey(this.root, this.slug); }
|
|
1239
|
+
|
|
1240
|
+
// How the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task line:
|
|
1241
|
+
// `keyed` (`VO-42`), `plain` (`#42`, a codec from before numbers were keyed), or null (none). One that writes none
|
|
1242
|
+
// renders, reads and indexes cards as it always did; the open document is written with the numbers in the codec's form
|
|
1243
|
+
// by the first write made with it (tx), whichever was installed first.
|
|
1244
|
+
get numberForm() {
|
|
1245
|
+
if (this._numberForm === undefined) {
|
|
1246
|
+
const writes = (key, mark) => {
|
|
1247
|
+
try {
|
|
1248
|
+
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;
|
|
1249
|
+
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`);
|
|
1250
|
+
} catch { return false; }
|
|
1251
|
+
};
|
|
1252
|
+
this._numberForm = writes('KB', 'KB-') ? 'keyed' : writes(null, '#') ? 'plain' : null;
|
|
1193
1253
|
}
|
|
1194
|
-
return this.
|
|
1254
|
+
return this._numberForm;
|
|
1195
1255
|
}
|
|
1256
|
+
get numbersRendered() { return this.numberForm !== null; }
|
|
1196
1257
|
|
|
1197
1258
|
// Give each card the number its document gives it (its heading, or its task line), where no other card holds that
|
|
1198
1259
|
// number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
|
|
@@ -1200,7 +1261,12 @@ class ZtrackBoard {
|
|
|
1200
1261
|
numberFromHeadings(numbers, db = this.conn) {
|
|
1201
1262
|
if (!numbers.length) return;
|
|
1202
1263
|
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
|
-
|
|
1264
|
+
const holder = db.prepare(`SELECT task_id FROM ${CARD_NUMBERS} WHERE number = ?`);
|
|
1265
|
+
for (const [id, number] of numbers) {
|
|
1266
|
+
const other = holder.get(number)?.task_id;
|
|
1267
|
+
if (other && other !== id) throw new Error(`cannot rebuild card numbers: ${number} names both ${other} and ${id}`);
|
|
1268
|
+
give.run(number, id, id, number);
|
|
1269
|
+
}
|
|
1204
1270
|
raiseCardNumbers(db);
|
|
1205
1271
|
}
|
|
1206
1272
|
|
|
@@ -1216,7 +1282,7 @@ class ZtrackBoard {
|
|
|
1216
1282
|
for (const path of [this.path, this.archive, ...rotated]) {
|
|
1217
1283
|
const text = lf(read(path));
|
|
1218
1284
|
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)?.[
|
|
1285
|
+
if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
|
|
1220
1286
|
let records = [];
|
|
1221
1287
|
try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
|
|
1222
1288
|
for (const record of records) {
|
|
@@ -1234,16 +1300,17 @@ class ZtrackBoard {
|
|
|
1234
1300
|
numberMark() {
|
|
1235
1301
|
const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
|
|
1236
1302
|
if (!given) return null;
|
|
1237
|
-
const
|
|
1303
|
+
const keyed = this.numberForm === 'keyed';
|
|
1304
|
+
const line = (top) => `The board has given card numbers up to ${keyed ? `${this.cardKey}-` : '#'}${top}.`;
|
|
1238
1305
|
const first = this.entries('open')[0];
|
|
1239
1306
|
if (first && first.id === null) {
|
|
1240
1307
|
const text = this.texts([first])[0].text;
|
|
1241
1308
|
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`;
|
|
1309
|
+
if (said && (keyed ? said[1] === this.cardKey : said[1] === undefined) && Number(said[2]) >= given) return null;
|
|
1310
|
+
const next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
|
|
1244
1311
|
return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
|
|
1245
1312
|
}
|
|
1246
|
-
const text = `${line}\n\n`;
|
|
1313
|
+
const text = `${line(given)}\n\n`;
|
|
1247
1314
|
return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
|
|
1248
1315
|
}
|
|
1249
1316
|
|
|
@@ -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,12 +88,18 @@ 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
104
|
let teams,owner;
|
|
74
105
|
if(flags['teams-token-file']){
|
|
@@ -86,9 +117,13 @@ export async function connectWorkplace(flags) {
|
|
|
86
117
|
const path=connectionPath(root,id),prior=existsSync(path)?JSON.parse(readFileSync(path,'utf8')):null;
|
|
87
118
|
if(!prior&&!(Date.parse(ticket.expiresAt)>Date.now()))throw new Error('connection ticket expired; apply the organization file again');
|
|
88
119
|
const record=prior??{version:1,id,scope:ticket.scope,teamsContext:flags.context??process.env.SUPERCODE_CONTEXT,origin:ticket.origin,issuer:ticket.issuer,organization:ticket.organization,teams,source:flags.source,
|
|
89
|
-
selection:ticket.grants.map(g=>g.name),moduleRoot
|
|
120
|
+
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
121
|
record.root=root;
|
|
91
|
-
if(prior
|
|
122
|
+
if(prior?.disconnectedAt)throw new Error('this connection was disconnected; apply the organization file again for a new ticket');
|
|
123
|
+
// The service's event address is part of the connection: a different one is a new connection, never silently dropped.
|
|
124
|
+
const webhook=flags['webhook-url']?{url:flags['webhook-url'],listen:flags['webhook-listen']}:undefined;
|
|
125
|
+
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');
|
|
126
|
+
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
127
|
const lock=join(dirname(path),'runner.lock');
|
|
93
128
|
if(prior&&existsSync(lock)) {
|
|
94
129
|
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
@@ -106,7 +141,8 @@ export async function connectWorkplace(flags) {
|
|
|
106
141
|
for(const selected of ticket.grants){
|
|
107
142
|
let grant=record.grants.find(g=>g.name===selected.name);
|
|
108
143
|
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);}
|
|
144
|
+
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);}
|
|
145
|
+
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
146
|
if(!grant.rh2){
|
|
111
147
|
const recovering=typeof grant.redemption==='string';
|
|
112
148
|
grant.redemption??=randomBytes(32).toString('base64url');privateJson(path,record);
|
|
@@ -136,13 +172,13 @@ export async function connectWorkplace(flags) {
|
|
|
136
172
|
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
173
|
delete grant.consent;delete grant.registration.secret;privateJson(path,record);
|
|
138
174
|
}
|
|
139
|
-
generateModuleConfig(root,record,grant);
|
|
175
|
+
if(!SERVICE_APPS.includes(grant.name))generateModuleConfig(root,record,grant);
|
|
140
176
|
}
|
|
141
|
-
|
|
177
|
+
// The service's config names every one of its registrations, so it is written once all of them hold credentials.
|
|
178
|
+
for(const grant of record.grants.filter(g=>SERVICE_APPS.includes(g.name)))generateServiceConfig(root,record,grant);
|
|
179
|
+
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
180
|
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
181
|
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
182
|
privateJson(join(dirname(path),'standing.json'),[]);
|
|
147
183
|
record.connectedAt??=new Date().toISOString();
|
|
148
184
|
record.preparing=false;record.paused=Boolean(flags['no-start']);privateJson(path,record);
|
|
@@ -150,4 +186,37 @@ export async function connectWorkplace(flags) {
|
|
|
150
186
|
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
187
|
}
|
|
152
188
|
|
|
189
|
+
/** Undo a connection with its own credentials, no admin needed (runhuman-2 ADR 0055 decision 3): its runner stops, every
|
|
190
|
+
* installation it holds leaves both sides by itself (each Workplace installation through its own uninstall door, each
|
|
191
|
+
* Teams installation and the issuer's by deleting itself), and the home's registry entries go. The registrations stay, as
|
|
192
|
+
* consented apps with no installation; an admin deregisters them. The custody record stays, marked disconnected, for
|
|
193
|
+
* audit; it can never run again. */
|
|
194
|
+
export async function disconnectWorkplace(flags) {
|
|
195
|
+
if(!flags.root||!flags.connection)throw new Error('disconnect needs --root and --connection (the id connect printed)');
|
|
196
|
+
const root=resolve(flags.root), path=connectionPath(root,flags.connection);
|
|
197
|
+
if(!existsSync(path))throw new Error(`no connection ${flags.connection} in ${root}`);
|
|
198
|
+
const record=JSON.parse(readFileSync(path,'utf8'));record.root=root;
|
|
199
|
+
record.paused=true;privateJson(path,record);
|
|
200
|
+
const lock=join(dirname(path),'runner.lock');
|
|
201
|
+
if(existsSync(lock)){
|
|
202
|
+
const running=JSON.parse(readFileSync(lock,'utf8'));
|
|
203
|
+
try{process.kill(running.pid,'SIGTERM');}catch(error){if(error.code!=='ESRCH')throw error;}
|
|
204
|
+
for(let waited=0;waited<20000;waited+=250){try{process.kill(running.pid,0);}catch{break;}await new Promise(done=>setTimeout(done,250));}
|
|
205
|
+
}
|
|
206
|
+
const team=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`, left=[];
|
|
207
|
+
const leave=async(name,side,method,door,token)=>{
|
|
208
|
+
try{await connectionCall(record,side,method,door,side==='rh2'?{}:undefined,token);left.push({name,side,left:true});}
|
|
209
|
+
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});}
|
|
210
|
+
privateJson(path,record);
|
|
211
|
+
};
|
|
212
|
+
for(const grant of record.grants){
|
|
213
|
+
if(grant.teams?.installation?.id)await leave(grant.name,'teams','DELETE',`${team}/app-installations/${encodeURIComponent(grant.teams.installation.id)}`,grant.teams.credential.token);
|
|
214
|
+
if(grant.rh2?.installationId)await leave(grant.name,'rh2','POST',`/api/v3/automation/installations/${encodeURIComponent(grant.rh2.installationId)}/uninstall`,grant.rh2.accessToken);
|
|
215
|
+
const entry=join(root,'apps',`${grant.name}.${record.id}.json`);if(existsSync(entry))unlinkSync(entry);
|
|
216
|
+
}
|
|
217
|
+
if(record.issuerCredential?.installation?.id)await leave('issuer','teams','DELETE',`${team}/app-installations/${encodeURIComponent(record.issuerCredential.installation.id)}`,record.issuerCredential.token);
|
|
218
|
+
record.disconnectedAt=new Date().toISOString();record.left=left;privateJson(path,record);
|
|
219
|
+
return {connection:record.id,root,left,registrations:[...record.grants.map(g=>g.registration?.id).filter(Boolean),record.issuerRegistration?.id].filter(Boolean)};
|
|
220
|
+
}
|
|
221
|
+
|
|
153
222
|
export function newInstallationToken(){return `sct_cred_${randomBytes(16).toString('hex')}.${randomBytes(32).toString('base64url')}`;}
|
package/index.d.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function agentScope(context?: string): {server: string; org: string; prin
|
|
|
131
131
|
/** The identity a run starts as without asking Teams; `identity.synced` is false until Teams has its declaration, made later under the same id. */
|
|
132
132
|
/** A local agent: its daemon-minted id, retained intent and assignments. */
|
|
133
133
|
export type LocalAgent = {identity: AgentIdentity; intended: ReturnType<typeof resolveAgentProfile>; assignment: string; assignments: Array<Record<string, unknown>>; org: string};
|
|
134
|
-
export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string;
|
|
134
|
+
export function localCardAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; card: string; role: string; handle?: string | null; readableId?: string | null; machine?: string | null; starterSession?: string | null}): LocalAgent & {synced: false};
|
|
135
135
|
export function localProfileAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; profile: string; machine?: string | null; agent?: string | null; declarations?: Record<string, unknown>}): LocalAgent & {synced: false};
|
|
136
136
|
export function localForkAgent(options: {root: Path; org: string; server: string; profiles: Record<string, Profile>; parent: string; session: string; name: string; key: string}): LocalAgent;
|
|
137
137
|
/** Hand this home's agent declarations and ends to this machine's daemon, which declares and ends them in Teams. Answers what could not be handed on. */
|
|
@@ -349,6 +349,10 @@ export class AgentMailboxBridge {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
export class Orchestrator {
|
|
352
|
+
/** Private local preparation only: no hosted runtime or provider process. */
|
|
353
|
+
prepareMainPane(options: {directory: string; name: string; resume?: import('@volter/supercode-harness-sdk').SessionLocator | null}): Promise<{
|
|
354
|
+
ran: string; program: 'codex'; profile: string; cwd: string; arguments: string[]; env: Record<string,string>;
|
|
355
|
+
}>;
|
|
352
356
|
constructor(o: {
|
|
353
357
|
state: OrchestratorState;
|
|
354
358
|
vault?: Vault;
|
|
@@ -485,7 +489,7 @@ export interface OrchestrationImported {
|
|
|
485
489
|
|
|
486
490
|
/** Our folder, strict, through `harness.v1.orchestration.load`; the vault is read out of the home's own `.env`. */
|
|
487
491
|
export function load(root: Path): Promise<{ state: OrchestratorState; vault: Vault }>;
|
|
488
|
-
/**
|
|
492
|
+
/** Runtime persistence through `harness.v1.orchestration.save`. Leaves workflow.yaml untouched; sends no secret. */
|
|
489
493
|
export function save(state: OrchestratorState, root?: Path): Promise<void>;
|
|
490
494
|
/** Another harness's home as a state value; `vault_keys` names the `.env` keys it would need. */
|
|
491
495
|
export function compile(from: 'hermes' | 'openclaw', home: Path): Promise<{ state: OrchestratorState; vault_keys: string[] }>;
|