@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/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 number (`- [ ] #2836 c3`).
108
- const NUMBER_MARK = /^The board has given card numbers up to #(\d+)\.[ \t]*$/m;
109
- const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+#(\d+)\s+[a-z]+\d+\s/gm;
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) after the store numbered its cards
570
- // (store.mjs layOutNumbers) writes every card of the open document again, with its number in its heading and each
571
- // open subtask's on its task line; a card made since is written with its number when it is made.
572
- if (this.numbersRendered && !this.stateText('numbers_written')) {
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 = fields.filter((field) => (task || field !== 'created_by') && !same(value(rows.task, rows.state, field), value(task, state, field)));
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 initial = !overlay && !this.one('SELECT 1 FROM main.tasks LIMIT 1');
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
- // A task line carries its brief and its tick; what else the subtask holds is the store's (an older document's
962
- // `metadata:` line is still read).
963
- const stored = task.metadata ? null : this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
964
- const storedState = task.metadata ? null : this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
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 = { ...task.metadata?.card, ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}),
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?.assignee ?? stored?.assignee ?? card.assignee,
971
- machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
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
- // A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
1160
- // output stay in the store (company RFC 0025 decision 4).
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 (`## #42 t_… — …`): the store's (store.mjs layOutNumbers). An edit to a
1176
- // heading's number changes nothing; a store made again from its documents takes it back (numberFromHeadings). A card's
1177
- // record keeps the key where the document codec reads it, after the id.
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
- // Whether the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task
1185
- // line. One that does not renders, reads and indexes cards as it always did; the open document is written with the
1186
- // numbers by the first write made with one that does (tx), whichever was installed first.
1187
- get numbersRendered() {
1188
- if (this._numbersRendered === undefined) {
1189
- try {
1190
- const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, status: 'pending', evidence: [], text: 'number' }] }).body;
1191
- this._numbersRendered = this.codec.renderBoardCard({ id: 't_00000000', number: 1, title: 'number', status: 'todo', body: '' }, 2).startsWith('## #1 t_00000000 ') && task.includes('- [ ] #2 c1 number');
1192
- } catch { this._numbersRendered = false; }
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._numbersRendered;
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
- for (const [id, number] of numbers) give.run(number, id, id, number);
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)?.[1] ?? 0));
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 line = `The board has given card numbers up to #${given}.`;
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 && Number(said[1]) >= given) return null;
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. A lease refresh is a new operation even
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(path.endsWith('issuer-checks')?30000:9000),
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':operationKey??`connect-${createHash('sha256').update(`${method}\n${path}\n${JSON.stringify(body)}`).digest('hex')}`}:{})},
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
- const config={rh2:{baseUrl:record.origin,issuer:record.issuer,organization:record.organization,principalId:grant.rh2.principalId,tokenEnv:'RH2_TOKEN'},
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['modules-root']||!flags.source)throw new Error('connect needs --root, --ticket-file, --modules-root and --source (one board event source)');
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('unsupported or duplicate connection modules');
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
- const {context}=fromContext({supercodeHome:supercodeHome(),name:flags.context});
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
- teams={server:context.origin,team:context.team_id};owner=new WorkspaceContexts(supercodeHome()).credential(context.credential_ref).token;
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: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:resolve(flags['modules-root']),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
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&&(record.source!==flags.source||record.moduleRoot!==resolve(flags['modules-root'])||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');
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
- 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:'Installation-scoped outbound delegated trust',actions:['delegation.sessions','delegation.surfaces','delegation.notices','delegation.machines'],event_kinds:[],redirect_urls:['http://127.0.0.1/connected'],issuer:record.issuer,outbound_issuer:true,secret:record.issuerStagedSecret},owner)).data;privateJson(path,record);}
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')}`;}