@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/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,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 initial = !overlay && !this.one('SELECT 1 FROM main.tasks LIMIT 1');
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
- // 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);
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 = { ...task.metadata?.card, ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}),
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?.assignee ?? stored?.assignee ?? card.assignee,
971
- machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
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
- // 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).
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 (`## #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.
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
- // 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; }
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._numbersRendered;
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
- for (const [id, number] of numbers) give.run(number, id, id, number);
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)?.[1] ?? 0));
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 line = `The board has given card numbers up to #${given}.`;
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 && Number(said[1]) >= given) return null;
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. 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,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['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
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:resolve(flags['modules-root']),floor:flags.floor??ticket.floor??'Work',alertRole:flags['alert-role']??'admin',grants:[]};
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&&(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');
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
- 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);}
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; sponsor?: string | null; machine?: string | null; starterSession?: string | null}): LocalAgent & {synced: false};
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
- /** Our folder, canonical and byte-stable, through `harness.v1.orchestration.save`. Sends no secret. */
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[] }>;