@volter/supercode-orchestrator 0.4.160 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/adapters/chat-sdk.mjs +4 -1
- package/adapters/rh2.mjs +18 -20
- package/agent-home.mjs +35 -0
- package/agent-profiles.mjs +53 -0
- package/agents.mjs +443 -0
- package/bin/agent-door.mjs +53 -0
- package/bin/runtime-door.mjs +23 -0
- package/bin/runtime.mjs +59 -4
- package/board/agent-declarations.mjs +29 -0
- package/board/ci.mjs +6 -1
- package/board/cli.mjs +158 -31
- package/board/dispatch.mjs +299 -121
- package/board/engine.mjs +30 -10
- package/board/event-fleet.mjs +59 -0
- package/board/event-relay.mjs +45 -0
- package/board/event-stream.mjs +104 -0
- package/board/incidents.mjs +57 -0
- package/board/published.mjs +86 -0
- package/board/runtime-door.mjs +12 -0
- package/board/store.mjs +39 -6
- package/board/tools.mjs +1 -0
- package/board/wake.mjs +17 -56
- package/board/workflow.mjs +28 -7
- package/board/workspace.mjs +18 -15
- package/commands.mjs +2 -2
- package/conversations.mjs +108 -0
- package/cron.mjs +30 -4
- package/gates.mjs +3 -2
- package/hermes-cli.mjs +19 -2
- package/index.d.ts +43 -2
- package/index.mjs +4 -0
- package/installed-apps.mjs +52 -0
- package/ir.mjs +3 -3
- package/kanban-worker.mjs +16 -6
- package/loop.mjs +211 -21
- package/mcp/bridge.mjs +13 -2
- package/mcp/tools.mjs +17 -0
- package/mcp/verbs.mjs +29 -7
- package/ontology.d.ts +246 -2
- package/operator.mjs +6 -1
- package/package.json +2 -2
- package/reducer.mjs +133 -28
- package/webhook.mjs +8 -4
package/board/engine.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
-
import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
|
+
import { view, actorOf, principalOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
16
16
|
import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
|
|
17
17
|
import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
18
18
|
|
|
@@ -37,7 +37,8 @@ export function getTask(db, id) {
|
|
|
37
37
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
38
38
|
if (!row) return null;
|
|
39
39
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
40
|
-
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null
|
|
40
|
+
return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
|
|
41
|
+
tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function must(db, id) {
|
|
@@ -201,6 +202,10 @@ export function createCard(board, spec) {
|
|
|
201
202
|
if (initial === 'running' && (spec.noStart || spec.adopt)) throw new Error('A held or awaiting-adoption card cannot be created initially running.');
|
|
202
203
|
if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
|
|
203
204
|
if (spec.provider && !spec.model) throw new Error('a provider needs a model');
|
|
205
|
+
// Several ordered tags (C4); Hermes's single `tenant` is the first of them, as its export lowers them.
|
|
206
|
+
const tags = [...new Set([spec.tenant, ...(spec.tags ?? [])].filter((tag) => typeof tag === 'string' && tag.trim()).map((tag) => tag.trim()))];
|
|
207
|
+
// A sponsor is a person, never an agent or an automation (Teams refuses any principal that is not a person).
|
|
208
|
+
if (spec.sponsor && /^(agent_|svc_|service_)/.test(spec.sponsor)) throw new Error('a sponsor is a person; an agent or an automation cannot sponsor a card');
|
|
204
209
|
return tx(board, (db) => {
|
|
205
210
|
if (spec.idempotencyKey) {
|
|
206
211
|
const got = one(db, "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1", spec.idempotencyKey);
|
|
@@ -238,18 +243,19 @@ export function createCard(board, spec) {
|
|
|
238
243
|
tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
|
|
239
244
|
project_id, goal_mode, goal_max_turns)
|
|
240
245
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
241
|
-
id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0,
|
|
242
|
-
spec.branch ?? null,
|
|
246
|
+
id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0, spec.createdBy ?? principalOf() ?? (actorOf() ? `session:${actorOf()}` : 'unidentified'), t, ws.kind, ws.path,
|
|
247
|
+
spec.branch ?? null, tags[0] ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
|
|
243
248
|
spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
|
|
244
249
|
spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
|
|
245
250
|
const machine = spec.machine ?? arc?.machine ?? null;
|
|
246
|
-
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (
|
|
251
|
+
run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,sponsor,sponsor_evidence,requested_by_unverified) VALUES (?,?,?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
|
|
252
|
+
tags.length ? JSON.stringify(tags) : null, spec.sponsor ?? null, spec.sponsorEvidence ? JSON.stringify(spec.sponsorEvidence) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
|
|
247
253
|
for (const p of parents) addDependency(db, entry, id, p);
|
|
248
254
|
// where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
|
|
249
255
|
// the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
|
|
250
256
|
const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
|
|
251
257
|
every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
|
|
252
|
-
event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents,
|
|
258
|
+
event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null, status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
|
|
253
259
|
if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
|
|
254
260
|
if (parents.length) inheritSubscriptions(db, id, parents);
|
|
255
261
|
if (arc) {
|
|
@@ -259,9 +265,19 @@ export function createCard(board, spec) {
|
|
|
259
265
|
}
|
|
260
266
|
if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
|
|
261
267
|
else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
|
|
268
|
+
// The creation declares its role even when dispatch is held. The existing durable effect queue
|
|
269
|
+
// keeps public-door I/O outside this transaction and retains an offline declaration for retry.
|
|
270
|
+
let declaration;
|
|
271
|
+
if(!arc) {
|
|
272
|
+
const creator=/^sc:[^:]+:(claude-code|codex|gemini|grok|pi|hermes|opencode|supercode):(.+)$/.exec(actorOf()??'');
|
|
273
|
+
const effect={kind:'declare_card_agent',id,role:'implementer',profile:spec.assignee??null,
|
|
274
|
+
context:process.env.SUPERCODE_CONTEXT??null,starter_session:creator?`${creator[1]}:${creator[2]}`:null};
|
|
275
|
+
const inserted=run(db,'INSERT INTO supercode_effects(effect) VALUES(?)',JSON.stringify(effect));
|
|
276
|
+
declaration={...effect,durableId:Number(inserted.lastInsertRowid)};
|
|
277
|
+
}
|
|
262
278
|
// the body's task lines, as subtasks the creator filed, in this same write
|
|
263
279
|
const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
|
|
264
|
-
return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
|
|
280
|
+
return { id, existing: false, effects:declaration?[declaration]:[], ...(subtasks.length ? { subtasks } : {}) };
|
|
265
281
|
});
|
|
266
282
|
}
|
|
267
283
|
|
|
@@ -460,6 +476,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
|
|
|
460
476
|
if (!await sessionNotice(board, e)) continue;
|
|
461
477
|
}
|
|
462
478
|
else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
479
|
+
else if (e.kind === 'declare_card_agent') {
|
|
480
|
+
const {declareCardAgentEffect}=await import('./agent-declarations.mjs');
|
|
481
|
+
if(!declareCardAgentEffect(board,e))continue;
|
|
482
|
+
}
|
|
463
483
|
else if (e.kind === 'board_message') {
|
|
464
484
|
const { sendBoardMessages } = await import('./dispatch.mjs');
|
|
465
485
|
await sendBoardMessages(board, { undelivered: [] }, e.id);
|
|
@@ -536,7 +556,7 @@ export const requestReview = (board, id, { summary = null, metadata = null, revi
|
|
|
536
556
|
act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
|
|
537
557
|
export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
|
|
538
558
|
export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
|
|
539
|
-
export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
|
|
559
|
+
export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
|
|
540
560
|
export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
|
|
541
561
|
export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
|
|
542
562
|
export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
|
|
@@ -683,7 +703,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
|
|
|
683
703
|
// ------------------------------------------------------------------ reading
|
|
684
704
|
|
|
685
705
|
export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
|
|
686
|
-
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
706
|
+
const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
|
|
687
707
|
WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
|
|
688
708
|
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
|
|
689
709
|
return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
|
|
@@ -742,7 +762,7 @@ export function cardContext(db, id) {
|
|
|
742
762
|
|
|
743
763
|
|
|
744
764
|
/** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
|
|
745
|
-
export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy =
|
|
765
|
+
export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = null, priority = 0, idempotencyKey = null }) {
|
|
746
766
|
if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
|
|
747
767
|
if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
|
|
748
768
|
const protocol = (root) => `\n\n## Swarm protocol\n- Swarm root / shared blackboard: \`${root}\`.\n- Read sibling/parent handoffs from the board's context before working.\n`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A projection of retained Teams events. Dispatch reads this projection, never a periodically fetched catalogue.
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { eventClient } from '@volter/teams/event-client';
|
|
6
|
+
const pathOf=root=>join(root,'.session-events.json');
|
|
7
|
+
const normalize=value=>String(value??'').toLowerCase().replace(/[^a-z0-9-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'');
|
|
8
|
+
const wait=signal=>new Promise(resolve=>{const timer=setTimeout(resolve,5000);signal.addEventListener('abort',()=>{clearTimeout(timer);resolve();},{once:true});});
|
|
9
|
+
|
|
10
|
+
export function followFleet({root,context,supercodeHome,wake,log}){
|
|
11
|
+
const abort=new AbortController();let resources={},cursor=null,connected=false,snapshot=null;
|
|
12
|
+
const persist=()=>{
|
|
13
|
+
const path=pathOf(root),temporary=`${path}.${randomUUID()}.tmp`;
|
|
14
|
+
writeFileSync(temporary,JSON.stringify({pid:process.pid,connected,cursor,resources}),{mode:0o600});renameSync(temporary,path);
|
|
15
|
+
};
|
|
16
|
+
const apply=(target,frame)=>{
|
|
17
|
+
const event=frame.data;if(!event||!['session','machine'].includes(event.resource?.kind))return false;
|
|
18
|
+
const key=`${event.resource.kind}:${event.resource.id}`,old=target[key];
|
|
19
|
+
if(old&&old.sequence>=frame.sequence)return false;
|
|
20
|
+
target[key]={sequence:frame.sequence,data:event.data};return true;
|
|
21
|
+
};
|
|
22
|
+
void(async()=>{
|
|
23
|
+
while(!abort.signal.aborted){
|
|
24
|
+
try{
|
|
25
|
+
const {team}=eventClient({context,supercodeHome});
|
|
26
|
+
// Every connection begins with a complete, ACL-filtered snapshot. A process restart cannot trust an old cache.
|
|
27
|
+
for await(const frame of team.events.follow({kinds:['session.started','session.ended','session.changed','resource.changed']}, {signal:abort.signal,after:cursor})){
|
|
28
|
+
if(frame.type==='snapshot_begin'){snapshot={};connected=false;persist();}
|
|
29
|
+
else if(frame.type==='snapshot_item')apply(snapshot??resources,frame);
|
|
30
|
+
else if(frame.type==='snapshot_end'){resources=snapshot??resources;snapshot=null;cursor=frame.cursor;connected=true;persist();wake('session-events-snapshot');}
|
|
31
|
+
else if(frame.type==='event'){const changed=apply(resources,frame);cursor=frame.cursor;connected=true;persist();if(changed)wake('session-events');}
|
|
32
|
+
else if(frame.type==='checkpoint'){const recovered=!connected;cursor=frame.cursor;connected=true;persist();if(recovered)wake('session-events-reconnected');}
|
|
33
|
+
else if(frame.terminal){cursor=null;connected=false;persist();wake('session-events-unavailable');break;}
|
|
34
|
+
}
|
|
35
|
+
}catch(error){if(!abort.signal.aborted)log(`dispatch session events unavailable: ${error.code??error.message}`);if(error.status===409)cursor=null;}
|
|
36
|
+
connected=false;persist();wake('session-events-unavailable');if(!abort.signal.aborted)await wait(abort.signal);
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
return {close(){abort.abort();connected=false;persist();}};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readEventFleet(root){
|
|
43
|
+
let cache;try{cache=JSON.parse(readFileSync(pathOf(root),'utf8'));process.kill(cache.pid,0);}catch{return {ok:false,alive:new Set(),online:new Set(),sessions:[],native:new Map(),error:'published session events unavailable',presenceKnown:false};}
|
|
44
|
+
const machines=new Map(),online=new Set();
|
|
45
|
+
for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('machine:')){machines.set(value.data.id,normalize(value.data.name));if(cache.connected&&value.data.linked)online.add(normalize(value.data.name));}
|
|
46
|
+
const sessions=[],native=new Map(),alive=new Set();
|
|
47
|
+
for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('session:')){
|
|
48
|
+
for(const binding of value.data.bindings??[]){
|
|
49
|
+
const machine=machines.get(binding.machine_id);if(!machine||!binding.harness||!binding.native_session_id)continue;
|
|
50
|
+
const address=`sc:${machine}:${binding.harness}:${binding.native_session_id}`;
|
|
51
|
+
const known=cache.connected&&online.has(machine),state=known?(binding.state==='running'?true:binding.state==='stopped'?false:null):null;
|
|
52
|
+
const previous=native.get(address),states=[...(previous?.states??[]),state];
|
|
53
|
+
const combined=states.includes(true)?true:states.includes(null)?null:states.every(value=>value===false)?false:null;
|
|
54
|
+
native.set(address,{alive:combined,states});if(combined===true)alive.add(address);
|
|
55
|
+
sessions.push({address,machine,harness:binding.harness,session_id:binding.native_session_id,pane:binding.pane??null,name:binding.name??null,state:binding.state,identity:value.data.agent_identity??null});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {ok:!!cache.connected,presenceKnown:!!cache.connected,online,alive,native,sessions,error:cache.connected?null:'published session events disconnected'};
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// App-owned source names/maps live here; the Teams core knows only a scoped event publisher.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { eventClient } from '@volter/teams/event-client';
|
|
4
|
+
import { boardSourceName, recordCardPublications } from './published.mjs';
|
|
5
|
+
import { listBoards, openBoard } from './store.mjs';
|
|
6
|
+
|
|
7
|
+
export function boardEventRelay({root,context,supercodeHome,log=()=>{}}) {
|
|
8
|
+
let work=null,dirty=false,closed=false,retry=null,reconcile=true;const sources=new Map();
|
|
9
|
+
const run=async()=>{
|
|
10
|
+
if(reconcile){
|
|
11
|
+
// Establish every native source head before any network operation. A
|
|
12
|
+
// delivery retry cannot leave later boards without their initial inventory.
|
|
13
|
+
for(const slug of listBoards(root)){const board=openBoard(root,slug);try{board.tx(db=>recordCardPublications(db,null));}finally{board.close();}}
|
|
14
|
+
reconcile=false;
|
|
15
|
+
}
|
|
16
|
+
const {team,context:audience}=eventClient({context,supercodeHome});
|
|
17
|
+
for(const slug of listBoards(root)){
|
|
18
|
+
const board=openBoard(root,slug);
|
|
19
|
+
try{
|
|
20
|
+
const key=JSON.stringify({server:audience.server,org:audience.org,principal:audience.principal,board:slug});
|
|
21
|
+
let source=sources.get(key);
|
|
22
|
+
if(!source){source=(await team.events.createSource({name:boardSourceName(root,slug)})).data.id;sources.set(key,source);}
|
|
23
|
+
const destination=createHash('sha256').update(`${key}:${source}`).digest('hex');
|
|
24
|
+
for(;;){
|
|
25
|
+
const rows=board.db.prepare('SELECT * FROM supercode_publications p WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_receipts r WHERE r.event_id=p.id AND r.destination=?) ORDER BY sequence LIMIT 32').all(destination);if(!rows.length)break;
|
|
26
|
+
for(const row of rows){
|
|
27
|
+
if(closed)return;
|
|
28
|
+
const id=`event_${createHash('sha256').update(`${row.id}:${destination}`).digest('hex')}`;
|
|
29
|
+
await team.events.publish(source,{id,kind:row.kind,resource:{kind:'task',id:`${source}:${row.resource_id}`},revision:row.sequence,data:{board:slug,card:JSON.parse(row.snapshot)},actor:{session:row.actor?`session:${row.actor}`:null}},{idempotencyKey:id});
|
|
30
|
+
board.tx(db=>db.prepare('INSERT OR IGNORE INTO supercode_publication_receipts VALUES (?,?)').run(row.id,destination));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}finally{board.close();}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const wake=(refresh=true)=>{
|
|
37
|
+
reconcile ||= refresh;dirty=true;if(work||closed)return work;
|
|
38
|
+
work=(async()=>{while(dirty&&!closed){dirty=false;await run();}})().catch(error=>{
|
|
39
|
+
log(`board event delivery pending: ${error.code??error.message}`);
|
|
40
|
+
// Retry durable delivery, not a read of the board's current state.
|
|
41
|
+
retry=setTimeout(()=>wake(false),5000);retry.unref?.();
|
|
42
|
+
}).finally(()=>{work=null;});return work;
|
|
43
|
+
};
|
|
44
|
+
return {wake,close(){closed=true;clearTimeout(retry);}};
|
|
45
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Public native workflow observation. Filesystem custody authorizes this door;
|
|
2
|
+
// Teams separately authorizes the Org event and its installed subscribers.
|
|
3
|
+
import { watch, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { dirname, basename, join, resolve } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { listBoards, openBoard, backingPaths } from './store.mjs';
|
|
7
|
+
import { recordCardPublications } from './published.mjs';
|
|
8
|
+
|
|
9
|
+
const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
|
|
10
|
+
const cursorOf = (home, boards) => Buffer.from(JSON.stringify({version:1,home,boards})).toString('base64url');
|
|
11
|
+
const resync = reason => Object.assign(new Error(reason), {code:'resync_required'});
|
|
12
|
+
|
|
13
|
+
/** Snapshot once, then replay only committed publications on backing notifications. */
|
|
14
|
+
export async function* followBoardPublications({root,after,signal}) {
|
|
15
|
+
const home=identity(root), positions={}, watchers=new Map();
|
|
16
|
+
let dirty=true, wake=null, failure=null, inventory=null;
|
|
17
|
+
const notify=()=>{dirty=true;wake?.();};
|
|
18
|
+
const directory=(path,accept)=>{
|
|
19
|
+
if(!existsSync(path))return;
|
|
20
|
+
if(watchers.has(path)){watchers.get(path).accept.push(accept);return;}
|
|
21
|
+
const entry={accept:[accept],handle:null};
|
|
22
|
+
entry.handle=watch(path,(_kind,name)=>{if(name==null||entry.accept.some(fn=>fn(String(name))))notify();});
|
|
23
|
+
entry.handle.on('error',error=>{failure=error;notify();});watchers.set(path,entry);
|
|
24
|
+
};
|
|
25
|
+
const file=path=>directory(dirname(path),name=>name===basename(path));
|
|
26
|
+
const register=slugs=>{
|
|
27
|
+
for(const entry of watchers.values())entry.accept=[];
|
|
28
|
+
directory(root,name=>['kanban.db','kanban.db-wal','kanban'].includes(name));
|
|
29
|
+
directory(join(root,'kanban'),name=>['boards','ztrack.json','board'].includes(name));
|
|
30
|
+
directory(join(root,'kanban','boards'),()=>true);
|
|
31
|
+
for(const slug of slugs){
|
|
32
|
+
const paths=backingPaths(root,slug);
|
|
33
|
+
file(paths.sqlite);file(`${paths.sqlite}-wal`);file(paths.ztrack);
|
|
34
|
+
directory(paths.files,name=>name.endsWith('.json'));
|
|
35
|
+
try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);file(`${document}.archive.md`);}catch{/* other backing */}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const aborted=()=>wake?.();signal?.addEventListener('abort',aborted);
|
|
39
|
+
try {
|
|
40
|
+
if(after){
|
|
41
|
+
let decoded;try{decoded=JSON.parse(Buffer.from(after,'base64url').toString());}catch{throw resync('Malformed workflow event cursor');}
|
|
42
|
+
if(decoded.version!==1||decoded.home!==home||!decoded.boards||typeof decoded.boards!=='object')throw resync('Workflow event cursor belongs to another source');
|
|
43
|
+
Object.assign(positions,decoded.boards);inventory=Object.keys(positions).sort().join('\0');
|
|
44
|
+
} else yield {type:'snapshot_begin'};
|
|
45
|
+
let initial=!after,checkpointPending=!!after;
|
|
46
|
+
while(!signal?.aborted){
|
|
47
|
+
if(failure)throw failure;
|
|
48
|
+
dirty=false;
|
|
49
|
+
const slugs=listBoards(root).sort(),current=slugs.join('\0');register(slugs);
|
|
50
|
+
if(inventory!==null&¤t!==inventory)throw resync('Workflow board inventory changed');
|
|
51
|
+
inventory=current;
|
|
52
|
+
for(const slug of slugs){
|
|
53
|
+
const board=openBoard(root,slug);
|
|
54
|
+
try {
|
|
55
|
+
// External native writers do not share our transaction wrapper. Their
|
|
56
|
+
// filesystem event reconciles once into the same retained source log.
|
|
57
|
+
const boundary=board.tx(db=>{
|
|
58
|
+
recordCardPublications(db,null);
|
|
59
|
+
const log=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get();
|
|
60
|
+
const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
|
|
61
|
+
const heads=initial?db.prepare('SELECT sequence FROM supercode_publication_heads ORDER BY resource_id').all().map(row=>row.sequence):[];
|
|
62
|
+
return {log:log.id,floor:Number(log.pruned_through),high,heads};
|
|
63
|
+
});
|
|
64
|
+
const position=positions[slug];
|
|
65
|
+
if(initial){
|
|
66
|
+
// The transaction's watermark fences concurrent mutations. Replay
|
|
67
|
+
// after it repairs any head updated while this inventory is read.
|
|
68
|
+
for(const sequence of boundary.heads){
|
|
69
|
+
const row=board.db.prepare('SELECT snapshot FROM supercode_publications WHERE sequence=?').get(sequence);
|
|
70
|
+
if(!row)throw resync('Workflow snapshot unavailable');
|
|
71
|
+
yield {type:'snapshot_item',data:{board:slug,card:JSON.parse(row.snapshot)}};
|
|
72
|
+
}
|
|
73
|
+
positions[slug]={log:boundary.log,sequence:boundary.high};
|
|
74
|
+
}else{
|
|
75
|
+
// A pruned floor above the cursor means changes it never read are gone; a new log id means a new store.
|
|
76
|
+
if(!position||position.log!==boundary.log||!Number.isSafeInteger(position.sequence)||position.sequence<boundary.floor||position.sequence>boundary.high)throw resync(`Workflow publication log changed: ${slug}`);
|
|
77
|
+
for(;;){
|
|
78
|
+
const rows=board.db.prepare('SELECT * FROM supercode_publications WHERE sequence>? ORDER BY sequence LIMIT 32').all(positions[slug].sequence);if(!rows.length)break;
|
|
79
|
+
for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}finally{board.close();}
|
|
83
|
+
}
|
|
84
|
+
if(initial){initial=false;yield {type:'snapshot_end',cursor:cursorOf(home,positions)};dirty=true;}
|
|
85
|
+
else if(checkpointPending){checkpointPending=false;yield {type:'checkpoint',cursor:cursorOf(home,positions)};}
|
|
86
|
+
if(!dirty&&!signal?.aborted)await new Promise(done=>{wake=done;if(dirty||signal?.aborted)done();});
|
|
87
|
+
wake=null;
|
|
88
|
+
}
|
|
89
|
+
}finally{signal?.removeEventListener('abort',aborted);for(const {handle} of watchers.values())handle.close();}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function streamBoardPublications(options) {
|
|
93
|
+
if(options.once&&options.after)throw new Error('--once requires a fresh snapshot; omit --after');
|
|
94
|
+
const abort=new AbortController();const stop=()=>abort.abort();
|
|
95
|
+
process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
|
96
|
+
try {
|
|
97
|
+
for await(const frame of followBoardPublications({...options,signal:abort.signal})){
|
|
98
|
+
if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await new Promise(done=>process.stdout.once('drain',done));
|
|
99
|
+
if(options.once&&frame.type==='snapshot_end')break;
|
|
100
|
+
}
|
|
101
|
+
}catch(error){if(error.code==='resync_required'){process.stdout.write(`${JSON.stringify({type:'resync_required',reason:error.message})}\n`);return 1;}throw error;}
|
|
102
|
+
finally{process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Service supervision retains evidence; only a manager/creator act releases dispatch.
|
|
2
|
+
import { event, json, now } from './store.mjs';
|
|
3
|
+
import { statusOf, creatorOf } from './workflow.mjs';
|
|
4
|
+
|
|
5
|
+
export const monitorsRuns = entry => entry?.params?.run_failure_policy !== 'retry';
|
|
6
|
+
export const failedObservation = name => ['exited', 'session_closed', 'session_lost', 'spawn_failed', 'orphaned', 'timed_out', 'stale'].includes(name);
|
|
7
|
+
|
|
8
|
+
export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId = null) {
|
|
9
|
+
const task = db.prepare('SELECT * FROM tasks WHERE id=?').get(id);
|
|
10
|
+
runId ??= task?.current_run_id;
|
|
11
|
+
if (!task || !runId) return false;
|
|
12
|
+
const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(runId);
|
|
13
|
+
if (!row || row.task_id !== id) return false;
|
|
14
|
+
const meta = json(row.metadata, {}) ?? {}, session = meta.supercode ?? {};
|
|
15
|
+
const prior = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='run_incident' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
|
|
16
|
+
const continued = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='session_continued' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
|
|
17
|
+
if (prior && !(continued?.id > prior.id)) return false;
|
|
18
|
+
const reason = evidence.error ?? row.error ?? `run ${runId} ended (${row.outcome ?? row.status}) without moving its card`;
|
|
19
|
+
const payload = { observation: name, run: runId, status: statusOf(db, task), reason,
|
|
20
|
+
session: session.session_id ?? null, machine: session.machine ?? null, pane: session.pane ?? null,
|
|
21
|
+
launch_key: session.launch_key ?? null, pid: session.pid ?? null, evidence };
|
|
22
|
+
const incident = event(db, id, 'run_incident', payload, runId);
|
|
23
|
+
event(db, id, 'dispatch_held', { reason, incident, run: runId });
|
|
24
|
+
meta.supercode = { ...session, incident, monitor_held: true, state: 'failed' };
|
|
25
|
+
db.prepare(`UPDATE task_runs SET ended_at=COALESCE(ended_at,?),status=CASE WHEN ended_at IS NULL THEN 'failed' ELSE status END,
|
|
26
|
+
outcome=COALESCE(outcome,'failed'),error=COALESCE(error,?),metadata=?,claim_lock=NULL,claim_expires=NULL WHERE id=?`)
|
|
27
|
+
.run(now(), reason, JSON.stringify(meta), runId);
|
|
28
|
+
if (task.current_run_id === runId) db.prepare('UPDATE tasks SET current_run_id=NULL,claim_lock=NULL,claim_expires=NULL,worker_pid=NULL,last_failure_error=? WHERE id=?').run(reason, id);
|
|
29
|
+
const text = `${id}: run #${runId} stopped: ${reason}. Incident event ${incident}. Evidence: ${JSON.stringify(payload)}\nThe dispatcher will start nothing. The manager or creator decides whether to start, resume or block the card; this incident remains recorded.`;
|
|
30
|
+
db.prepare('INSERT INTO task_comments(task_id,author,body,created_at) VALUES (?,?,?,?)').run(id, 'dispatcher', text, now());
|
|
31
|
+
const creator = creatorOf(db, id);
|
|
32
|
+
const reachableCreator = db.prepare("SELECT chat_id FROM kanban_notify_subs WHERE task_id=? AND json_extract(delivery_metadata,'$.role')='creator' AND platform='supercode' LIMIT 1").get(id)?.chat_id;
|
|
33
|
+
const to = [].concat(entry.params.managers ?? []).find(Boolean) ?? reachableCreator ?? (creator?.startsWith('session:sc:') ? creator.slice(8) : creator?.startsWith('sc:') ? creator : null);
|
|
34
|
+
if (to) event(db, id, 'board_message', { to, text, incident }, null);
|
|
35
|
+
else event(db, id, 'incident_delivery_pending', { incident, reason: 'no manager or reachable creator configured; incident retained on card' });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Older ended runs are audited before eligibility: a completed run is not permission to repeat. */
|
|
40
|
+
export function auditEndedRuns(db, board, entry) {
|
|
41
|
+
if (!monitorsRuns(entry)) return;
|
|
42
|
+
for (const task of db.prepare(`SELECT t.*,r.id run_id,r.outcome,r.error,r.metadata run_metadata FROM tasks t JOIN task_runs r ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
|
|
43
|
+
WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status NOT IN ('done','archived')`).all()) {
|
|
44
|
+
const session = json(task.run_metadata, {})?.supercode;
|
|
45
|
+
const status = statusOf(db, task);
|
|
46
|
+
if (session?.status && session.status !== status) continue; // a card transition intentionally advances its work
|
|
47
|
+
const incident = db.prepare("SELECT id FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(task.id, task.run_id);
|
|
48
|
+
const release = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='dispatch_released' ORDER BY id DESC LIMIT 1").get(task.id);
|
|
49
|
+
if (incident && session?.monitor_held && release?.id > incident.id) continue;
|
|
50
|
+
const decided = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND kind='dispatch_released' AND json_extract(payload,'$.ended_run')=?").get(task.id, task.run_id);
|
|
51
|
+
if (decided) continue; // its manager or creator ended it by an explicit act
|
|
52
|
+
const audited = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='audited'").get(task.id,task.run_id);
|
|
53
|
+
if (audited) continue; // Every explicitly schedules the next successful audit
|
|
54
|
+
if (['blocked','paused','reconfigured','review_passed','review','review_requested','audited'].includes(task.outcome)) continue;
|
|
55
|
+
surfaceIncident(db, board, entry, task.id, 'run_ended', { error: task.error, outcome: task.outcome }, task.run_id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The card writer commits the full snapshot and a retained delivery obligation together.
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
export const PUBLICATION_SCHEMA = `
|
|
5
|
+
CREATE TABLE IF NOT EXISTS supercode_publication_receipts (
|
|
6
|
+
event_id TEXT NOT NULL, destination TEXT NOT NULL, PRIMARY KEY(event_id,destination)
|
|
7
|
+
);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS supercode_publications (
|
|
9
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, resource_id TEXT NOT NULL,
|
|
10
|
+
kind TEXT NOT NULL, snapshot TEXT NOT NULL, actor TEXT, created_at INTEGER NOT NULL
|
|
11
|
+
);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS supercode_publication_heads (
|
|
13
|
+
resource_id TEXT PRIMARY KEY, digest TEXT NOT NULL, sequence INTEGER NOT NULL, last_event_id INTEGER
|
|
14
|
+
);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS supercode_publication_log (
|
|
16
|
+
id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL DEFAULT 0
|
|
17
|
+
);`;
|
|
18
|
+
// Every card's head stays; older changes beyond this many are pruned in one batch once twice as many accumulate.
|
|
19
|
+
// A reader whose cursor falls below the pruned floor takes a new snapshot.
|
|
20
|
+
const RETAINED_CHANGES = 1024;
|
|
21
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
22
|
+
const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
|
|
23
|
+
const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
|
|
24
|
+
const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',resumed:'resumed',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The ontology's Task status (contracts/ontology.md: todo, running, review, done, cancelled) of a card in a board lane.
|
|
28
|
+
* The lanes are the card machine's Hermes-shaped states: waiting lanes are `todo` (blocked is the blocked_by relation,
|
|
29
|
+
* ready is derived), and an archived card is `done` when it was completed, else `cancelled`.
|
|
30
|
+
*/
|
|
31
|
+
export function taskStatusOf(lane, completedAt) {
|
|
32
|
+
if (lane === 'running' || lane === 'review' || lane === 'done') return lane;
|
|
33
|
+
if (lane === 'archived') return completedAt ? 'done' : 'cancelled';
|
|
34
|
+
return 'todo';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function cardSnapshot(db,row) {
|
|
38
|
+
const own=db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(row.id)??{};
|
|
39
|
+
const comments=db.prepare('SELECT id,author,body,created_at FROM task_comments WHERE task_id=? ORDER BY id').all(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
|
|
40
|
+
const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
|
|
41
|
+
const reviews=db.prepare("SELECT id,kind,payload,created_at FROM task_events WHERE task_id=? AND kind IN ('review_requested','approved','review_approved','changes_requested','escalated') ORDER BY id").all(row.id).map(event=>{const payload=parse(event.payload,{});return {id:event.id,verdict:verdicts[event.kind],by:payload.reviewer??payload.by??payload.profile,reason:payload.reason,at:at(event.created_at)};});
|
|
42
|
+
const attempts=db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id').all(row.id).map(run=>{
|
|
43
|
+
const native=parse(run.metadata,{})?.supercode??null;
|
|
44
|
+
const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
|
|
45
|
+
const metadata=parse(run.metadata,{});delete metadata.supercode;
|
|
46
|
+
return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
|
|
47
|
+
});
|
|
48
|
+
const lane=own.stage??row.status;
|
|
49
|
+
return {id:row.id,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),lane,
|
|
50
|
+
priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
|
|
51
|
+
tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
|
|
52
|
+
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
|
|
53
|
+
workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
|
|
54
|
+
parents:db.prepare('SELECT parent_id FROM task_links WHERE child_id=? ORDER BY parent_id').all(row.id).map(link=>link.parent_id),
|
|
55
|
+
reviews,children:db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(row.id).map(link=>link.child_id),comments,attempts};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Runs inside the backing's native transaction, including files and ztrack projections. */
|
|
59
|
+
export function recordCardPublications(db,actor) {
|
|
60
|
+
const rows=db.prepare('SELECT * FROM tasks ORDER BY id').all(),present=new Set();let changed=0;
|
|
61
|
+
for(const row of rows){
|
|
62
|
+
present.add(row.id);const snapshot=JSON.stringify(cardSnapshot(db,row)),digest=hash(snapshot);
|
|
63
|
+
const previous=db.prepare('SELECT digest,last_event_id FROM supercode_publication_heads WHERE resource_id=?').get(row.id);
|
|
64
|
+
if(previous?.digest===digest)continue;
|
|
65
|
+
const last=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1').get(row.id);
|
|
66
|
+
const kind=`task.${previous?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
|
|
67
|
+
const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,row.id,kind,snapshot,actor,Date.now());
|
|
68
|
+
db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id) VALUES (?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id').run(row.id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
|
|
69
|
+
}
|
|
70
|
+
for(const old of db.prepare('SELECT resource_id,digest FROM supercode_publication_heads').all()){
|
|
71
|
+
if(present.has(old.resource_id)||old.digest==='removed')continue;
|
|
72
|
+
const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,old.resource_id,'task.changed',JSON.stringify({id:old.resource_id,removed:true}),actor,Date.now());
|
|
73
|
+
db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=? WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),old.resource_id);changed++;
|
|
74
|
+
}
|
|
75
|
+
db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
|
|
76
|
+
const history=Number(db.prepare('SELECT count(*) AS n FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads)').get().n);
|
|
77
|
+
if(history>2*RETAINED_CHANGES){
|
|
78
|
+
const cut=Number(db.prepare('SELECT sequence FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads) ORDER BY sequence DESC LIMIT 1 OFFSET ?').get(RETAINED_CHANGES).sequence);
|
|
79
|
+
const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
|
|
80
|
+
db.prepare(`DELETE FROM supercode_publication_receipts WHERE event_id IN (${old})`).run(cut);
|
|
81
|
+
db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut);
|
|
82
|
+
db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
|
|
83
|
+
}
|
|
84
|
+
return changed;
|
|
85
|
+
}
|
|
86
|
+
export const boardSourceName = (root,slug) => `board:${hash(realpathSync(root)).slice(0,24)}:${slug}`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
const entry = fileURLToPath(new URL('../bin/runtime-door.mjs',import.meta.url));
|
|
4
|
+
|
|
5
|
+
/** Public machine RPC only; no Teams binary or implementation/state-file access. */
|
|
6
|
+
export function runtimeDoor(machine,method,params={}, {env=process.env,timeout=60_000,as=null}={}) {
|
|
7
|
+
const result = spawnSync(process.execPath,[entry],{
|
|
8
|
+
input:JSON.stringify({machine,method,params}),encoding:'utf8',timeout,
|
|
9
|
+
env:{...env,...(as?{SUPERCODE_CALLER:as}:{})},maxBuffer:512*1024*1024,
|
|
10
|
+
});
|
|
11
|
+
return {code:result.status,stdout:result.stdout??'',stderr:(result.stderr??'')+(result.error?.message??'')};
|
|
12
|
+
}
|
package/board/store.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
17
17
|
import { openZtrackBoard } from './ztrack.mjs';
|
|
18
18
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
19
19
|
import { signalBoardWrite } from './wake.mjs';
|
|
20
|
+
import { PUBLICATION_SCHEMA, recordCardPublications } from './published.mjs';
|
|
20
21
|
|
|
21
22
|
export const DEFAULT_BOARD = 'default';
|
|
22
23
|
|
|
@@ -73,7 +74,8 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
|
|
|
73
74
|
CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
|
|
74
75
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
75
76
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
76
|
-
|
|
77
|
+
// `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `sponsor`, `sponsor_evidence`, `requested_by_unverified`: C4
|
|
78
|
+
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
|
|
77
79
|
|
|
78
80
|
// ------------------------------------------------------------------ backings
|
|
79
81
|
//
|
|
@@ -134,6 +136,7 @@ export function listBoards(root) {
|
|
|
134
136
|
export function layOut(db, { fresh }) {
|
|
135
137
|
if (fresh) db.exec(SCHEMA);
|
|
136
138
|
db.exec(OURS);
|
|
139
|
+
db.exec(PUBLICATION_SCHEMA);
|
|
137
140
|
const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
|
|
138
141
|
for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
|
|
139
142
|
}
|
|
@@ -171,9 +174,21 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
171
174
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
172
175
|
const db = new DatabaseSync(path);
|
|
173
176
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
+
// The check and the layout are one write transaction: a board another process is laying out is not mistaken for a
|
|
178
|
+
// laid-out one (its `tasks` table made, the rest not), and a layout cut short by that process leaves nothing behind.
|
|
179
|
+
for (;;) {
|
|
180
|
+
try {
|
|
181
|
+
db.exec('BEGIN IMMEDIATE');
|
|
182
|
+
const fresh = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get();
|
|
183
|
+
if (fresh && !create) { db.exec('ROLLBACK'); db.close(); throw new Error(`${path} is not a board`); }
|
|
184
|
+
layOut(db, { fresh });
|
|
185
|
+
db.exec('COMMIT');
|
|
186
|
+
break;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
189
|
+
if (!/locked|busy/i.test(error.message)) throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
177
192
|
return { db, root, slug, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
|
|
178
193
|
}
|
|
179
194
|
|
|
@@ -182,11 +197,22 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
|
|
|
182
197
|
* A read inside the transaction is the state it writes over.
|
|
183
198
|
*/
|
|
184
199
|
export const tx = (board, fn) => {
|
|
185
|
-
const result = board.tx(fn);
|
|
200
|
+
const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actor);return result;});
|
|
186
201
|
signalBoardWrite(board.root, actor);
|
|
187
202
|
return result;
|
|
188
203
|
};
|
|
189
|
-
|
|
204
|
+
// A read that meets another process's write is retried as a write is (sqliteTx): a dispatcher service and a CLI share
|
|
205
|
+
// the board, and a lost read must not become a launch failure. Inside a transaction its retry is the transaction's.
|
|
206
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
207
|
+
export const view = (board, fn) => {
|
|
208
|
+
for (;;) {
|
|
209
|
+
try { return fn(board.db); }
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (inTx.has(board.db) || !/locked|busy/i.test(error.message)) throw error;
|
|
212
|
+
Atomics.wait(pause, 0, 0, 10);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
190
216
|
|
|
191
217
|
// A deferred native transaction, with the whole operation retried on an optimistic conflict.
|
|
192
218
|
// There is no separate application writer lock or contention timeout. A write inside one is part of it.
|
|
@@ -226,6 +252,13 @@ export function json(text, fallback = null) {
|
|
|
226
252
|
let actor = null;
|
|
227
253
|
export const actorOf = () => actor;
|
|
228
254
|
export function setActor(address) { actor = address || null; }
|
|
255
|
+
// C4: the principal the caller acts as (`agent:<id>`, `automation:<name>`, `person:<id>`, `session:<address>`): what a
|
|
256
|
+
// card's `created_by` records, and what a relaunched agent still matches when it acts on the cards it created.
|
|
257
|
+
let principal = null;
|
|
258
|
+
export const principalOf = () => principal;
|
|
259
|
+
export function setPrincipal(ref) { principal = ref || null; }
|
|
260
|
+
/** Whether a recorded creator is the caller: its principal, its session address, or that session as an actor. */
|
|
261
|
+
export const isCaller = (ref) => Boolean(ref) && [principal, actor, actor ? `session:${actor}` : null].includes(ref);
|
|
229
262
|
|
|
230
263
|
export function event(db, taskId, kind, payload = null, runId = null) {
|
|
231
264
|
// Session observations belong to the run machine. Legacy runs without a machine state retain
|
package/board/tools.mjs
CHANGED
|
@@ -181,6 +181,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
181
181
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
182
182
|
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
183
183
|
});
|
|
184
|
+
await engine.runEffects(board,made.effects??[]);
|
|
184
185
|
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
185
186
|
}
|
|
186
187
|
case 'kanban_unblock':
|