@goah/cli 0.11.4 → 0.13.0
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/dist/cli.js +165 -66
- package/dist/cli.js.map +1 -1
- package/dist/console/assets/index-3sYrDMmt.css +1 -0
- package/dist/console/assets/index-Rtc_B7cs.js +52 -0
- package/dist/console/assets/index-Rtc_B7cs.js.map +1 -0
- package/dist/console/index.html +2 -2
- package/dist/control.d.ts +13 -13
- package/dist/control.d.ts.map +1 -1
- package/dist/control.js +77 -50
- package/dist/control.js.map +1 -1
- package/dist/index.d.ts +6 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +156 -28
- package/dist/index.js.map +1 -1
- package/dist/live-config.js +2 -2
- package/dist/live-config.js.map +1 -1
- package/dist/prompt-queue.d.ts.map +1 -1
- package/dist/prompt-queue.js +5 -0
- package/dist/prompt-queue.js.map +1 -1
- package/dist/setup-wizard.d.ts +13 -2
- package/dist/setup-wizard.d.ts.map +1 -1
- package/dist/setup-wizard.js +87 -63
- package/dist/setup-wizard.js.map +1 -1
- package/dist/thread-inspect.d.ts +47 -0
- package/dist/thread-inspect.d.ts.map +1 -0
- package/dist/thread-inspect.js +78 -0
- package/dist/thread-inspect.js.map +1 -0
- package/dist/transcript.d.ts +2 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +2 -0
- package/dist/transcript.js.map +1 -0
- package/dist/tui-theme.d.ts +1 -0
- package/dist/tui-theme.d.ts.map +1 -1
- package/dist/tui-theme.js +1 -0
- package/dist/tui-theme.js.map +1 -1
- package/dist/tui.d.ts +16 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +332 -80
- package/dist/tui.js.map +1 -1
- package/dist/update.d.ts +1 -0
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +15 -7
- package/dist/update.js.map +1 -1
- package/dist/web-console.d.ts +1 -1
- package/dist/web-console.d.ts.map +1 -1
- package/dist/web-console.js +27 -37
- package/dist/web-console.js.map +1 -1
- package/dist/welcome.d.ts +2 -2
- package/dist/welcome.d.ts.map +1 -1
- package/dist/welcome.js +24 -36
- package/dist/welcome.js.map +1 -1
- package/node_modules/goah-ledger-contract/dist/execution.d.ts +272 -130
- package/node_modules/goah-ledger-contract/dist/execution.d.ts.map +1 -1
- package/node_modules/goah-ledger-contract/dist/execution.js +31 -19
- package/node_modules/goah-ledger-contract/dist/execution.js.map +1 -1
- package/node_modules/goah-ledger-contract/dist/index.d.ts +1 -2
- package/node_modules/goah-ledger-contract/dist/index.d.ts.map +1 -1
- package/node_modules/goah-ledger-contract/dist/index.js +1 -2
- package/node_modules/goah-ledger-contract/dist/index.js.map +1 -1
- package/node_modules/goah-ledger-contract/dist/kernel.d.ts +1 -1
- package/node_modules/goah-ledger-contract/dist/kernel.d.ts.map +1 -1
- package/node_modules/goah-ledger-contract/dist/kernel.js +1 -1
- package/node_modules/goah-ledger-contract/dist/kernel.js.map +1 -1
- package/node_modules/goah-ledger-contract/dist/transcript.d.ts +63 -0
- package/node_modules/goah-ledger-contract/dist/transcript.d.ts.map +1 -0
- package/node_modules/goah-ledger-contract/dist/transcript.js +115 -0
- package/node_modules/goah-ledger-contract/dist/transcript.js.map +1 -0
- package/node_modules/goah-ledger-contract/package.json +3 -4
- package/node_modules/goah-ledger-sqlite/dist/index.d.ts +32 -23
- package/node_modules/goah-ledger-sqlite/dist/index.d.ts.map +1 -1
- package/node_modules/goah-ledger-sqlite/dist/index.js +764 -394
- package/node_modules/goah-ledger-sqlite/dist/index.js.map +1 -1
- package/node_modules/goah-ledger-sqlite/package.json +2 -2
- package/node_modules/goah-runner-pi/dist/configurator.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/configurator.js +186 -30
- package/node_modules/goah-runner-pi/dist/configurator.js.map +1 -1
- package/node_modules/goah-runner-pi/dist/credential-store.d.ts +2 -0
- package/node_modules/goah-runner-pi/dist/credential-store.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/credential-store.js +117 -23
- package/node_modules/goah-runner-pi/dist/credential-store.js.map +1 -1
- package/node_modules/goah-runner-pi/dist/index.d.ts +18 -6
- package/node_modules/goah-runner-pi/dist/index.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/index.js +97 -12
- package/node_modules/goah-runner-pi/dist/index.js.map +1 -1
- package/node_modules/goah-runner-pi/dist/pi-worker.d.ts +6 -2
- package/node_modules/goah-runner-pi/dist/pi-worker.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/pi-worker.js +232 -70
- package/node_modules/goah-runner-pi/dist/pi-worker.js.map +1 -1
- package/node_modules/goah-runner-pi/dist/steering-no-ack.test-fixture.d.ts +2 -0
- package/node_modules/goah-runner-pi/dist/steering-no-ack.test-fixture.d.ts.map +1 -0
- package/node_modules/goah-runner-pi/dist/steering-no-ack.test-fixture.js +3 -0
- package/node_modules/goah-runner-pi/dist/steering-no-ack.test-fixture.js.map +1 -0
- package/node_modules/goah-runner-pi/dist/steering-reject.test-fixture.d.ts +2 -0
- package/node_modules/goah-runner-pi/dist/steering-reject.test-fixture.d.ts.map +1 -0
- package/node_modules/goah-runner-pi/dist/steering-reject.test-fixture.js +5 -0
- package/node_modules/goah-runner-pi/dist/steering-reject.test-fixture.js.map +1 -0
- package/node_modules/goah-runner-pi/dist/steering-worker.test-fixture.d.ts +2 -0
- package/node_modules/goah-runner-pi/dist/steering-worker.test-fixture.d.ts.map +1 -0
- package/node_modules/goah-runner-pi/dist/steering-worker.test-fixture.js +5 -0
- package/node_modules/goah-runner-pi/dist/steering-worker.test-fixture.js.map +1 -0
- package/node_modules/goah-runner-pi/dist/test-helpers.d.ts +1 -1
- package/node_modules/goah-runner-pi/dist/test-helpers.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/test-helpers.js +3 -1
- package/node_modules/goah-runner-pi/dist/test-helpers.js.map +1 -1
- package/node_modules/goah-runner-pi/dist/verification-worker.d.ts.map +1 -1
- package/node_modules/goah-runner-pi/dist/verification-worker.js +3 -5
- package/node_modules/goah-runner-pi/dist/verification-worker.js.map +1 -1
- package/node_modules/goah-runner-pi/package.json +2 -2
- package/node_modules/goah-supervisor/dist/context-view.d.ts +4 -5
- package/node_modules/goah-supervisor/dist/context-view.d.ts.map +1 -1
- package/node_modules/goah-supervisor/dist/context-view.js +7 -20
- package/node_modules/goah-supervisor/dist/context-view.js.map +1 -1
- package/node_modules/goah-supervisor/dist/index.d.ts +38 -36
- package/node_modules/goah-supervisor/dist/index.d.ts.map +1 -1
- package/node_modules/goah-supervisor/dist/index.js +762 -575
- package/node_modules/goah-supervisor/dist/index.js.map +1 -1
- package/node_modules/goah-supervisor/dist/roles.d.ts +2 -1
- package/node_modules/goah-supervisor/dist/roles.d.ts.map +1 -1
- package/node_modules/goah-supervisor/dist/roles.js +20 -5
- package/node_modules/goah-supervisor/dist/roles.js.map +1 -1
- package/node_modules/goah-supervisor/dist/verification.d.ts +8 -29
- package/node_modules/goah-supervisor/dist/verification.d.ts.map +1 -1
- package/node_modules/goah-supervisor/dist/verification.js +71 -46
- package/node_modules/goah-supervisor/dist/verification.js.map +1 -1
- package/node_modules/goah-supervisor/package.json +2 -2
- package/node_modules/goah-testkit/dist/faux-runner-worker.js +16 -9
- package/node_modules/goah-testkit/dist/faux-runner-worker.js.map +1 -1
- package/node_modules/goah-testkit/dist/index.d.ts +13 -16
- package/node_modules/goah-testkit/dist/index.d.ts.map +1 -1
- package/node_modules/goah-testkit/dist/index.js +27 -44
- package/node_modules/goah-testkit/dist/index.js.map +1 -1
- package/node_modules/goah-testkit/package.json +5 -5
- package/package.json +7 -8
- package/dist/console/assets/index-BJTGvlCH.js +0 -52
- package/dist/console/assets/index-BJTGvlCH.js.map +0 -1
- package/dist/console/assets/index-msBn41Cl.css +0 -1
- package/dist/inspect.d.ts +0 -61
- package/dist/inspect.d.ts.map +0 -1
- package/dist/inspect.js +0 -110
- package/dist/inspect.js.map +0 -1
- package/dist/metrics.d.ts +0 -2
- package/dist/metrics.d.ts.map +0 -1
- package/dist/metrics.js +0 -2
- package/dist/metrics.js.map +0 -1
- package/dist/session.d.ts +0 -2
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js +0 -2
- package/dist/session.js.map +0 -1
- package/node_modules/goah-ledger-contract/dist/metrics.d.ts +0 -40
- package/node_modules/goah-ledger-contract/dist/metrics.d.ts.map +0 -1
- package/node_modules/goah-ledger-contract/dist/metrics.js +0 -25
- package/node_modules/goah-ledger-contract/dist/metrics.js.map +0 -1
- package/node_modules/goah-ledger-contract/dist/session.d.ts +0 -63
- package/node_modules/goah-ledger-contract/dist/session.d.ts.map +0 -1
- package/node_modules/goah-ledger-contract/dist/session.js +0 -115
- package/node_modules/goah-ledger-contract/dist/session.js.map +0 -1
- package/node_modules/goah-testkit/dist/mock-connector-worker.d.ts +0 -2
- package/node_modules/goah-testkit/dist/mock-connector-worker.d.ts.map +0 -1
- package/node_modules/goah-testkit/dist/mock-connector-worker.js +0 -29
- package/node_modules/goah-testkit/dist/mock-connector-worker.js.map +0 -1
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { assertHandoff, assertGoalSnapshot, assertGoalTransition, controlStream, goalStream, goalRoute, goalExecutionBinding, wakeStream, workRecordStream, } from "goah-ledger-contract";
|
|
4
|
+
export const SQLITE_SCHEMA_VERSION = 24;
|
|
5
|
+
const createThreads = `CREATE TABLE IF NOT EXISTS threads (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
agent TEXT NOT NULL,
|
|
8
|
+
parent_thread_id TEXT REFERENCES threads(id),
|
|
9
|
+
created_at TEXT NOT NULL,
|
|
10
|
+
updated_at TEXT NOT NULL,
|
|
11
|
+
UNIQUE(agent)
|
|
12
|
+
) STRICT;`;
|
|
13
|
+
const createTurns = `CREATE TABLE IF NOT EXISTS turns (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
16
|
+
source TEXT NOT NULL CHECK(source IN ('human','goal','system')),
|
|
17
|
+
binding_kind TEXT NOT NULL CHECK(binding_kind IN ('human','goal','specialist')),
|
|
18
|
+
specialist_role TEXT CHECK(specialist_role IS NULL OR specialist_role IN ('verifier','audit')),
|
|
19
|
+
goal_id TEXT REFERENCES goals(id),
|
|
20
|
+
goal_revision INTEGER CHECK(goal_revision IS NULL OR goal_revision >= 0),
|
|
21
|
+
status TEXT NOT NULL CHECK(status IN ('in_progress','completed','failed','interrupted')),
|
|
22
|
+
attempt INTEGER NOT NULL CHECK(attempt > 0),
|
|
23
|
+
error TEXT CHECK(error IS NULL OR json_valid(error)),
|
|
24
|
+
started_at TEXT NOT NULL,
|
|
25
|
+
ended_at TEXT,
|
|
26
|
+
lease_until TEXT,
|
|
27
|
+
lease_token TEXT,
|
|
28
|
+
runner_pid INTEGER,
|
|
29
|
+
runner_profile_id TEXT,
|
|
30
|
+
CHECK((goal_id IS NULL) = (goal_revision IS NULL)),
|
|
31
|
+
CHECK((binding_kind='human' AND goal_id IS NULL AND specialist_role IS NULL) OR (binding_kind='goal' AND goal_id IS NOT NULL AND specialist_role IS NULL) OR (binding_kind='specialist' AND goal_id IS NULL AND specialist_role IS NOT NULL)),
|
|
32
|
+
CHECK((binding_kind='human' AND source='human') OR (binding_kind='goal' AND source IN ('human','goal')) OR (binding_kind='specialist' AND source='system')),
|
|
33
|
+
CHECK((status='in_progress' AND ended_at IS NULL AND lease_until IS NOT NULL AND lease_token IS NOT NULL) OR (status<>'in_progress' AND ended_at IS NOT NULL AND lease_until IS NULL AND lease_token IS NULL AND runner_pid IS NULL))
|
|
34
|
+
) STRICT;`;
|
|
35
|
+
const createTurnItems = `CREATE TABLE IF NOT EXISTS turn_items (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
|
|
38
|
+
ordinal INTEGER NOT NULL CHECK(ordinal > 0),
|
|
39
|
+
type TEXT NOT NULL CHECK(type IN ('user_message','assistant_message','reasoning','tool_call','tool_result','plan','handoff')),
|
|
40
|
+
status TEXT NOT NULL CHECK(status IN ('in_progress','completed','failed')),
|
|
41
|
+
data TEXT NOT NULL CHECK(json_valid(data)),
|
|
42
|
+
created_at TEXT NOT NULL,
|
|
43
|
+
completed_at TEXT,
|
|
44
|
+
UNIQUE(turn_id, ordinal),
|
|
45
|
+
CHECK((status='in_progress' AND completed_at IS NULL) OR (status<>'in_progress' AND completed_at IS NOT NULL))
|
|
46
|
+
) STRICT;`;
|
|
4
47
|
const createGoals = `CREATE TABLE IF NOT EXISTS goals (
|
|
5
48
|
id TEXT PRIMARY KEY,
|
|
6
49
|
parent_id TEXT REFERENCES goals(id),
|
|
@@ -18,7 +61,7 @@ const createWorkRecords = `CREATE TABLE IF NOT EXISTS work_records (
|
|
|
18
61
|
content TEXT NOT NULL CHECK(length(trim(content)) > 0),
|
|
19
62
|
updated_by TEXT NOT NULL,
|
|
20
63
|
updated_in_turn TEXT NOT NULL,
|
|
21
|
-
|
|
64
|
+
source_wake_id TEXT,
|
|
22
65
|
updated_at TEXT NOT NULL,
|
|
23
66
|
reason TEXT NOT NULL CHECK(length(trim(reason)) > 0),
|
|
24
67
|
evidence TEXT NOT NULL CHECK(json_valid(evidence)),
|
|
@@ -26,44 +69,45 @@ const createWorkRecords = `CREATE TABLE IF NOT EXISTS work_records (
|
|
|
26
69
|
) STRICT;`;
|
|
27
70
|
const createWakes = `CREATE TABLE IF NOT EXISTS wakes (
|
|
28
71
|
id TEXT PRIMARY KEY,
|
|
72
|
+
binding_kind TEXT NOT NULL CHECK(binding_kind IN ('human','goal','specialist')),
|
|
29
73
|
agent TEXT NOT NULL,
|
|
74
|
+
specialist_role TEXT CHECK(specialist_role IS NULL OR specialist_role IN ('verifier','audit')),
|
|
30
75
|
trigger_ref TEXT NOT NULL,
|
|
31
|
-
status TEXT NOT NULL CHECK(status IN ('queued','
|
|
32
|
-
lease_until TEXT,
|
|
76
|
+
status TEXT NOT NULL CHECK(status IN ('queued','claimed','consumed','cancelled')),
|
|
33
77
|
attempt INTEGER NOT NULL CHECK(attempt >= 0),
|
|
34
|
-
started_at TEXT,
|
|
35
|
-
ended_at TEXT,
|
|
36
78
|
enqueued_seq INTEGER NOT NULL CHECK(enqueued_seq > 0),
|
|
37
|
-
|
|
38
|
-
|
|
79
|
+
claimed_at TEXT,
|
|
80
|
+
consumed_at TEXT,
|
|
81
|
+
turn_id TEXT REFERENCES turns(id),
|
|
82
|
+
goal_id TEXT REFERENCES goals(id),
|
|
39
83
|
UNIQUE(agent, trigger_ref),
|
|
40
|
-
|
|
41
|
-
CHECK((status
|
|
84
|
+
UNIQUE(turn_id),
|
|
85
|
+
CHECK((status='claimed' AND claimed_at IS NOT NULL) OR status<>'claimed'),
|
|
86
|
+
CHECK((status='consumed' AND consumed_at IS NOT NULL AND turn_id IS NOT NULL) OR status<>'consumed'),
|
|
87
|
+
CHECK((binding_kind='human' AND agent='ceo' AND goal_id IS NULL AND specialist_role IS NULL) OR (binding_kind='goal' AND goal_id IS NOT NULL AND specialist_role IS NULL) OR (binding_kind='specialist' AND goal_id IS NULL AND specialist_role IS NOT NULL))
|
|
42
88
|
) STRICT;`;
|
|
43
|
-
const
|
|
44
|
-
|
|
89
|
+
const createWakeTriggers = `CREATE TABLE IF NOT EXISTS wake_triggers (
|
|
90
|
+
wake_id TEXT NOT NULL REFERENCES wakes(id) ON DELETE CASCADE,
|
|
45
91
|
agent TEXT NOT NULL,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
external_ref TEXT,
|
|
55
|
-
audit_advice TEXT CHECK(audit_advice IS NULL OR json_valid(audit_advice)),
|
|
56
|
-
advice_acked INTEGER NOT NULL CHECK(advice_acked IN (0,1)),
|
|
57
|
-
CHECK(reconciled_at IS NULL OR status IN ('confirmed','failed'))
|
|
92
|
+
trigger_ref TEXT NOT NULL,
|
|
93
|
+
source TEXT NOT NULL CHECK(source IN ('human','goal','system')),
|
|
94
|
+
status TEXT NOT NULL CHECK(status IN ('pending','resolved')),
|
|
95
|
+
added_at TEXT NOT NULL,
|
|
96
|
+
resolved_at TEXT,
|
|
97
|
+
PRIMARY KEY(wake_id,trigger_ref),
|
|
98
|
+
UNIQUE(agent,trigger_ref),
|
|
99
|
+
CHECK((status='pending' AND resolved_at IS NULL) OR (status='resolved' AND resolved_at IS NOT NULL))
|
|
58
100
|
) STRICT;`;
|
|
59
101
|
const indexesAndTriggers = `
|
|
60
|
-
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
102
|
+
CREATE UNIQUE INDEX IF NOT EXISTS turns_one_active_thread ON turns(thread_id) WHERE status='in_progress';
|
|
103
|
+
CREATE INDEX IF NOT EXISTS turns_thread_started ON turns(thread_id,started_at,id);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS turn_items_turn_ordinal ON turn_items(turn_id,ordinal);
|
|
105
|
+
CREATE UNIQUE INDEX IF NOT EXISTS wakes_one_claimed_agent ON wakes(agent) WHERE status='claimed';
|
|
61
106
|
CREATE INDEX IF NOT EXISTS wakes_queue_order ON wakes(status, enqueued_seq);
|
|
62
|
-
CREATE INDEX IF NOT EXISTS
|
|
107
|
+
CREATE INDEX IF NOT EXISTS wake_triggers_pending ON wake_triggers(wake_id,status);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS schedule_due ON schedule(status,next_wake_at);
|
|
63
109
|
CREATE INDEX IF NOT EXISTS events_actor_type_seq ON events(actor, type, seq DESC);
|
|
64
110
|
CREATE INDEX IF NOT EXISTS events_stream_seq ON events(stream_id, stream_seq);
|
|
65
|
-
CREATE INDEX IF NOT EXISTS events_coalesced_trigger ON events(json_extract(data,'$.triggerRef'), stream_id) WHERE type='wake.trigger_coalesced';
|
|
66
|
-
CREATE INDEX IF NOT EXISTS actions_agent_status ON actions(agent, status);
|
|
67
111
|
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(actor, type, data, content='events', content_rowid='seq');
|
|
68
112
|
|
|
69
113
|
CREATE TRIGGER IF NOT EXISTS events_no_update BEFORE UPDATE ON events
|
|
@@ -75,20 +119,20 @@ BEGIN INSERT INTO events_fts(rowid,actor,type,data) VALUES (new.seq,new.actor,ne
|
|
|
75
119
|
|
|
76
120
|
CREATE TRIGGER IF NOT EXISTS wakes_valid_transition BEFORE UPDATE OF status ON wakes
|
|
77
121
|
WHEN OLD.status <> NEW.status AND NOT (
|
|
78
|
-
(OLD.status = 'queued' AND NEW.status IN ('
|
|
79
|
-
(OLD.status = '
|
|
80
|
-
(OLD.status = 'running' AND NEW.status IN ('done','abnormal','merge_blocked'))
|
|
122
|
+
(OLD.status = 'queued' AND NEW.status IN ('claimed','cancelled')) OR
|
|
123
|
+
(OLD.status = 'claimed' AND NEW.status IN ('queued','consumed','cancelled'))
|
|
81
124
|
)
|
|
82
125
|
BEGIN SELECT RAISE(ABORT, 'invalid wake transition'); END;
|
|
83
126
|
|
|
84
|
-
CREATE TRIGGER IF NOT EXISTS
|
|
127
|
+
CREATE TRIGGER IF NOT EXISTS schedule_valid_transition BEFORE UPDATE OF status ON schedule
|
|
85
128
|
WHEN OLD.status <> NEW.status AND NOT (
|
|
86
|
-
|
|
87
|
-
(OLD.status = 'approved' AND NEW.status IN ('dispatching','failed')) OR
|
|
88
|
-
(OLD.status = 'dispatching' AND NEW.status IN ('confirmed','failed','unknown')) OR
|
|
89
|
-
(OLD.status = 'unknown' AND NEW.status IN ('dispatching','confirmed','failed'))
|
|
129
|
+
OLD.status = 'pending' AND NEW.status IN ('consumed','cancelled','superseded')
|
|
90
130
|
)
|
|
91
|
-
BEGIN SELECT RAISE(ABORT, 'invalid
|
|
131
|
+
BEGIN SELECT RAISE(ABORT, 'invalid schedule transition'); END;
|
|
132
|
+
|
|
133
|
+
CREATE TRIGGER IF NOT EXISTS wake_triggers_valid_transition BEFORE UPDATE OF status ON wake_triggers
|
|
134
|
+
WHEN OLD.status <> NEW.status AND NOT (OLD.status='pending' AND NEW.status='resolved')
|
|
135
|
+
BEGIN SELECT RAISE(ABORT, 'invalid Wake trigger transition'); END;
|
|
92
136
|
|
|
93
137
|
CREATE TRIGGER IF NOT EXISTS goals_valid_transition BEFORE UPDATE OF phase ON goals
|
|
94
138
|
WHEN OLD.phase <> NEW.phase AND NOT (
|
|
@@ -107,28 +151,43 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
107
151
|
actor TEXT NOT NULL,
|
|
108
152
|
type TEXT NOT NULL,
|
|
109
153
|
data TEXT NOT NULL CHECK(json_valid(data)),
|
|
154
|
+
projection_name TEXT,
|
|
110
155
|
ignorable INTEGER CHECK(ignorable IS NULL OR ignorable = 1),
|
|
111
156
|
UNIQUE(stream_id, stream_seq)
|
|
112
157
|
) STRICT;
|
|
113
158
|
${createGoals}
|
|
114
159
|
${createWorkRecords}
|
|
160
|
+
${createThreads}
|
|
161
|
+
${createTurns}
|
|
162
|
+
${createTurnItems}
|
|
115
163
|
CREATE TABLE IF NOT EXISTS schedule (
|
|
116
164
|
id TEXT PRIMARY KEY,
|
|
165
|
+
binding_kind TEXT NOT NULL CHECK(binding_kind IN ('goal','specialist')),
|
|
117
166
|
agent TEXT NOT NULL,
|
|
167
|
+
specialist_role TEXT CHECK(specialist_role IS NULL OR specialist_role IN ('verifier','audit')),
|
|
118
168
|
next_wake_at TEXT NOT NULL,
|
|
119
169
|
reason TEXT NOT NULL,
|
|
120
|
-
set_by TEXT NOT NULL
|
|
170
|
+
set_by TEXT NOT NULL,
|
|
171
|
+
status TEXT NOT NULL CHECK(status IN ('pending','consumed','cancelled','superseded')),
|
|
172
|
+
resolved_at TEXT,
|
|
173
|
+
goal_id TEXT REFERENCES goals(id),
|
|
174
|
+
CHECK((status='pending' AND resolved_at IS NULL) OR (status<>'pending' AND resolved_at IS NOT NULL)),
|
|
175
|
+
CHECK((binding_kind='goal' AND goal_id IS NOT NULL AND specialist_role IS NULL) OR (binding_kind='specialist' AND goal_id IS NULL AND specialist_role IS NOT NULL))
|
|
121
176
|
) STRICT;
|
|
122
177
|
${createWakes}
|
|
178
|
+
${createWakeTriggers}
|
|
123
179
|
CREATE TABLE IF NOT EXISTS mailbox (
|
|
124
180
|
id TEXT PRIMARY KEY,
|
|
181
|
+
route_kind TEXT NOT NULL CHECK(route_kind IN ('goal','human_inbox','human_request','specialist_inbox')),
|
|
125
182
|
to_agent TEXT NOT NULL,
|
|
126
183
|
from_agent TEXT NOT NULL,
|
|
184
|
+
specialist_role TEXT CHECK(specialist_role IS NULL OR specialist_role IN ('verifier','audit')),
|
|
127
185
|
level TEXT NOT NULL CHECK(level IN ('fyi','decision','emergency')),
|
|
186
|
+
goal_id TEXT REFERENCES goals(id),
|
|
128
187
|
body TEXT NOT NULL CHECK(json_valid(body)),
|
|
129
|
-
read_at TEXT
|
|
188
|
+
read_at TEXT,
|
|
189
|
+
CHECK((route_kind='goal' AND goal_id IS NOT NULL AND specialist_role IS NULL) OR (route_kind='human_inbox' AND to_agent='ceo' AND from_agent IN ('human','verifier','audit','supervisor') AND goal_id IS NULL AND specialist_role IS NULL) OR (route_kind='human_request' AND to_agent='human' AND from_agent='ceo' AND goal_id IS NULL AND specialist_role IS NULL) OR (route_kind='specialist_inbox' AND goal_id IS NULL AND specialist_role IS NOT NULL))
|
|
130
190
|
) STRICT;
|
|
131
|
-
${createActions}
|
|
132
191
|
${indexesAndTriggers}`;
|
|
133
192
|
class SystemClock {
|
|
134
193
|
now() { return new Date(); }
|
|
@@ -147,26 +206,99 @@ export class SqliteLedger {
|
|
|
147
206
|
this.db.close();
|
|
148
207
|
throw new Error(`ledger schema ${version} is newer than supported schema ${SQLITE_SCHEMA_VERSION}`);
|
|
149
208
|
}
|
|
209
|
+
if (version > 0 && version < SQLITE_SCHEMA_VERSION) {
|
|
210
|
+
this.db.close();
|
|
211
|
+
throw new Error(`ledger schema ${version} predates runtime schema ${SQLITE_SCHEMA_VERSION}; recreate this development workspace`);
|
|
212
|
+
}
|
|
150
213
|
if (version === 0) {
|
|
151
214
|
this.db.exec(schema);
|
|
152
215
|
this.db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
153
216
|
}
|
|
154
|
-
else if (version < SQLITE_SCHEMA_VERSION) {
|
|
155
|
-
this.#migrateLegacy();
|
|
156
|
-
}
|
|
157
217
|
else {
|
|
158
218
|
this.db.exec(schema);
|
|
159
219
|
}
|
|
160
220
|
}
|
|
161
221
|
close() { this.db.close(); }
|
|
162
|
-
appendEvent(input) { return this.#transaction(() => this.#insertEvent(input)); }
|
|
163
|
-
appendEvents(inputs) {
|
|
222
|
+
appendEvent(input) { this.#assertRawEvent(input); return this.#transaction(() => this.#insertEvent(input)); }
|
|
223
|
+
appendEvents(inputs) { for (const input of inputs)
|
|
224
|
+
this.#assertRawEvent(input); return this.#transaction(() => inputs.map((input) => this.#insertEvent(input))); }
|
|
164
225
|
readStream(streamId, fromStreamSeq = 1) {
|
|
165
226
|
return this.db.prepare("SELECT * FROM events WHERE stream_id=? AND stream_seq>=? ORDER BY stream_seq").all(streamId, fromStreamSeq).map(mapEvent);
|
|
166
227
|
}
|
|
167
|
-
|
|
228
|
+
putThread(thread, actor) {
|
|
229
|
+
if (!thread.id.trim() || !thread.agent.trim())
|
|
230
|
+
throw new Error("thread id and agent are required");
|
|
231
|
+
const current = this.thread(thread.id);
|
|
232
|
+
const agentThread = this.threads().find((candidate) => candidate.agent === thread.agent);
|
|
233
|
+
if (agentThread && agentThread.id !== thread.id)
|
|
234
|
+
throw new Error("agent already owns a Thread");
|
|
235
|
+
if (current && (current.agent !== thread.agent || current.parentThreadId !== thread.parentThreadId || current.createdAt !== thread.createdAt))
|
|
236
|
+
throw new Error("thread identity cannot change");
|
|
237
|
+
return this.#project("threads", thread, actor, "thread.put", undefined, thread.updatedAt, `thread:${thread.id}`);
|
|
238
|
+
}
|
|
239
|
+
putTurn(turn, actor) {
|
|
240
|
+
if (!turn.id.trim() || !turn.threadId.trim())
|
|
241
|
+
throw new Error("turn id and thread are required");
|
|
242
|
+
this.#assertTurnBinding(turn);
|
|
243
|
+
if (turn.status === "in_progress" && (!turn.leaseUntil || !turn.leaseToken) || turn.status !== "in_progress" && (turn.leaseUntil !== null || turn.leaseToken !== null || turn.runnerPid !== null))
|
|
244
|
+
throw new Error("Turn execution ownership does not match status");
|
|
245
|
+
const thread = this.thread(turn.threadId);
|
|
246
|
+
if (!thread)
|
|
247
|
+
throw new Error("Turn Thread does not exist");
|
|
248
|
+
const current = this.turn(turn.id);
|
|
249
|
+
if (current) {
|
|
250
|
+
if (turn.status !== "in_progress")
|
|
251
|
+
throw new Error("terminal Turn state must be committed through finishTurn or commitHandoff");
|
|
252
|
+
if (current.threadId !== turn.threadId || current.source !== turn.source)
|
|
253
|
+
throw new Error("turn identity cannot change");
|
|
254
|
+
if (current.runnerProfileId !== turn.runnerProfileId)
|
|
255
|
+
throw new Error("turn Runner Profile cannot change");
|
|
256
|
+
if (current.status !== "in_progress")
|
|
257
|
+
throw new Error("terminal turn cannot change");
|
|
258
|
+
const bindsGoal = actor === "supervisor" && current.bindingKind === "human" && turn.bindingKind === "goal" && current.source === "human";
|
|
259
|
+
if (current.bindingKind !== turn.bindingKind && !bindsGoal || current.bindingKind === turn.bindingKind && (current.goalId !== turn.goalId || current.goalRevision !== turn.goalRevision || current.specialistRole !== turn.specialistRole))
|
|
260
|
+
throw new Error("turn execution binding cannot change");
|
|
261
|
+
if (bindsGoal) {
|
|
262
|
+
const goal = this.#getGoal(turn.goalId);
|
|
263
|
+
if (thread.agent !== "ceo" || !goal || goal.parentId !== null || goal.owner !== "ceo" || goal.phase !== "active" || goal.revision !== turn.goalRevision)
|
|
264
|
+
throw new Error("Human Turn may bind only the current active CEO Root Goal");
|
|
265
|
+
}
|
|
266
|
+
if (turn.attempt !== current.attempt && turn.attempt !== current.attempt + 1)
|
|
267
|
+
throw new Error("turn attempt must stay current or increment by one");
|
|
268
|
+
if (turn.status === "in_progress" && turn.endedAt !== null || turn.status !== "in_progress" && turn.endedAt === null)
|
|
269
|
+
throw new Error("turn terminal time does not match status");
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
if (turn.status !== "in_progress" || turn.attempt !== 1)
|
|
273
|
+
throw new Error("new turn must start in progress at attempt one");
|
|
274
|
+
if (actor !== "human" || thread.agent !== "ceo" || turn.bindingKind !== "human")
|
|
275
|
+
throw new Error("new automatic Turns must start from a claimed Wake");
|
|
276
|
+
}
|
|
277
|
+
return this.#project("turns", turn, actor, current ? `turn.${turn.status}` : "turn.started", undefined, undefined, `turn:${turn.id}`);
|
|
278
|
+
}
|
|
279
|
+
putTurnItem(item, actor) {
|
|
280
|
+
return this.#transaction(() => this.#admitTurnItem(item, actor));
|
|
281
|
+
}
|
|
282
|
+
thread(id) { const row = this.db.prepare("SELECT * FROM threads WHERE id=?").get(id); return row ? mapThread(row) : null; }
|
|
283
|
+
threads() { return this.db.prepare("SELECT * FROM threads ORDER BY created_at,id").all().map(mapThread); }
|
|
284
|
+
turn(id) { const row = this.db.prepare("SELECT * FROM turns WHERE id=?").get(id); return row ? mapTurn(row) : null; }
|
|
285
|
+
turns(threadId) { return (threadId ? this.db.prepare("SELECT * FROM turns WHERE thread_id=? ORDER BY rowid").all(threadId) : this.db.prepare("SELECT * FROM turns ORDER BY rowid").all()).map(mapTurn); }
|
|
286
|
+
turnItems(turnId) { return this.db.prepare("SELECT * FROM turn_items WHERE turn_id=? ORDER BY ordinal").all(turnId).map(mapTurnItem); }
|
|
287
|
+
activeTurn(threadId) { const row = this.db.prepare("SELECT * FROM turns WHERE thread_id=? AND status='in_progress'").get(threadId); return row ? mapTurn(row) : null; }
|
|
288
|
+
putGoal(goal, actor, wakeId, change) {
|
|
168
289
|
const normalized = normalizeGoal(goal);
|
|
169
290
|
assertGoalSnapshot(normalized);
|
|
291
|
+
if (change?.idempotencyKey) {
|
|
292
|
+
const row = this.db.prepare("SELECT * FROM events WHERE type='goal.changed' AND actor=? AND json_extract(data,'$.idempotencyKey')=? ORDER BY seq LIMIT 1").get(actor, change.idempotencyKey);
|
|
293
|
+
if (row) {
|
|
294
|
+
const event = mapEvent(row);
|
|
295
|
+
const data = event.data;
|
|
296
|
+
const same = isDeepStrictEqual(data.snapshot, normalized) && data.operation === change.operation && data.reason === change.reason.trim() && isDeepStrictEqual(data.evidence, change.evidence) && (!change.authority || isDeepStrictEqual(data.authority, change.authority));
|
|
297
|
+
if (!same)
|
|
298
|
+
throw new Error("Goal idempotency key was reused with a different mutation");
|
|
299
|
+
return event;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
170
302
|
const current = this.#getGoal(normalized.id);
|
|
171
303
|
if (current) {
|
|
172
304
|
if (normalized.revision !== current.revision + 1)
|
|
@@ -177,6 +309,9 @@ export class SqliteLedger {
|
|
|
177
309
|
throw new Error("goal reparenting is not supported");
|
|
178
310
|
if (normalized.phase === "complete")
|
|
179
311
|
throw new Error("goal completion requires reason and evidence");
|
|
312
|
+
const definitionChanged = normalized.objective !== current.objective || normalized.observationMethod !== current.observationMethod || normalized.verificationMethod !== current.verificationMethod;
|
|
313
|
+
if (current.phase !== normalized.phase && (current.owner !== normalized.owner || definitionChanged) || current.owner !== normalized.owner && definitionChanged)
|
|
314
|
+
throw new Error("Goal mutation must express exactly one lifecycle operation");
|
|
180
315
|
if (normalized.objective !== current.objective && ((current.observationMethod !== null && normalized.observationMethod === current.observationMethod) || (current.verificationMethod !== null && normalized.verificationMethod === current.verificationMethod)))
|
|
181
316
|
throw new Error("objective revision must replace or invalidate observation and verification methods");
|
|
182
317
|
assertGoalTransition(current.phase, normalized.phase);
|
|
@@ -187,8 +322,9 @@ export class SqliteLedger {
|
|
|
187
322
|
throw new Error("new goal revision must be 0");
|
|
188
323
|
this.#assertGoalAuthority(normalized.parentId, actor);
|
|
189
324
|
}
|
|
325
|
+
this.#assertOwnerSeparation(normalized.id, normalized.parentId, normalized.owner);
|
|
190
326
|
return this.#transaction(() => {
|
|
191
|
-
const event = this.#
|
|
327
|
+
const event = this.#recordGoalChange(normalized, current, actor, wakeId, change);
|
|
192
328
|
if (!current)
|
|
193
329
|
this.#createWorkRecord(normalized, actor, wakeId ?? `goal:create:${normalized.id}`, wakeId);
|
|
194
330
|
return event;
|
|
@@ -204,6 +340,9 @@ export class SqliteLedger {
|
|
|
204
340
|
if (goal.revision !== request.goalRevision)
|
|
205
341
|
throw new Error("work record goal revision is stale");
|
|
206
342
|
this.#assertWorkRecordAuthority(goal, actor);
|
|
343
|
+
const turn = this.turn(request.turnId);
|
|
344
|
+
if (!turn || turn.status !== "in_progress" || turn.bindingKind !== "goal" || turn.goalId !== goal.id || turn.goalRevision !== request.goalRevision)
|
|
345
|
+
throw new Error("work record update does not match an active Goal-bound Turn");
|
|
207
346
|
const current = this.workRecord(goal.id);
|
|
208
347
|
if (!current)
|
|
209
348
|
throw new Error("work record does not exist");
|
|
@@ -218,7 +357,7 @@ export class SqliteLedger {
|
|
|
218
357
|
content: request.content,
|
|
219
358
|
updatedBy: actor,
|
|
220
359
|
updatedInTurn: request.turnId,
|
|
221
|
-
|
|
360
|
+
sourceWakeId: request.sourceWakeId ?? null,
|
|
222
361
|
updatedAt: this.#now(),
|
|
223
362
|
reason: request.reason,
|
|
224
363
|
evidence: request.evidence,
|
|
@@ -227,8 +366,10 @@ export class SqliteLedger {
|
|
|
227
366
|
commitDelegation(request, actor, wakeId) {
|
|
228
367
|
const existing = this.#delegationResult(request.id);
|
|
229
368
|
if (existing) {
|
|
230
|
-
|
|
231
|
-
|
|
369
|
+
const event = this.events().find((candidate) => candidate.type === "delegation.created" && candidate.data.delegationId === request.id);
|
|
370
|
+
const prior = event?.data?.request;
|
|
371
|
+
if (event?.actor !== actor || !isDeepStrictEqual(prior, withoutSourceTurn(request)))
|
|
372
|
+
throw new Error("delegation id was reused with a different request");
|
|
232
373
|
return existing;
|
|
233
374
|
}
|
|
234
375
|
if (!request.id.trim() || !request.reason.trim())
|
|
@@ -241,6 +382,8 @@ export class SqliteLedger {
|
|
|
241
382
|
const parent = this.#getGoal(request.parentGoalId);
|
|
242
383
|
if (!parent)
|
|
243
384
|
throw new Error("delegation parent goal does not exist");
|
|
385
|
+
if (parent.revision !== request.expectedParentRevision)
|
|
386
|
+
throw new Error("delegation parent Goal revision is stale");
|
|
244
387
|
if (parent.owner !== actor)
|
|
245
388
|
throw new Error("only the parent goal owner may delegate");
|
|
246
389
|
if (parent.phase !== "active")
|
|
@@ -254,43 +397,44 @@ export class SqliteLedger {
|
|
|
254
397
|
to: goal.owner,
|
|
255
398
|
from: actor,
|
|
256
399
|
level: "decision",
|
|
400
|
+
...goalRoute(goal.id),
|
|
257
401
|
body: { type: "delegation", delegationId: request.id, goalId: goal.id, parentGoalId: request.parentGoalId, objective: goal.objective, observationMethod: goal.observationMethod, verificationMethod: goal.verificationMethod ?? null, brief: request.brief, reason: request.reason, evidence: request.evidence },
|
|
258
402
|
readAt: null,
|
|
259
403
|
};
|
|
260
404
|
const wakeBase = {
|
|
261
405
|
id: `delegation-wake:${request.id}`,
|
|
262
|
-
|
|
406
|
+
...goalExecutionBinding(goal.owner, goal.id),
|
|
263
407
|
triggerRef: `delegation:${request.id}`,
|
|
264
408
|
status: "queued",
|
|
265
|
-
leaseUntil: null,
|
|
266
409
|
attempt: 0,
|
|
267
|
-
startedAt: null,
|
|
268
|
-
endedAt: null,
|
|
269
410
|
enqueuedSeq: 0,
|
|
270
|
-
|
|
271
|
-
|
|
411
|
+
claimedAt: null,
|
|
412
|
+
consumedAt: null,
|
|
413
|
+
turnId: null,
|
|
272
414
|
};
|
|
273
415
|
this.#insertEvent({
|
|
274
416
|
streamId: wakeId ? wakeStream(wakeId) : controlStream("delegations"),
|
|
275
417
|
ts: this.#now(),
|
|
276
418
|
actor,
|
|
277
419
|
type: "delegation.created",
|
|
278
|
-
data: { delegationId: request.id, parentGoalId: request.parentGoalId, goalId: goal.id, mailId: mail.id, wakeId: wakeBase.id, reason: request.reason, evidence: request.evidence },
|
|
420
|
+
data: { delegationId: request.id, parentGoalId: request.parentGoalId, goalId: goal.id, mailId: mail.id, wakeId: wakeBase.id, reason: request.reason, evidence: request.evidence, request: withoutSourceTurn(request) },
|
|
279
421
|
});
|
|
280
422
|
this.#faultInjector?.("after_delegation_event");
|
|
281
|
-
this.#
|
|
423
|
+
this.#recordGoalChange(goal, null, actor, wakeId, { operation: "create", reason: request.reason, evidence: request.evidence, authority: { kind: "parent_goal", goalId: parent.id, goalRevision: parent.revision }, ...(request.sourceTurnId ? { sourceTurnId: request.sourceTurnId } : {}), ...(wakeId ? { sourceWakeId: wakeId } : {}), idempotencyKey: request.id });
|
|
282
424
|
this.#createWorkRecord(goal, actor, wakeId ?? `delegation:${request.id}`, wakeId);
|
|
425
|
+
this.#assertNewMail(mail, actor);
|
|
283
426
|
this.#recordProjection("mailbox", mail, actor, "mail.put", wakeId);
|
|
284
|
-
const wake =
|
|
285
|
-
this.#recordProjection("wakes", wake, "supervisor", "wake.enqueued", wake.id, undefined, wake.enqueuedSeq);
|
|
427
|
+
const wake = this.#admitWake(wakeBase, "supervisor", false).wake;
|
|
286
428
|
return { delegationId: request.id, goal, mail, wake };
|
|
287
429
|
});
|
|
288
430
|
}
|
|
289
431
|
commitReassignment(request, actor, wakeId) {
|
|
290
432
|
const existing = this.#reassignmentResult(request.id);
|
|
291
433
|
if (existing) {
|
|
292
|
-
|
|
293
|
-
|
|
434
|
+
const event = this.events().find((candidate) => candidate.type === "goal.reassigned" && candidate.data.reassignmentId === request.id);
|
|
435
|
+
const prior = event?.data?.request;
|
|
436
|
+
if (event?.actor !== actor || !isDeepStrictEqual(prior, withoutSourceTurn(request)))
|
|
437
|
+
throw new Error("reassignment id was reused with a different request");
|
|
294
438
|
return existing;
|
|
295
439
|
}
|
|
296
440
|
if (!request.id.trim() || !request.newOwner.trim() || !request.reason.trim())
|
|
@@ -299,6 +443,8 @@ export class SqliteLedger {
|
|
|
299
443
|
const current = this.#getGoal(request.goalId);
|
|
300
444
|
if (!current)
|
|
301
445
|
throw new Error("reassignment goal does not exist");
|
|
446
|
+
if (current.revision !== request.expectedGoalRevision)
|
|
447
|
+
throw new Error("reassignment Goal revision is stale");
|
|
302
448
|
if (!current.parentId)
|
|
303
449
|
throw new Error("root goals cannot be reassigned by CEO");
|
|
304
450
|
this.#assertGoalAuthority(current.parentId, actor);
|
|
@@ -306,23 +452,19 @@ export class SqliteLedger {
|
|
|
306
452
|
throw new Error("completed goals cannot be reassigned");
|
|
307
453
|
if (current.owner === request.newOwner)
|
|
308
454
|
throw new Error("reassignment owner is unchanged");
|
|
455
|
+
this.#assertOwnerSeparation(current.id, current.parentId, request.newOwner);
|
|
309
456
|
return this.#transaction(() => {
|
|
310
457
|
const goal = { ...current, owner: request.newOwner, revision: current.revision + 1 };
|
|
311
|
-
const mail = [
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
];
|
|
315
|
-
const wakeBase = { id: `reassignment-wake:${request.id}`, agent: goal.owner, triggerRef: `reassignment:${request.id}`, status: "queued", leaseUntil: null, attempt: 0, startedAt: null, endedAt: null, enqueuedSeq: 0, leaseToken: null, runnerPid: null };
|
|
316
|
-
this.#insertEvent({ streamId: wakeId ? wakeStream(wakeId) : controlStream("reassignments"), ts: this.#now(), actor, type: "goal.reassigned", data: { reassignmentId: request.id, goalId: goal.id, oldOwner: current.owner, newOwner: goal.owner, mailIds: mail.map((item) => item.id), wakeId: wakeBase.id, reason: request.reason, evidence: request.evidence } });
|
|
458
|
+
const mail = [{ id: `reassignment-new-mail:${request.id}`, to: goal.owner, from: actor, level: "decision", ...goalRoute(goal.id), body: { type: "reassignment", reassignmentId: request.id, role: "new_owner", previousOwner: current.owner, brief: request.brief, reason: request.reason, evidence: request.evidence }, readAt: null }];
|
|
459
|
+
const wakeBase = goal.phase === "active" ? { id: `reassignment-wake:${request.id}`, ...goalExecutionBinding(goal.owner, goal.id), triggerRef: `reassignment:${request.id}`, status: "queued", attempt: 0, enqueuedSeq: 0, claimedAt: null, consumedAt: null, turnId: null } : null;
|
|
460
|
+
this.#insertEvent({ streamId: wakeId ? wakeStream(wakeId) : controlStream("reassignments"), ts: this.#now(), actor, type: "goal.reassigned", data: { reassignmentId: request.id, goalId: goal.id, oldOwner: current.owner, newOwner: goal.owner, mailIds: mail.map((item) => item.id), wakeId: wakeBase?.id ?? null, reason: request.reason, evidence: request.evidence, request: withoutSourceTurn(request) } });
|
|
317
461
|
this.#faultInjector?.("after_reassignment_event");
|
|
318
|
-
this.#
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
this.#recordProjection("wakes", { ...oldWake, status: "abnormal", endedAt: this.#now() }, "supervisor", "wake.suppressed", oldWake.id);
|
|
322
|
-
for (const item of mail)
|
|
462
|
+
this.#recordGoalChange(goal, current, actor, wakeId, { operation: "reassign", reason: request.reason, evidence: request.evidence, authority: { kind: "parent_goal", goalId: current.parentId, goalRevision: this.#getGoal(current.parentId).revision }, ...(request.sourceTurnId ? { sourceTurnId: request.sourceTurnId } : {}), ...(wakeId ? { sourceWakeId: wakeId } : {}), idempotencyKey: request.id });
|
|
463
|
+
for (const item of mail) {
|
|
464
|
+
this.#assertNewMail(item, actor);
|
|
323
465
|
this.#recordProjection("mailbox", item, actor, "mail.put", wakeId);
|
|
324
|
-
|
|
325
|
-
this.#
|
|
466
|
+
}
|
|
467
|
+
const wake = wakeBase ? this.#admitWake(wakeBase, "supervisor", false).wake : null;
|
|
326
468
|
return { reassignmentId: request.id, goal, mail, wake };
|
|
327
469
|
});
|
|
328
470
|
}
|
|
@@ -333,10 +475,15 @@ export class SqliteLedger {
|
|
|
333
475
|
const current = this.#getGoal(request.goalId);
|
|
334
476
|
if (!current)
|
|
335
477
|
throw new Error("goal does not exist");
|
|
478
|
+
if (current.phase === "complete") {
|
|
479
|
+
const decision = this.readStream(goalStream(current.id)).findLast((event) => event.type === "goal.completion_decided");
|
|
480
|
+
const data = decision?.data;
|
|
481
|
+
if (decision?.actor === actor && data.revision === request.revision && data.reason === request.reason && JSON.stringify(data.evidence) === JSON.stringify(request.evidence))
|
|
482
|
+
return current;
|
|
483
|
+
throw new Error("completed goal cannot accept a different completion decision");
|
|
484
|
+
}
|
|
336
485
|
if (request.revision !== current.revision)
|
|
337
486
|
throw new Error("goal completion revision is stale");
|
|
338
|
-
if (current.phase === "complete")
|
|
339
|
-
return current;
|
|
340
487
|
if (current.observationMethod === null)
|
|
341
488
|
throw new Error("goal completion requires an observation method");
|
|
342
489
|
if (current.verificationMethod === null || current.verificationMethod === undefined)
|
|
@@ -344,7 +491,7 @@ export class SqliteLedger {
|
|
|
344
491
|
this.#assertGoalAuthority(current.parentId, actor);
|
|
345
492
|
if (current.parentId === null && this.#hasNonCompleteDescendant(current.id))
|
|
346
493
|
throw new Error("root goal cannot complete while descendants remain non-complete");
|
|
347
|
-
const source = this.db.prepare("SELECT seq FROM events WHERE stream_id=? AND type='goal.
|
|
494
|
+
const source = this.db.prepare("SELECT seq FROM events WHERE stream_id=? AND type='goal.changed' AND json_extract(data,'$.snapshot.revision')=? ORDER BY seq DESC LIMIT 1").get(goalStream(current.id), current.revision);
|
|
348
495
|
if (!source)
|
|
349
496
|
throw new Error("goal revision has no source event");
|
|
350
497
|
if (request.evidence.some((seq) => seq <= source.seq))
|
|
@@ -352,214 +499,188 @@ export class SqliteLedger {
|
|
|
352
499
|
return this.#transaction(() => {
|
|
353
500
|
this.#insertEvent({ streamId: goalStream(current.id), ts: this.#now(), actor, type: "goal.completion_decided", data: { goalId: current.id, revision: current.revision, observationMethod: current.observationMethod, verificationMethod: current.verificationMethod ?? null, reason: request.reason, evidence: request.evidence } });
|
|
354
501
|
const next = { ...current, phase: "complete", revision: current.revision + 1 };
|
|
355
|
-
this.#
|
|
502
|
+
this.#recordGoalChange(next, current, actor, wakeId, { operation: "complete", reason: request.reason, evidence: request.evidence, authority: this.#goalAuthority(current.parentId, actor), ...(request.sourceTurnId ? { sourceTurnId: request.sourceTurnId } : {}), ...(wakeId ? { sourceWakeId: wakeId } : {}) });
|
|
356
503
|
return next;
|
|
357
504
|
});
|
|
358
505
|
}
|
|
359
506
|
putSchedule(value, actor, wakeId) {
|
|
360
|
-
|
|
361
|
-
throw new Error("schedule may only be set by its agent or supervisor");
|
|
362
|
-
return this.#project("schedule", value, actor, "schedule.put", wakeId);
|
|
507
|
+
return this.#transaction(() => this.#admitSchedule(value, actor, wakeId));
|
|
363
508
|
}
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
509
|
+
consumeSchedule(id, wake, now) { const current = this.#requiredSchedule(id); if (current.status !== "pending")
|
|
510
|
+
throw new Error("only a pending Schedule may be consumed"); if (wake.bindingKind !== current.bindingKind || wake.agent !== current.agent || wake.specialistRole !== current.specialistRole || wake.triggerRef !== `${current.id}@${current.nextWakeAt}` || wake.goalId !== current.goalId)
|
|
511
|
+
throw new Error("scheduled Wake does not match Schedule"); if (wake.status !== "queued" || wake.attempt !== 0 || wake.claimedAt || wake.consumedAt || wake.turnId)
|
|
512
|
+
throw new Error("scheduled Wake must be pristine"); return this.#transaction(() => { const exact = this.wakeByTrigger(wake.agent, wake.triggerRef); let stored; if (exact) {
|
|
513
|
+
if (exact.status !== "queued" || exact.bindingKind !== wake.bindingKind || exact.specialistRole !== wake.specialistRole || exact.goalId !== wake.goalId)
|
|
514
|
+
throw new Error("scheduled Wake trigger is already terminal or bound differently");
|
|
515
|
+
stored = exact;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const queued = this.wakes().find((candidate) => candidate.agent === wake.agent && candidate.status === "queued" && candidate.bindingKind === wake.bindingKind && candidate.specialistRole === wake.specialistRole && candidate.goalId === wake.goalId);
|
|
519
|
+
if (queued) {
|
|
520
|
+
stored = queued;
|
|
521
|
+
this.#admitWakeTrigger(queued.id, wake.triggerRef, "supervisor", now);
|
|
376
522
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
523
|
+
else
|
|
524
|
+
stored = this.#admitWake(wake, "supervisor", false).wake;
|
|
525
|
+
} const schedule = { ...current, status: "consumed", resolvedAt: now }; this.#recordProjection("schedule", schedule, "supervisor", "schedule.consumed", undefined, now); return { schedule, wake: stored }; }); }
|
|
526
|
+
cancelSchedule(id, now) { const current = this.#requiredSchedule(id); if (current.status !== "pending")
|
|
527
|
+
throw new Error("only a pending Schedule may be cancelled"); const next = { ...current, status: "cancelled", resolvedAt: now }; this.#project("schedule", next, "supervisor", "schedule.cancelled", undefined, now); return next; }
|
|
528
|
+
supersedeSchedule(id, now) { const current = this.#requiredSchedule(id); if (current.status !== "pending")
|
|
529
|
+
throw new Error("only a pending Schedule may be superseded"); const next = { ...current, status: "superseded", resolvedAt: now }; this.#project("schedule", next, "supervisor", "schedule.superseded", undefined, now); return next; }
|
|
530
|
+
enqueueWake(input, actor) {
|
|
531
|
+
return this.#transaction(() => { const result = this.#admitWake(input, actor, true); return { event: result.event, created: result.created }; });
|
|
383
532
|
}
|
|
384
|
-
|
|
533
|
+
addWakeTrigger(wakeId, triggerRef, actor) { return this.#transaction(() => this.#admitWakeTrigger(wakeId, triggerRef, actor, this.#now())); }
|
|
534
|
+
claimNextWake(now) {
|
|
385
535
|
return this.#transaction(() => {
|
|
386
536
|
const row = this.db.prepare(`SELECT * FROM wakes w WHERE status='queued' AND NOT EXISTS (
|
|
387
|
-
SELECT 1 FROM wakes active WHERE active.agent=w.agent AND active.status
|
|
388
|
-
)
|
|
537
|
+
SELECT 1 FROM wakes active WHERE active.agent=w.agent AND active.status='claimed'
|
|
538
|
+
) AND NOT EXISTS (
|
|
539
|
+
SELECT 1 FROM turns WHERE source='human' AND status='in_progress'
|
|
540
|
+
) AND NOT EXISTS (
|
|
541
|
+
SELECT 1 FROM turns t JOIN threads th ON th.id=t.thread_id WHERE th.agent=w.agent AND t.status='in_progress'
|
|
542
|
+
) ORDER BY enqueued_seq LIMIT 1`).get();
|
|
389
543
|
if (!row)
|
|
390
544
|
return null;
|
|
391
545
|
const current = mapWake(row);
|
|
392
|
-
const next = { ...current, status: "
|
|
393
|
-
this.#recordProjection("wakes", next, "supervisor", "wake.
|
|
546
|
+
const next = { ...current, status: "claimed", claimedAt: now, attempt: current.attempt + 1 };
|
|
547
|
+
this.#recordProjection("wakes", next, "supervisor", "wake.claimed", next.id, now);
|
|
394
548
|
return next;
|
|
395
549
|
});
|
|
396
550
|
}
|
|
397
|
-
|
|
551
|
+
startTurnFromWake(id, turn, now) { const wake = this.#requiredWake(id); if (wake.status !== "claimed")
|
|
552
|
+
throw new Error("only a claimed Wake may start a Turn"); if (this.turn(turn.id))
|
|
553
|
+
throw new Error("Wake Turn already exists"); this.#assertTurnBinding(turn); if (turn.status !== "in_progress" || turn.attempt !== 1 || !turn.leaseUntil || !turn.leaseToken)
|
|
554
|
+
throw new Error("Wake must start an owned Turn at attempt one"); const thread = this.thread(turn.threadId); if (!thread || thread.agent !== wake.agent)
|
|
555
|
+
throw new Error("Wake Turn agent does not match"); if (!sameWakeTurnBinding(wake, turn))
|
|
556
|
+
throw new Error("Wake Turn execution binding does not match"); return this.#transaction(() => { if (this.db.prepare("SELECT 1 FROM turns WHERE source='human' AND status='in_progress' LIMIT 1").get())
|
|
557
|
+
throw new Error("automatic Wake cannot start while a Human Turn is in progress"); this.#assertCurrentAutomaticTurn(turn, thread.agent); this.#recordProjection("turns", turn, "supervisor", "turn.started", undefined, now, undefined, `turn:${turn.id}`); this.#resolveWakeTriggers(id, now); const next = { ...wake, status: "consumed", consumedAt: now, turnId: turn.id }; this.#recordProjection("wakes", next, "supervisor", "wake.consumed", id, now); return next; }); }
|
|
558
|
+
releaseWake(id, now) {
|
|
398
559
|
const current = this.#requiredWake(id);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
560
|
+
if (current.status !== "claimed")
|
|
561
|
+
throw new Error("only a claimed Wake may be released");
|
|
562
|
+
const next = { ...current, status: "queued", claimedAt: null };
|
|
563
|
+
this.#project("wakes", next, "supervisor", "wake.released", id, now);
|
|
402
564
|
return next;
|
|
403
565
|
}
|
|
404
|
-
|
|
566
|
+
cancelWake(id, now) {
|
|
405
567
|
const current = this.#requiredWake(id);
|
|
406
|
-
this.#
|
|
407
|
-
if (current.status !== "running")
|
|
408
|
-
throw new Error("runner pid may only be attached to a running wake");
|
|
409
|
-
const next = { ...current, runnerPid: pid };
|
|
410
|
-
this.#project("wakes", next, "supervisor", "wake.runner_attached", id, now);
|
|
411
|
-
return next;
|
|
568
|
+
return this.#transaction(() => this.#cancelWakeProjection(current, now));
|
|
412
569
|
}
|
|
413
|
-
|
|
414
|
-
const current = this.#
|
|
415
|
-
this.#
|
|
416
|
-
if (current.status !== "
|
|
417
|
-
throw new Error("only
|
|
570
|
+
attachTurnProcess(id, leaseToken, pid) {
|
|
571
|
+
const current = this.#requiredTurn(id);
|
|
572
|
+
this.#assertTurnLease(current, leaseToken);
|
|
573
|
+
if (current.status !== "in_progress")
|
|
574
|
+
throw new Error("runner pid may only be attached to an active Turn");
|
|
575
|
+
this.putTurn({ ...current, runnerPid: pid }, "supervisor");
|
|
576
|
+
return this.#requiredTurn(id);
|
|
577
|
+
}
|
|
578
|
+
renewTurnLease(id, leaseToken, leaseUntil, now) {
|
|
579
|
+
const current = this.#requiredTurn(id);
|
|
580
|
+
this.#assertTurnLease(current, leaseToken);
|
|
581
|
+
if (current.status !== "in_progress")
|
|
582
|
+
throw new Error("only an active Turn lease may be renewed");
|
|
418
583
|
if (leaseUntil <= now)
|
|
419
584
|
throw new Error("renewed lease must expire in the future");
|
|
420
|
-
|
|
421
|
-
this.#
|
|
422
|
-
return next;
|
|
585
|
+
this.putTurn({ ...current, leaseUntil }, "supervisor");
|
|
586
|
+
return this.#requiredTurn(id);
|
|
423
587
|
}
|
|
424
|
-
|
|
425
|
-
const current = this.#requiredWake(id);
|
|
426
|
-
const next = { ...current, status, leaseUntil: null, leaseToken: null, runnerPid: null, endedAt: now };
|
|
427
|
-
this.#project("wakes", next, "supervisor", `wake.${status}`, id, now);
|
|
428
|
-
return next;
|
|
429
|
-
}
|
|
430
|
-
expiredWakes(now) {
|
|
431
|
-
return this.db.prepare("SELECT * FROM wakes WHERE status IN ('leased','running') AND lease_until <= ? ORDER BY enqueued_seq").all(now).map(mapWake);
|
|
432
|
-
}
|
|
433
|
-
recoverExpiredWake(id, now) {
|
|
434
|
-
const current = this.#requiredWake(id);
|
|
435
|
-
if (current.status !== "leased" && current.status !== "running")
|
|
436
|
-
throw new Error("wake is not recoverable");
|
|
437
|
-
if (!current.leaseUntil || current.leaseUntil > now)
|
|
438
|
-
throw new Error("wake lease has not expired");
|
|
439
|
-
const running = current.status === "running";
|
|
440
|
-
const next = { ...current, status: running ? "abnormal" : "queued", leaseUntil: null, leaseToken: null, runnerPid: null, endedAt: running ? now : null };
|
|
441
|
-
this.#project("wakes", next, "supervisor", running ? "wake.expired_abnormal" : "wake.lease_expired", id, now);
|
|
442
|
-
return next;
|
|
443
|
-
}
|
|
444
|
-
appendRunnerEvent(input, leaseToken) {
|
|
588
|
+
appendTurnEvent(input, leaseToken) {
|
|
445
589
|
return this.#transaction(() => {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
this.#
|
|
450
|
-
|
|
590
|
+
this.#assertRawEvent(input);
|
|
591
|
+
if (!input.streamId.startsWith("turn:"))
|
|
592
|
+
throw new Error("runner event requires a Turn stream");
|
|
593
|
+
const turn = this.#requiredTurn(input.streamId.slice("turn:".length));
|
|
594
|
+
this.#assertTurnLease(turn, leaseToken);
|
|
595
|
+
if (turn.status !== "in_progress" || !turn.leaseUntil || input.ts > turn.leaseUntil)
|
|
451
596
|
throw new Error("stale runner event rejected");
|
|
452
597
|
return this.#insertEvent(input);
|
|
453
598
|
});
|
|
454
599
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return this.#
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
this
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return rows.map(({ id }) => this.transitionAction(id, "unknown"));
|
|
484
|
-
}
|
|
485
|
-
putAuditAdvice(id, input, wakeId) {
|
|
486
|
-
const advice = { ...input, at: this.#now() };
|
|
487
|
-
this.#assertEvidenceExists(advice.evidence);
|
|
488
|
-
const current = this.#requiredAction(id);
|
|
489
|
-
const next = { ...current, auditAdvice: advice, adviceAcked: false };
|
|
490
|
-
this.#project("actions", next, advice.by, "action.audit_advice", wakeId, advice.at);
|
|
491
|
-
return next;
|
|
492
|
-
}
|
|
493
|
-
ackAuditAdvice(id, agent) {
|
|
494
|
-
const current = this.#requiredAction(id);
|
|
495
|
-
if (current.agent !== agent)
|
|
496
|
-
throw new Error("only the action owner may acknowledge audit advice");
|
|
497
|
-
if (!current.auditAdvice)
|
|
498
|
-
throw new Error("action has no audit advice");
|
|
499
|
-
const next = { ...current, adviceAcked: true };
|
|
500
|
-
this.#project("actions", next, agent, "action.audit_advice_acked");
|
|
501
|
-
return next;
|
|
502
|
-
}
|
|
503
|
-
putMail(mail, actor, wakeId) {
|
|
504
|
-
if (mail.from !== actor && actor !== "supervisor")
|
|
505
|
-
throw new Error("mail sender does not match actor");
|
|
506
|
-
return this.#project("mailbox", mail, actor, "mail.put", wakeId);
|
|
507
|
-
}
|
|
600
|
+
repairTurnAttempt(id, reason, now, actor) { const current = this.#requiredTurn(id); if (current.status !== "in_progress")
|
|
601
|
+
throw new Error("only an active Turn attempt may be repaired"); return this.#transaction(() => this.#repairOpenTurnItems(id, reason, now, actor)); }
|
|
602
|
+
finishTurn(id, status, error, now, actor, mailIds = []) { const current = this.#requiredTurn(id); if (current.status !== "in_progress")
|
|
603
|
+
throw new Error("only an active Turn may finish"); if (status === "completed" && current.bindingKind === "goal")
|
|
604
|
+
throw new Error("Goal-bound Turn completion requires commitHandoff"); if (status === "completed" && error !== null || status !== "completed" && error === null)
|
|
605
|
+
throw new Error("Turn terminal error does not match status"); return this.#transaction(() => { if (status !== "completed")
|
|
606
|
+
this.#repairOpenTurnItems(id, String(error.message ?? "Turn interrupted"), now, actor);
|
|
607
|
+
else if (this.turnItems(id).some((item) => item.status === "in_progress"))
|
|
608
|
+
throw new Error("successful Turn cannot retain in-progress Items"); const streamId = `turn:${id}`; const thread = this.thread(current.threadId); const delivered = new Set(mailIds); if (status !== "completed" && mailIds.length)
|
|
609
|
+
throw new Error("failed Turn cannot acknowledge Mail"); if (status === "completed" && thread)
|
|
610
|
+
this.#recordMailReads(this.unreadMail(thread.agent).filter((candidate) => delivered.has(candidate.id)), now, streamId); this.#insertEvent({ streamId, ts: now, actor, type: status === "completed" ? "transcript.completed" : "transcript.interrupted", data: status === "completed" ? {} : { reason: String(error.message ?? "Turn interrupted") } }); const next = { ...current, status, error, endedAt: now, leaseUntil: null, leaseToken: null, runnerPid: null }; this.#recordProjection("turns", next, actor, `turn.${status}`, undefined, now, undefined, streamId); return next; }); }
|
|
611
|
+
putMail(mail, actor, wakeId) { return this.putMails([mail], actor, wakeId)[0]; }
|
|
612
|
+
putMails(mails, actor, wakeId) { const ids = new Set(); const prior = new Map(); for (const mail of mails) {
|
|
613
|
+
this.#assertMailRequest(mail, actor);
|
|
614
|
+
if (ids.has(mail.id))
|
|
615
|
+
throw new Error("mail batch contains a duplicate id");
|
|
616
|
+
ids.add(mail.id);
|
|
617
|
+
const row = this.db.prepare("SELECT * FROM mailbox WHERE id=?").get(mail.id);
|
|
618
|
+
if (!row)
|
|
619
|
+
continue;
|
|
620
|
+
const current = mapMail(row);
|
|
621
|
+
if (current.routeKind !== mail.routeKind || current.to !== mail.to || current.from !== mail.from || current.specialistRole !== mail.specialistRole || current.level !== mail.level || current.goalId !== mail.goalId || !isDeepStrictEqual(current.body, mail.body))
|
|
622
|
+
throw new Error("mail id was reused with different content");
|
|
623
|
+
const event = this.db.prepare("SELECT * FROM events WHERE type='mail.put' AND json_extract(data,'$.snapshot.id')=? ORDER BY seq LIMIT 1").get(mail.id);
|
|
624
|
+
if (!event)
|
|
625
|
+
throw new Error("mail projection has no source event");
|
|
626
|
+
prior.set(mail.id, mapEvent(event));
|
|
627
|
+
} return this.#transaction(() => mails.map((mail) => prior.get(mail.id) ?? this.#recordProjection("mailbox", mail, actor, "mail.put", wakeId))); }
|
|
508
628
|
commitHandoff(commit) {
|
|
509
629
|
return this.#transaction(() => {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
630
|
+
const turn = this.#requiredTurn(commit.turnId);
|
|
631
|
+
const thread = this.thread(turn.threadId);
|
|
632
|
+
if (turn.status !== "in_progress" || thread?.agent !== commit.agent)
|
|
633
|
+
throw new Error("handoff does not match an active Turn");
|
|
634
|
+
if (turn.bindingKind !== "goal")
|
|
635
|
+
throw new Error("only a Goal-bound Turn may commit Handoff");
|
|
636
|
+
const goal = this.#getGoal(turn.goalId);
|
|
637
|
+
if (!goal || goal.owner !== commit.agent || goal.phase !== "active" || goal.revision !== turn.goalRevision)
|
|
638
|
+
throw new Error("Handoff Turn Goal binding is stale");
|
|
639
|
+
const record = this.workRecord(goal.id);
|
|
640
|
+
if (!record || record.updatedInTurn !== turn.id || record.goalRevision !== goal.revision || record.recordRevision < 1)
|
|
641
|
+
throw new Error("Goal-bound Turn must update its Work Record before Handoff");
|
|
642
|
+
assertHandoff(commit.output.handoff);
|
|
643
|
+
if (commit.output.handoff.goalId !== goal.id || commit.output.handoff.goalRevision !== goal.revision || commit.output.handoff.recordRevision !== record.recordRevision)
|
|
644
|
+
throw new Error("Goal Handoff does not match the current Goal and Work Record");
|
|
645
|
+
this.#assertEvidenceExists(commit.output.handoff.evidence);
|
|
646
|
+
const responseItem = this.turnItems(turn.id).find((item) => item.id === commit.responseItemId);
|
|
647
|
+
if (!responseItem || responseItem.type !== "assistant_message" || responseItem.status !== "completed" || typeof responseItem.data.text !== "string" || !responseItem.data.text.trim())
|
|
648
|
+
throw new Error("Handoff response does not match a readable Turn Item");
|
|
649
|
+
const ordinal = this.turnItems(turn.id).length + 1;
|
|
650
|
+
if (commit.item.turnId !== turn.id || commit.item.type !== "handoff" || commit.item.status !== "completed" || commit.item.completedAt !== commit.ts || commit.item.createdAt !== commit.ts || commit.item.ordinal !== ordinal || !isDeepStrictEqual(commit.item.data, commit.output.handoff))
|
|
651
|
+
throw new Error("handoff Item does not match active TurnOutput");
|
|
652
|
+
if (commit.sourceWakeId) {
|
|
653
|
+
const wake = this.#requiredWake(commit.sourceWakeId);
|
|
654
|
+
if (wake.status !== "consumed" || wake.turnId !== turn.id)
|
|
655
|
+
throw new Error("handoff source Wake does not match Turn");
|
|
526
656
|
}
|
|
657
|
+
const streamId = `turn:${commit.turnId}`;
|
|
658
|
+
this.#insertEvent({ streamId, ts: commit.ts, actor: commit.agent, type: "response.committed", data: { messageItemId: responseItem.id, text: responseItem.data.text } });
|
|
659
|
+
this.#admitTurnItem(commit.item, commit.agent, streamId);
|
|
660
|
+
const event = this.#insertEvent({ streamId, ts: commit.ts, actor: commit.agent, type: "handoff.recorded", data: commit.output.handoff });
|
|
661
|
+
const delivered = new Set(commit.mailIds);
|
|
662
|
+
this.#recordMailReads(this.unreadMail(commit.agent).filter((candidate) => delivered.has(candidate.id)), commit.ts, streamId);
|
|
663
|
+
if (this.turnItems(turn.id).some((item) => item.status === "in_progress"))
|
|
664
|
+
throw new Error("successful Turn cannot retain in-progress Items");
|
|
665
|
+
this.#insertEvent({ streamId, ts: commit.ts, actor: "supervisor", type: "transcript.completed", data: {} });
|
|
666
|
+
this.#recordProjection("turns", { ...turn, status: "completed", error: null, endedAt: commit.ts, leaseUntil: null, leaseToken: null, runnerPid: null }, "supervisor", "turn.completed", undefined, commit.ts, undefined, streamId);
|
|
527
667
|
return event;
|
|
528
668
|
});
|
|
529
669
|
}
|
|
530
|
-
|
|
531
|
-
return this.#transaction(() => {
|
|
532
|
-
const wake = this.#requiredWake(commit.wakeId);
|
|
533
|
-
if (wake.status !== "running" || wake.agent !== commit.agent)
|
|
534
|
-
throw new Error("interaction does not match a running wake");
|
|
535
|
-
const mailRow = this.db.prepare("SELECT * FROM mailbox WHERE id=?").get(commit.mailId);
|
|
536
|
-
if (!mailRow)
|
|
537
|
-
throw new Error("interaction mail does not exist");
|
|
538
|
-
const mail = mapMail(mailRow);
|
|
539
|
-
if (mail.to !== commit.agent || mail.readAt !== null)
|
|
540
|
-
throw new Error("interaction mail is not unread for the agent");
|
|
541
|
-
const event = this.#insertEvent({ streamId: wakeStream(commit.wakeId), ts: commit.ts, actor: commit.agent, type: "interaction.completed", data: { mailId: mail.id, response: commit.response } });
|
|
542
|
-
this.#recordProjection("mailbox", { ...mail, readAt: commit.ts }, "supervisor", "mail.read", commit.wakeId, commit.ts);
|
|
543
|
-
return event;
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
dueSchedules(now) { return this.db.prepare("SELECT * FROM schedule WHERE next_wake_at <= ? ORDER BY next_wake_at,id").all(now).map(mapSchedule); }
|
|
670
|
+
dueSchedules(now) { return this.db.prepare("SELECT * FROM schedule WHERE status='pending' AND next_wake_at <= ? ORDER BY next_wake_at,id").all(now).map(mapSchedule); }
|
|
547
671
|
unreadMail(agent) { return this.db.prepare("SELECT * FROM mailbox WHERE to_agent=? AND read_at IS NULL ORDER BY rowid").all(agent).map(mapMail); }
|
|
548
|
-
unackedAuditAdvice(agent) { return this.db.prepare("SELECT * FROM actions WHERE agent=? AND audit_advice IS NOT NULL AND advice_acked=0 ORDER BY rowid").all(agent).map(mapAction); }
|
|
549
672
|
lastEvent(actor, type) { const row = this.db.prepare("SELECT * FROM events WHERE actor=? AND type=? ORDER BY seq DESC LIMIT 1").get(actor, type); return row ? mapEvent(row) : null; }
|
|
673
|
+
lastGoalHandoff(goalId) { const row = this.db.prepare("SELECT * FROM events WHERE type='handoff.recorded' AND json_extract(data,'$.goalId')=? ORDER BY seq DESC LIMIT 1").get(goalId); return row ? mapEvent(row) : null; }
|
|
550
674
|
latestEvent() { const row = this.db.prepare("SELECT * FROM events ORDER BY seq DESC LIMIT 1").get(); return row ? mapEvent(row) : null; }
|
|
551
675
|
eventsForWake(wakeId) { return this.readStream(wakeStream(wakeId)); }
|
|
552
676
|
wake(id) { const row = this.db.prepare("SELECT * FROM wakes WHERE id=?").get(id); return row ? mapWake(row) : null; }
|
|
553
677
|
wakeByTrigger(agent, triggerRef) {
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
return mapWake(direct);
|
|
557
|
-
const coalesced = this.db.prepare(`SELECT w.* FROM events e JOIN wakes w ON e.stream_id='wake:'||w.id
|
|
558
|
-
WHERE e.type='wake.trigger_coalesced' AND json_extract(e.data,'$.triggerRef')=? AND w.agent=? LIMIT 1`).get(triggerRef, agent);
|
|
559
|
-
return coalesced ? mapWake(coalesced) : null;
|
|
678
|
+
const row = this.db.prepare("SELECT w.* FROM wake_triggers t JOIN wakes w ON w.id=t.wake_id WHERE t.agent=? AND t.trigger_ref=?").get(agent, triggerRef);
|
|
679
|
+
return row ? mapWake(row) : null;
|
|
560
680
|
}
|
|
681
|
+
wakeTriggers(wakeId) { return this.db.prepare("SELECT * FROM wake_triggers WHERE wake_id=? ORDER BY rowid").all(wakeId).map(mapWakeTrigger); }
|
|
682
|
+
wakeTriggersForAgent(agent) { return this.db.prepare("SELECT * FROM wake_triggers WHERE agent=? ORDER BY rowid").all(agent).map(mapWakeTrigger); }
|
|
561
683
|
queuedWakeForAgent(agent) { const row = this.db.prepare("SELECT * FROM wakes WHERE agent=? AND status='queued' ORDER BY enqueued_seq LIMIT 1").get(agent); return row ? mapWake(row) : null; }
|
|
562
|
-
action(id) { const row = this.db.prepare("SELECT * FROM actions WHERE id=?").get(id); return row ? mapAction(row) : null; }
|
|
563
684
|
goalsForOwner(owner) { return this.db.prepare("SELECT * FROM goals WHERE owner=? ORDER BY id").all(owner).map(mapGoal); }
|
|
564
685
|
goal(id) { return this.#getGoal(id); }
|
|
565
686
|
workRecord(goalId) { const row = this.db.prepare("SELECT * FROM work_records WHERE goal_id=?").get(goalId); return row ? mapWorkRecord(row) : null; }
|
|
@@ -586,69 +707,116 @@ export class SqliteLedger {
|
|
|
586
707
|
searchEvents(query, limit = 50) {
|
|
587
708
|
return this.db.prepare("SELECT e.* FROM events_fts f JOIN events e ON e.seq=f.rowid WHERE events_fts MATCH ? ORDER BY rank LIMIT ?").all(query, limit).map(mapEvent);
|
|
588
709
|
}
|
|
589
|
-
metricSamples(goalId) {
|
|
590
|
-
return this.db.prepare("SELECT data FROM events WHERE type='metric.sampled' AND json_extract(data,'$.goalId')=? ORDER BY seq").all(goalId).map((row) => JSON.parse(row.data));
|
|
591
|
-
}
|
|
592
710
|
events() { return this.db.prepare("SELECT * FROM events ORDER BY seq").all().map(mapEvent); }
|
|
593
711
|
goals() { return this.db.prepare("SELECT * FROM goals ORDER BY id").all().map(mapGoal); }
|
|
594
712
|
workRecords() { return this.db.prepare("SELECT * FROM work_records ORDER BY goal_id").all().map(mapWorkRecord); }
|
|
595
713
|
schedules() { return this.db.prepare("SELECT * FROM schedule ORDER BY id").all().map(mapSchedule); }
|
|
596
714
|
wakes() { return this.db.prepare("SELECT * FROM wakes ORDER BY enqueued_seq").all().map(mapWake); }
|
|
597
|
-
actions() { return this.db.prepare("SELECT * FROM actions ORDER BY id").all().map(mapAction); }
|
|
598
715
|
mailbox() { return this.db.prepare("SELECT * FROM mailbox ORDER BY rowid").all().map(mapMail); }
|
|
599
716
|
rebuildProjections() {
|
|
600
|
-
const source = this.events();
|
|
717
|
+
const source = this.db.prepare("SELECT * FROM events ORDER BY seq").all();
|
|
601
718
|
this.#transaction(() => {
|
|
602
|
-
this.db.exec("DELETE FROM
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (
|
|
606
|
-
|
|
719
|
+
this.db.exec("DELETE FROM turn_items; DELETE FROM wake_triggers; DELETE FROM wakes; DELETE FROM turns; DELETE FROM threads; DELETE FROM work_records; DELETE FROM mailbox; DELETE FROM schedule; DELETE FROM goals;");
|
|
720
|
+
const goalKeys = new Set();
|
|
721
|
+
for (const row of source) {
|
|
722
|
+
if (row.projection_name === null)
|
|
723
|
+
continue;
|
|
724
|
+
const event = mapEvent(row);
|
|
725
|
+
const projection = String(row.projection_name);
|
|
726
|
+
const expected = projectionForEvent(event.type);
|
|
727
|
+
if (expected !== projection)
|
|
728
|
+
throw new Error(`event ${event.seq} cannot drive ${projection} projection`);
|
|
729
|
+
const data = event.data && typeof event.data === "object" && !Array.isArray(event.data) ? event.data : null;
|
|
730
|
+
if (!data || !("snapshot" in data))
|
|
731
|
+
throw new Error(`projection event ${event.seq} has no snapshot`);
|
|
732
|
+
if (projection === "goals")
|
|
733
|
+
this.#replayGoalChange(event, goalKeys);
|
|
734
|
+
else
|
|
735
|
+
this.#replayProjection(projection, data.snapshot, event);
|
|
607
736
|
}
|
|
608
737
|
});
|
|
609
738
|
}
|
|
610
739
|
#delegationResult(id) {
|
|
611
|
-
const row = this.db.prepare("SELECT data FROM events WHERE type='delegation.created' AND json_extract(data,'$.delegationId')=? ORDER BY seq LIMIT 1").get(id);
|
|
740
|
+
const row = this.db.prepare("SELECT data,actor FROM events WHERE type='delegation.created' AND json_extract(data,'$.delegationId')=? ORDER BY seq LIMIT 1").get(id);
|
|
612
741
|
if (!row)
|
|
613
742
|
return null;
|
|
614
743
|
const data = JSON.parse(row.data);
|
|
615
|
-
const goal = this.#
|
|
616
|
-
const
|
|
617
|
-
const wake = this.
|
|
618
|
-
if (!goal || !
|
|
619
|
-
throw new Error("committed delegation
|
|
620
|
-
return { delegationId: id, goal, mail
|
|
744
|
+
const goal = this.#goalChangeSnapshot(id, row.actor);
|
|
745
|
+
const mail = this.#eventSnapshot("mail.put", data.mailId);
|
|
746
|
+
const wake = this.#eventSnapshot("wake.enqueued", data.wakeId);
|
|
747
|
+
if (!goal || !mail || !wake)
|
|
748
|
+
throw new Error("committed delegation facts are incomplete");
|
|
749
|
+
return { delegationId: id, goal, mail, wake };
|
|
621
750
|
}
|
|
622
751
|
#reassignmentResult(id) {
|
|
623
|
-
const row = this.db.prepare("SELECT data FROM events WHERE type='goal.reassigned' AND json_extract(data,'$.reassignmentId')=? ORDER BY seq LIMIT 1").get(id);
|
|
752
|
+
const row = this.db.prepare("SELECT data,actor FROM events WHERE type='goal.reassigned' AND json_extract(data,'$.reassignmentId')=? ORDER BY seq LIMIT 1").get(id);
|
|
624
753
|
if (!row)
|
|
625
754
|
return null;
|
|
626
755
|
const data = JSON.parse(row.data);
|
|
627
|
-
const goal = this.#
|
|
628
|
-
const mail = data.mailIds.map((mailId) => {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
});
|
|
634
|
-
const wake = this.wake(data.wakeId);
|
|
635
|
-
if (!goal || !wake)
|
|
636
|
-
throw new Error("committed reassignment projections are incomplete");
|
|
756
|
+
const goal = this.#goalChangeSnapshot(id, row.actor);
|
|
757
|
+
const mail = data.mailIds.map((mailId) => { const value = this.#eventSnapshot("mail.put", mailId); if (!value)
|
|
758
|
+
throw new Error("committed reassignment mail is missing"); return value; });
|
|
759
|
+
const wake = data.wakeId ? this.#eventSnapshot("wake.enqueued", data.wakeId) : null;
|
|
760
|
+
if (!goal || data.wakeId && !wake)
|
|
761
|
+
throw new Error("committed reassignment facts are incomplete");
|
|
637
762
|
return { reassignmentId: id, goal, mail, wake };
|
|
638
763
|
}
|
|
639
|
-
#
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
764
|
+
#goalChangeSnapshot(id, actor) { const row = this.db.prepare("SELECT data FROM events WHERE type='goal.changed' AND actor=? AND json_extract(data,'$.idempotencyKey')=? ORDER BY seq LIMIT 1").get(actor, id); return row ? JSON.parse(row.data).snapshot : null; }
|
|
765
|
+
#eventSnapshot(type, id) { const row = this.db.prepare("SELECT data FROM events WHERE type=? AND json_extract(data,'$.snapshot.id')=? ORDER BY seq LIMIT 1").get(type, id); return row ? JSON.parse(row.data).snapshot : null; }
|
|
766
|
+
#recordGoalChange(snapshot, previous, actor, wakeId, change) { const derivedOperation = goalOperation(previous, snapshot); if (change && change.operation !== derivedOperation)
|
|
767
|
+
throw new Error("Goal change operation does not match its state transition"); const operation = change?.operation ?? derivedOperation; if (change && !change.reason.trim())
|
|
768
|
+
throw new Error("Goal change reason is required"); const evidence = change?.evidence ?? []; if (evidence.length)
|
|
769
|
+
this.#assertEvidenceExists(evidence); const expectedAuthority = this.#goalAuthority(snapshot.parentId, actor); if (change?.authority && !isDeepStrictEqual(change.authority, expectedAuthority))
|
|
770
|
+
throw new Error("Goal change authority does not match the authenticated actor"); const authority = change?.authority ?? expectedAuthority; const sourceWakeId = change?.sourceWakeId ?? wakeId; this.#assertGoalProvenance(snapshot, actor, authority, change?.sourceTurnId, sourceWakeId); if (change?.idempotencyKey && this.db.prepare("SELECT 1 FROM events WHERE type='goal.changed' AND actor=? AND json_extract(data,'$.idempotencyKey')=?").get(actor, change.idempotencyKey))
|
|
771
|
+
throw new Error("Goal idempotency key is already committed"); const data = { version: 1, operation, previousRevision: previous?.revision ?? null, snapshot, reason: change?.reason?.trim() || defaultGoalReason(operation), evidence, authority, ...(change?.sourceTurnId ? { sourceTurnId: change.sourceTurnId } : {}), ...(sourceWakeId ? { sourceWakeId } : {}), ...(change?.idempotencyKey ? { idempotencyKey: change.idempotencyKey } : {}) }; const event = this.#insertEvent({ streamId: goalStream(snapshot.id), ts: this.#now(), actor, type: "goal.changed", data: data }, undefined, "goals"); this.#faultInjector?.("after_event_before_projection"); this.#applyProjection("goals", snapshot, event.seq); if (previous && (previous.owner !== snapshot.owner || previous.phase !== snapshot.phase && snapshot.phase !== "active")) {
|
|
772
|
+
this.#cancelWakesForGoal(snapshot.id, this.#now());
|
|
773
|
+
this.#supersedeSchedulesForGoal(snapshot.id, this.#now());
|
|
774
|
+
} return event; }
|
|
775
|
+
#replayGoalChange(event, keys) { if (event.type !== "goal.changed")
|
|
776
|
+
throw new Error(`event ${event.seq} is not an authoritative Goal change`); const data = event.data; if (data.version !== 1)
|
|
777
|
+
throw new Error(`goal.changed ${event.seq} has an unsupported format`); const snapshot = normalizeGoal(data.snapshot); assertGoalSnapshot(snapshot); if (event.streamId !== goalStream(snapshot.id))
|
|
778
|
+
throw new Error(`goal.changed ${event.seq} uses the wrong stream`); const previous = this.#getGoal(snapshot.id); if (data.previousRevision !== (previous?.revision ?? null) || snapshot.revision !== (previous ? previous.revision + 1 : 0) || data.operation !== goalOperation(previous, snapshot))
|
|
779
|
+
throw new Error(`goal.changed ${event.seq} breaks the Goal revision chain`); if (!data.reason.trim())
|
|
780
|
+
throw new Error(`goal.changed ${event.seq} has no reason`); for (const seq of data.evidence)
|
|
781
|
+
if (!Number.isInteger(seq) || seq <= 0 || seq >= event.seq)
|
|
782
|
+
throw new Error(`goal.changed ${event.seq} has invalid evidence`); const expected = this.#goalAuthority(snapshot.parentId, event.actor); if (!isDeepStrictEqual(data.authority, expected))
|
|
783
|
+
throw new Error(`goal.changed ${event.seq} has invalid authority`); this.#assertGoalProvenance(snapshot, event.actor, data.authority, data.sourceTurnId, data.sourceWakeId); if (data.idempotencyKey) {
|
|
784
|
+
const key = `${event.actor}\u0000${data.idempotencyKey}`;
|
|
785
|
+
if (keys.has(key))
|
|
786
|
+
throw new Error(`goal.changed ${event.seq} reuses an idempotency key`);
|
|
787
|
+
keys.add(key);
|
|
788
|
+
} this.#applyProjection("goals", snapshot, event.seq); }
|
|
789
|
+
#assertGoalProvenance(snapshot, actor, authority, sourceTurnId, sourceWakeId) { if (sourceWakeId && !sourceTurnId)
|
|
790
|
+
throw new Error("Goal change source Wake requires its source Turn"); const turn = sourceTurnId ? this.turn(sourceTurnId) : null; if (sourceTurnId && !turn)
|
|
791
|
+
throw new Error("Goal change source Turn does not exist"); if (turn) {
|
|
792
|
+
if (turn.status !== "in_progress")
|
|
793
|
+
throw new Error("Goal change source Turn is not active");
|
|
794
|
+
const thread = this.thread(turn.threadId);
|
|
795
|
+
if (!thread)
|
|
796
|
+
throw new Error("Goal change source Turn has no Thread");
|
|
797
|
+
if (authority.kind === "human") {
|
|
798
|
+
const legalBinding = turn.bindingKind === "human" || turn.bindingKind === "goal" && turn.goalId === snapshot.id;
|
|
799
|
+
if (turn.source !== "human" || thread.agent !== "ceo" || !legalBinding)
|
|
800
|
+
throw new Error("Goal change source Turn does not carry Human authority");
|
|
801
|
+
}
|
|
802
|
+
else if (authority.kind === "parent_goal") {
|
|
803
|
+
if (thread.agent !== actor || turn.bindingKind !== "goal" || turn.goalId !== authority.goalId || turn.goalRevision !== authority.goalRevision)
|
|
804
|
+
throw new Error("Goal change source Turn is not bound to the authorizing parent Goal");
|
|
805
|
+
}
|
|
806
|
+
else if (thread.agent !== actor)
|
|
807
|
+
throw new Error("Goal change source Turn actor does not match");
|
|
808
|
+
} if (sourceWakeId) {
|
|
809
|
+
const wake = this.wake(sourceWakeId);
|
|
810
|
+
if (!wake)
|
|
811
|
+
throw new Error("Goal change source Wake does not exist");
|
|
812
|
+
if (wake.status !== "consumed" || sourceTurnId && wake.turnId !== sourceTurnId)
|
|
813
|
+
throw new Error("Goal change source Wake does not match its Turn");
|
|
814
|
+
if (authority.kind === "parent_goal" && (wake.bindingKind !== "goal" || wake.agent !== actor || wake.goalId !== authority.goalId))
|
|
815
|
+
throw new Error("Goal change source Wake is not bound to the authorizing parent Goal");
|
|
816
|
+
} }
|
|
817
|
+
#goalAuthority(parentId, actor) { if (parentId === null)
|
|
818
|
+
return actor === "human" ? { kind: "human" } : { kind: "system", reason: `root mutation by ${actor}` }; const parent = this.#getGoal(parentId); if (!parent)
|
|
819
|
+
throw new Error("parent goal does not exist"); return { kind: "parent_goal", goalId: parent.id, goalRevision: parent.revision }; }
|
|
652
820
|
#createWorkRecord(goal, actor, turnId, wakeId, seed) {
|
|
653
821
|
return this.#recordWorkRecord({
|
|
654
822
|
goalId: goal.id,
|
|
@@ -657,7 +825,7 @@ export class SqliteLedger {
|
|
|
657
825
|
content: seed?.content ?? initialWorkRecord(),
|
|
658
826
|
updatedBy: actor,
|
|
659
827
|
updatedInTurn: turnId,
|
|
660
|
-
|
|
828
|
+
sourceWakeId: wakeId ?? null,
|
|
661
829
|
updatedAt: this.#now(),
|
|
662
830
|
reason: "Goal created",
|
|
663
831
|
evidence: seed?.evidence ?? [],
|
|
@@ -667,7 +835,7 @@ export class SqliteLedger {
|
|
|
667
835
|
#recordWorkRecord(snapshot, type) {
|
|
668
836
|
const seq = this.#nextEventSeq();
|
|
669
837
|
const committed = { ...snapshot, lastEventSeq: seq };
|
|
670
|
-
this.#recordProjection("work_records", committed, committed.updatedBy, type, committed.
|
|
838
|
+
this.#recordProjection("work_records", committed, committed.updatedBy, type, committed.sourceWakeId ?? undefined, committed.updatedAt, seq, workRecordStream(committed.goalId));
|
|
671
839
|
return committed;
|
|
672
840
|
}
|
|
673
841
|
#assertWorkRecordAuthority(goal, actor) {
|
|
@@ -689,6 +857,9 @@ export class SqliteLedger {
|
|
|
689
857
|
if (parent.owner !== actor)
|
|
690
858
|
throw new Error("only the parent goal owner may modify a child goal");
|
|
691
859
|
}
|
|
860
|
+
#assertOwnerSeparation(goalId, parentId, owner) { if (parentId && this.#getGoal(parentId)?.owner === owner)
|
|
861
|
+
throw new Error("parent and child Goals require distinct owners"); if (this.goals().some((goal) => goal.parentId === goalId && goal.owner === owner))
|
|
862
|
+
throw new Error("parent and child Goals require distinct owners"); }
|
|
692
863
|
#hasNonCompleteDescendant(goalId) {
|
|
693
864
|
return Boolean(this.db.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
694
865
|
SELECT id FROM goals WHERE parent_id=?
|
|
@@ -696,36 +867,270 @@ export class SqliteLedger {
|
|
|
696
867
|
) SELECT 1 FROM goals WHERE id IN (SELECT id FROM descendants) AND phase<>'complete' LIMIT 1`).get(goalId));
|
|
697
868
|
}
|
|
698
869
|
#assertEvidenceExists(evidence) {
|
|
870
|
+
if (evidence.length === 0)
|
|
871
|
+
throw new Error("evidence is required");
|
|
699
872
|
const exists = this.db.prepare("SELECT 1 FROM events WHERE seq=?");
|
|
700
873
|
for (const seq of evidence)
|
|
701
874
|
if (!Number.isInteger(seq) || seq <= 0 || !exists.get(seq))
|
|
702
875
|
throw new Error(`evidence event does not exist: ${seq}`);
|
|
703
876
|
}
|
|
704
|
-
#
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
877
|
+
#assertTurnLease(turn, leaseToken) { if (turn.leaseToken !== leaseToken)
|
|
878
|
+
throw new Error("stale Turn lease token"); }
|
|
879
|
+
#assertRawEvent(input) { if (input.type === "goal.changed")
|
|
880
|
+
throw new Error("projection-driving events must use a typed Ledger domain API"); }
|
|
708
881
|
#getGoal(id) { const row = this.db.prepare("SELECT * FROM goals WHERE id=?").get(id); return row ? mapGoal(row) : null; }
|
|
882
|
+
#requiredTurn(id) { const value = this.turn(id); if (!value)
|
|
883
|
+
throw new Error(`turn not found: ${id}`); return value; }
|
|
709
884
|
#requiredWake(id) { const value = this.wake(id); if (!value)
|
|
710
885
|
throw new Error(`wake not found: ${id}`); return value; }
|
|
711
|
-
#
|
|
712
|
-
|
|
886
|
+
#admitWake(input, actor, allowExisting) {
|
|
887
|
+
if (actor !== "supervisor")
|
|
888
|
+
throw new Error("only supervisor may enqueue wakes");
|
|
889
|
+
this.#assertExecutionBinding(input, true);
|
|
890
|
+
if (!input.id.trim() || !input.agent.trim() || !input.triggerRef.trim())
|
|
891
|
+
throw new Error("Wake id, agent, and trigger are required");
|
|
892
|
+
if (input.status !== "queued" || input.attempt !== 0 || input.claimedAt || input.consumedAt || input.turnId)
|
|
893
|
+
throw new Error("new wake must be pristine and queued");
|
|
894
|
+
const sameId = this.wake(input.id);
|
|
895
|
+
if (sameId) {
|
|
896
|
+
const sameIdentity = sameId.bindingKind === input.bindingKind && sameId.agent === input.agent && sameId.specialistRole === input.specialistRole && sameId.triggerRef === input.triggerRef && sameId.goalId === input.goalId;
|
|
897
|
+
if (!allowExisting || !sameIdentity)
|
|
898
|
+
throw new Error("wake id was reused with a different identity");
|
|
899
|
+
const row = this.db.prepare("SELECT * FROM events WHERE type='wake.enqueued' AND json_extract(data,'$.snapshot.id')=? ORDER BY seq LIMIT 1").get(sameId.id);
|
|
900
|
+
if (!row)
|
|
901
|
+
throw new Error("wake projection has no source event");
|
|
902
|
+
return { event: mapEvent(row), created: false, wake: sameId };
|
|
903
|
+
}
|
|
904
|
+
const duplicate = this.wakeByTrigger(input.agent, input.triggerRef);
|
|
905
|
+
if (duplicate) {
|
|
906
|
+
const sameBinding = duplicate.bindingKind === input.bindingKind && duplicate.specialistRole === input.specialistRole && duplicate.goalId === input.goalId;
|
|
907
|
+
if (!allowExisting || !sameBinding)
|
|
908
|
+
throw new Error("Wake trigger was reused with a different execution binding");
|
|
909
|
+
const row = this.db.prepare("SELECT * FROM events WHERE type='wake.enqueued' AND json_extract(data,'$.snapshot.id')=? ORDER BY seq LIMIT 1").get(duplicate.id);
|
|
910
|
+
if (!row)
|
|
911
|
+
throw new Error("wake projection has no source event");
|
|
912
|
+
return { event: mapEvent(row), created: false, wake: duplicate };
|
|
913
|
+
}
|
|
914
|
+
if (input.bindingKind === "goal") {
|
|
915
|
+
const goal = this.#getGoal(input.goalId);
|
|
916
|
+
if (!goal || goal.owner !== input.agent || goal.phase !== "active")
|
|
917
|
+
throw new Error("Wake Goal binding is inactive or owned by another Agent");
|
|
918
|
+
}
|
|
919
|
+
const enqueuedSeq = this.#nextEventSeq();
|
|
920
|
+
const wake = { ...input, enqueuedSeq };
|
|
921
|
+
const event = this.#recordProjection("wakes", wake, actor, "wake.enqueued", wake.id, undefined, enqueuedSeq);
|
|
922
|
+
this.#admitWakeTrigger(wake.id, wake.triggerRef, actor, event.ts);
|
|
923
|
+
return { event, created: true, wake };
|
|
924
|
+
}
|
|
925
|
+
#admitWakeTrigger(wakeId, triggerRef, actor, at) { if (actor !== "supervisor")
|
|
926
|
+
throw new Error("only supervisor may add Wake triggers"); if (!triggerRef.trim())
|
|
927
|
+
throw new Error("Wake trigger is required"); const wake = this.#requiredWake(wakeId); if (wake.status !== "queued" && wake.status !== "claimed")
|
|
928
|
+
throw new Error("triggers may only be added before a Wake starts its Turn"); const row = this.db.prepare("SELECT * FROM wake_triggers WHERE agent=? AND trigger_ref=?").get(wake.agent, triggerRef); if (row) {
|
|
929
|
+
const current = mapWakeTrigger(row);
|
|
930
|
+
if (current.wakeId !== wake.id)
|
|
931
|
+
throw new Error("Wake trigger is already owned by another Wake");
|
|
932
|
+
return current;
|
|
933
|
+
} const mailId = mailIdFromTrigger(triggerRef); const mail = mailId ? this.mailbox().find((candidate) => candidate.id === mailId) : null; if (mail) {
|
|
934
|
+
const matches = mail.routeKind === "goal" ? wake.bindingKind === "goal" && wake.goalId === mail.goalId : mail.routeKind === "human_inbox" && mail.from === "human" ? wake.bindingKind === "human" : mail.routeKind === "specialist_inbox" ? wake.bindingKind === "specialist" && wake.specialistRole === mail.specialistRole : false;
|
|
935
|
+
if (!matches)
|
|
936
|
+
throw new Error("Mail trigger does not match the Wake execution binding");
|
|
937
|
+
} const source = wake.bindingKind === "human" ? "human" : wake.bindingKind === "goal" ? "goal" : "system"; const trigger = { wakeId: wake.id, agent: wake.agent, triggerRef, source, status: "pending", addedAt: at, resolvedAt: null }; this.#recordProjection("wake_triggers", trigger, "supervisor", "wake_trigger.added", wake.id, at); return trigger; }
|
|
938
|
+
#resolveWakeTriggers(wakeId, now) { return this.wakeTriggers(wakeId).filter((trigger) => trigger.status === "pending").map((trigger) => { const next = { ...trigger, status: "resolved", resolvedAt: now }; this.#recordProjection("wake_triggers", next, "supervisor", "wake_trigger.resolved", wakeId, now); return next; }); }
|
|
939
|
+
#cancelWakeProjection(wake, now) { if (wake.status !== "queued" && wake.status !== "claimed")
|
|
940
|
+
throw new Error("only a pending Wake may be cancelled"); this.#resolveWakeTriggers(wake.id, now); const next = { ...wake, status: "cancelled", consumedAt: now }; this.#recordProjection("wakes", next, "supervisor", "wake.cancelled", wake.id, now); return next; }
|
|
941
|
+
#requiredSchedule(id) { const row = this.db.prepare("SELECT * FROM schedule WHERE id=?").get(id); if (!row)
|
|
942
|
+
throw new Error(`schedule not found: ${id}`); return mapSchedule(row); }
|
|
943
|
+
#admitTurnItem(item, actor, streamId = `turn:${item.turnId}`) { if (!item.id.trim() || !item.turnId.trim())
|
|
944
|
+
throw new Error("turn item id and turn are required"); const turn = this.turn(item.turnId); if (!turn)
|
|
945
|
+
throw new Error("turn item turn does not exist"); if (turn.status !== "in_progress")
|
|
946
|
+
throw new Error("terminal turn cannot accept items"); if (item.status === "in_progress" && item.completedAt !== null || item.status !== "in_progress" && item.completedAt === null)
|
|
947
|
+
throw new Error("turn item terminal time does not match status"); const current = this.db.prepare("SELECT * FROM turn_items WHERE id=?").get(item.id); if (current && (String(current.turn_id) !== item.turnId || String(current.type) !== item.type || String(current.status) !== "in_progress"))
|
|
948
|
+
throw new Error("turn item identity or terminal state cannot change"); return this.#recordProjection("turn_items", item, actor, current ? `item.${item.type}.${item.status}` : `item.${item.type}.started`, undefined, item.completedAt ?? item.createdAt, undefined, streamId); }
|
|
949
|
+
#assertMailRequest(mail, actor) { if (!mail.id.trim() || !mail.to.trim() || !mail.from.trim())
|
|
950
|
+
throw new Error("mail id, sender, and recipient are required"); if (mail.from !== actor && actor !== "supervisor")
|
|
951
|
+
throw new Error("mail sender does not match actor"); if (mail.readAt !== null)
|
|
952
|
+
throw new Error("new mail must be unread"); if (mail.routeKind === "goal") {
|
|
953
|
+
if (mail.specialistRole !== null)
|
|
954
|
+
throw new Error("Goal Mail cannot carry a specialist role");
|
|
955
|
+
const goal = this.#getGoal(mail.goalId);
|
|
956
|
+
if (!goal || goal.owner !== mail.to)
|
|
957
|
+
throw new Error("Mail Goal route is not owned by its recipient");
|
|
958
|
+
}
|
|
959
|
+
else if (mail.routeKind === "human_inbox") {
|
|
960
|
+
if (mail.to !== "ceo" || mail.goalId !== null || mail.specialistRole !== null || !["human", "verifier", "audit", "supervisor"].includes(mail.from))
|
|
961
|
+
throw new Error("Human inbox Mail has an invalid sender or recipient");
|
|
962
|
+
}
|
|
963
|
+
else if (mail.routeKind === "human_request") {
|
|
964
|
+
if (mail.to !== "human" || mail.from !== "ceo" || mail.goalId !== null || mail.specialistRole !== null)
|
|
965
|
+
throw new Error("Human request Mail has an invalid sender or recipient");
|
|
966
|
+
}
|
|
967
|
+
else if (mail.goalId !== null || mail.specialistRole === null)
|
|
968
|
+
throw new Error("Specialist inbox Mail has an invalid route"); }
|
|
969
|
+
#assertNewMail(mail, actor) { this.#assertMailRequest(mail, actor); if (this.db.prepare("SELECT 1 FROM mailbox WHERE id=?").get(mail.id))
|
|
970
|
+
throw new Error("mail id is already in use"); }
|
|
971
|
+
#recordMailReads(mails, now, streamId) { for (const mail of mails)
|
|
972
|
+
this.#recordProjection("mailbox", { ...mail, readAt: now }, "supervisor", "mail.read", undefined, now, undefined, streamId); const recipients = new Set(mails.map((mail) => mail.to)); const ids = new Set(mails.map((mail) => mail.id)); for (const wake of this.wakes().filter((candidate) => recipients.has(candidate.agent) && (candidate.status === "queued" || candidate.status === "claimed"))) {
|
|
973
|
+
for (const trigger of this.wakeTriggers(wake.id).filter((candidate) => candidate.status === "pending")) {
|
|
974
|
+
const mailId = mailIdFromTrigger(trigger.triggerRef);
|
|
975
|
+
if (mailId && ids.has(mailId)) {
|
|
976
|
+
const next = { ...trigger, status: "resolved", resolvedAt: now };
|
|
977
|
+
this.#recordProjection("wake_triggers", next, "supervisor", "wake_trigger.resolved", wake.id, now);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (this.wakeTriggers(wake.id).every((trigger) => trigger.status === "resolved"))
|
|
981
|
+
this.#cancelWakeProjection(wake, now);
|
|
982
|
+
} }
|
|
983
|
+
#normalizeSchedule(value, actor, binding) { this.#assertExecutionBinding(value, false); if (actor !== "supervisor" && actor !== value.agent)
|
|
984
|
+
throw new Error("schedule may only be set by its agent or supervisor"); if (value.setBy !== actor)
|
|
985
|
+
throw new Error("schedule setBy does not match actor"); const parsed = Date.parse(value.nextWakeAt); if (!value.id.trim() || !value.agent.trim() || !value.reason.trim() || !Number.isFinite(parsed))
|
|
986
|
+
throw new Error("schedule requires an id, agent, valid time, and reason"); const normalized = { ...value, nextWakeAt: new Date(parsed).toISOString() }; if (normalized.status !== "pending" || normalized.resolvedAt !== null)
|
|
987
|
+
throw new Error("new schedule must be pending"); if (binding && normalized.goalId !== binding.goalId)
|
|
988
|
+
throw new Error("handoff schedule does not match the Turn Goal binding"); if (normalized.bindingKind === "goal") {
|
|
989
|
+
const goal = this.#getGoal(normalized.goalId);
|
|
990
|
+
if (!goal || goal.owner !== normalized.agent || goal.phase !== "active")
|
|
991
|
+
throw new Error("schedule Goal target is inactive or owned by another Agent");
|
|
992
|
+
} return normalized; }
|
|
993
|
+
#assertExecutionBinding(value, allowHuman) { if (value.bindingKind === "human") {
|
|
994
|
+
if (!allowHuman || value.agent !== "ceo" || value.goalId !== null || value.specialistRole !== null)
|
|
995
|
+
throw new Error("invalid Human execution binding");
|
|
996
|
+
return;
|
|
997
|
+
} if (value.bindingKind === "goal") {
|
|
998
|
+
if (!value.goalId || value.specialistRole !== null)
|
|
999
|
+
throw new Error("invalid Goal execution binding");
|
|
1000
|
+
return;
|
|
1001
|
+
} if (value.bindingKind !== "specialist" || value.goalId !== null || !value.specialistRole || !["verifier", "audit"].includes(value.specialistRole))
|
|
1002
|
+
throw new Error("invalid Specialist execution binding"); }
|
|
1003
|
+
#assertTurnBinding(value) { if (value.bindingKind === "human") {
|
|
1004
|
+
if (value.source !== "human" || value.goalId !== null || value.goalRevision !== null || value.specialistRole !== null)
|
|
1005
|
+
throw new Error("invalid Human Turn binding");
|
|
1006
|
+
return;
|
|
1007
|
+
} if (value.bindingKind === "goal") {
|
|
1008
|
+
if (!["human", "goal"].includes(value.source) || !value.goalId || value.goalRevision === null || value.specialistRole !== null)
|
|
1009
|
+
throw new Error("invalid Goal Turn binding");
|
|
1010
|
+
return;
|
|
1011
|
+
} if (value.bindingKind !== "specialist" || value.source !== "system" || value.goalId !== null || value.goalRevision !== null || !value.specialistRole || !["verifier", "audit"].includes(value.specialistRole))
|
|
1012
|
+
throw new Error("invalid Specialist Turn binding"); }
|
|
1013
|
+
#replayProjection(projection, snapshot, event) { if (projection === "turns")
|
|
1014
|
+
this.#assertReplayTurn(snapshot, event);
|
|
1015
|
+
else if (projection === "wakes")
|
|
1016
|
+
this.#assertReplayWake(snapshot, event); this.#applyProjection(projection, snapshot, event.seq); }
|
|
1017
|
+
#assertReplayTurn(turn, event) { this.#assertTurnBinding(turn); const thread = this.thread(turn.threadId); if (!thread)
|
|
1018
|
+
throw new Error(`turn event ${event.seq} has no Thread`); const current = this.turn(turn.id); if (!current) {
|
|
1019
|
+
if (event.type !== "turn.started" || turn.status !== "in_progress" || turn.attempt !== 1)
|
|
1020
|
+
throw new Error(`turn event ${event.seq} has an invalid initial state`);
|
|
1021
|
+
if (event.actor === "human") {
|
|
1022
|
+
if (turn.bindingKind !== "human" || thread.agent !== "ceo")
|
|
1023
|
+
throw new Error(`turn event ${event.seq} has invalid Human ownership`);
|
|
1024
|
+
}
|
|
1025
|
+
else {
|
|
1026
|
+
if (event.actor !== "supervisor" || !this.wakes().some((wake) => wake.status === "claimed" && wake.agent === thread.agent && sameWakeTurnBinding(wake, turn)))
|
|
1027
|
+
throw new Error(`turn event ${event.seq} bypasses Wake admission`);
|
|
1028
|
+
this.#assertCurrentAutomaticTurn(turn, thread.agent, event.seq);
|
|
1029
|
+
}
|
|
1030
|
+
return;
|
|
1031
|
+
} if (current.threadId !== turn.threadId || current.source !== turn.source || current.startedAt !== turn.startedAt || current.runnerProfileId !== turn.runnerProfileId)
|
|
1032
|
+
throw new Error(`turn event ${event.seq} changes Turn identity`); if (current.status !== "in_progress")
|
|
1033
|
+
throw new Error(`turn event ${event.seq} changes a terminal Turn`); const bindsGoal = event.actor === "supervisor" && current.bindingKind === "human" && turn.bindingKind === "goal" && current.source === "human"; if (current.bindingKind !== turn.bindingKind && !bindsGoal || current.bindingKind === turn.bindingKind && (current.goalId !== turn.goalId || current.goalRevision !== turn.goalRevision || current.specialistRole !== turn.specialistRole))
|
|
1034
|
+
throw new Error(`turn event ${event.seq} changes execution binding`); if (bindsGoal) {
|
|
1035
|
+
const goal = this.#getGoal(turn.goalId);
|
|
1036
|
+
if (thread.agent !== "ceo" || !goal || goal.parentId !== null || goal.owner !== "ceo" || goal.phase !== "active" || goal.revision !== turn.goalRevision)
|
|
1037
|
+
throw new Error(`turn event ${event.seq} has an invalid Human-to-Goal binding`);
|
|
1038
|
+
} if (turn.status === "in_progress") {
|
|
1039
|
+
if (event.type !== "turn.in_progress" || turn.attempt !== current.attempt && turn.attempt !== current.attempt + 1)
|
|
1040
|
+
throw new Error(`turn event ${event.seq} has an invalid retry transition`);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
if (event.type !== `turn.${turn.status}` || turn.attempt !== current.attempt)
|
|
1044
|
+
throw new Error(`turn event ${event.seq} has an invalid terminal transition`);
|
|
1045
|
+
if (turn.status === "completed" && turn.bindingKind === "goal")
|
|
1046
|
+
this.#assertReplayGoalCompletion(turn, event);
|
|
1047
|
+
} }
|
|
1048
|
+
#assertReplayGoalCompletion(turn, event) { const items = this.turnItems(turn.id); const responses = items.filter((item) => item.type === "assistant_message" && item.status === "completed" && typeof item.data.text === "string" && Boolean(item.data.text.trim())); const handoffItem = items.findLast((item) => item.type === "handoff" && item.status === "completed"); const responseRow = this.db.prepare("SELECT data FROM events WHERE stream_id=? AND type='response.committed' AND seq<? ORDER BY seq DESC LIMIT 1").get(`turn:${turn.id}`, event.seq); const handoffRow = this.db.prepare("SELECT data FROM events WHERE stream_id=? AND type='handoff.recorded' AND seq<? ORDER BY seq DESC LIMIT 1").get(`turn:${turn.id}`, event.seq); if (!responseRow || !handoffRow || !handoffItem)
|
|
1049
|
+
throw new Error(`turn event ${event.seq} completes a Goal without response and Handoff`); const response = JSON.parse(responseRow.data); const handoff = JSON.parse(handoffRow.data); const responseItem = responses.find((item) => item.id === response.messageItemId); if (!responseItem || responseItem.data.text !== response.text || !isDeepStrictEqual(handoffItem.data, handoff))
|
|
1050
|
+
throw new Error(`turn event ${event.seq} has mismatched response or Handoff facts`); assertHandoff(handoff); const record = this.workRecord(turn.goalId); if (handoff.goalId !== turn.goalId || handoff.goalRevision !== turn.goalRevision || record?.recordRevision !== handoff.recordRevision)
|
|
1051
|
+
throw new Error(`turn event ${event.seq} has a Handoff for another Goal or Work Record`); for (const seq of handoff.evidence)
|
|
1052
|
+
if (seq >= event.seq || !this.db.prepare("SELECT 1 FROM events WHERE seq=?").get(seq))
|
|
1053
|
+
throw new Error(`turn event ${event.seq} has invalid Handoff evidence`); }
|
|
1054
|
+
#assertReplayWake(wake, event) { this.#assertExecutionBinding(wake, true); const current = this.wake(wake.id); if (!current) {
|
|
1055
|
+
if (event.type !== "wake.enqueued" || wake.status !== "queued" || wake.attempt !== 0 || wake.claimedAt || wake.consumedAt || wake.turnId)
|
|
1056
|
+
throw new Error(`wake event ${event.seq} has an invalid initial state`);
|
|
1057
|
+
if (wake.bindingKind === "goal") {
|
|
1058
|
+
const goal = this.#getGoal(wake.goalId);
|
|
1059
|
+
if (!goal || goal.phase !== "active" || goal.owner !== wake.agent)
|
|
1060
|
+
throw new Error(`wake event ${event.seq} has an invalid Goal binding`);
|
|
1061
|
+
}
|
|
1062
|
+
return;
|
|
1063
|
+
} if (current.bindingKind !== wake.bindingKind || current.agent !== wake.agent || current.goalId !== wake.goalId || current.specialistRole !== wake.specialistRole || current.triggerRef !== wake.triggerRef || current.enqueuedSeq !== wake.enqueuedSeq)
|
|
1064
|
+
throw new Error(`wake event ${event.seq} changes Wake identity`); if (current.status === "consumed" || current.status === "cancelled")
|
|
1065
|
+
throw new Error(`wake event ${event.seq} changes a terminal Wake`); if (wake.status === "claimed") {
|
|
1066
|
+
if (event.type !== "wake.claimed" || current.status !== "queued" || wake.attempt !== current.attempt + 1 || !wake.claimedAt || wake.consumedAt !== null || wake.turnId !== null)
|
|
1067
|
+
throw new Error(`wake event ${event.seq} has an invalid claim transition`);
|
|
1068
|
+
}
|
|
1069
|
+
else if (wake.status === "queued") {
|
|
1070
|
+
if (event.type !== "wake.released" || current.status !== "claimed" || wake.attempt !== current.attempt || wake.claimedAt !== null || wake.consumedAt !== null || wake.turnId !== null)
|
|
1071
|
+
throw new Error(`wake event ${event.seq} has an invalid release transition`);
|
|
1072
|
+
}
|
|
1073
|
+
else if (wake.status === "cancelled") {
|
|
1074
|
+
if (event.type !== "wake.cancelled" || wake.attempt !== current.attempt || wake.claimedAt !== current.claimedAt || wake.consumedAt === null || wake.turnId !== null)
|
|
1075
|
+
throw new Error(`wake event ${event.seq} has an invalid cancel transition`);
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
if (event.type !== "wake.consumed" || current.status !== "claimed" || wake.attempt !== current.attempt || wake.claimedAt !== current.claimedAt || wake.consumedAt === null || !wake.turnId)
|
|
1079
|
+
throw new Error(`wake event ${event.seq} has an invalid consume transition`);
|
|
1080
|
+
const turn = this.turn(wake.turnId);
|
|
1081
|
+
const thread = turn ? this.thread(turn.threadId) : null;
|
|
1082
|
+
if (!turn || thread?.agent !== wake.agent || !sameWakeTurnBinding(wake, turn))
|
|
1083
|
+
throw new Error(`wake event ${event.seq} does not match its Turn`);
|
|
1084
|
+
} }
|
|
1085
|
+
#assertCurrentAutomaticTurn(turn, agent, seq) { if (turn.bindingKind === "human") {
|
|
1086
|
+
if (agent !== "ceo")
|
|
1087
|
+
throw new Error(seq ? `turn event ${seq} has invalid Human ownership` : "Human Wake may start only the CEO Turn");
|
|
1088
|
+
return;
|
|
1089
|
+
} if (turn.bindingKind === "goal") {
|
|
1090
|
+
const goal = this.#getGoal(turn.goalId);
|
|
1091
|
+
if (!goal || goal.phase !== "active" || goal.owner !== agent || goal.revision !== turn.goalRevision)
|
|
1092
|
+
throw new Error(seq ? `turn event ${seq} has a stale Goal binding` : "Wake Turn must bind the current active Goal revision owned by its Agent");
|
|
1093
|
+
} }
|
|
1094
|
+
#admitSchedule(value, actor, wakeId, ts = this.#now(), streamId, binding, allowExisting = true) { const normalized = this.#normalizeSchedule(value, actor, binding); const existing = this.db.prepare("SELECT * FROM schedule WHERE id=?").get(normalized.id); if (existing) {
|
|
1095
|
+
const current = mapSchedule(existing);
|
|
1096
|
+
if (!allowExisting || !isDeepStrictEqual(current, normalized))
|
|
1097
|
+
throw new Error("schedule id was reused with different content");
|
|
1098
|
+
const row = this.db.prepare("SELECT * FROM events WHERE type='schedule.put' AND json_extract(data,'$.snapshot.id')=? ORDER BY seq LIMIT 1").get(normalized.id);
|
|
1099
|
+
if (!row)
|
|
1100
|
+
throw new Error("schedule projection has no source event");
|
|
1101
|
+
return mapEvent(row);
|
|
1102
|
+
} this.#supersedeScheduleRoute(normalized.agent, normalized.goalId ?? null, normalized.id, ts); return this.#recordProjection("schedule", normalized, actor, "schedule.put", wakeId, ts, undefined, streamId); }
|
|
1103
|
+
#supersedeScheduleRoute(agent, goalId, exceptId, now) { const rows = (goalId === null ? this.db.prepare("SELECT * FROM schedule WHERE agent=? AND goal_id IS NULL AND id<>? AND status='pending' ORDER BY id").all(agent, exceptId) : this.db.prepare("SELECT * FROM schedule WHERE agent=? AND goal_id=? AND id<>? AND status='pending' ORDER BY id").all(agent, goalId, exceptId)); return rows.map((row) => { const current = mapSchedule(row); const next = { ...current, status: "superseded", resolvedAt: now }; this.#recordProjection("schedule", next, "supervisor", "schedule.superseded", undefined, now); return next; }); }
|
|
1104
|
+
#supersedeSchedulesForGoal(goalId, now) { const rows = this.db.prepare("SELECT * FROM schedule WHERE goal_id=? AND status='pending' ORDER BY id").all(goalId); return rows.map((row) => { const current = mapSchedule(row); const next = { ...current, status: "superseded", resolvedAt: now }; this.#recordProjection("schedule", next, "supervisor", "schedule.superseded", undefined, now); return next; }); }
|
|
1105
|
+
#cancelWakesForGoal(goalId, now) { return this.wakes().filter((wake) => wake.goalId === goalId && (wake.status === "queued" || wake.status === "claimed")).map((wake) => this.#cancelWakeProjection(wake, now)); }
|
|
1106
|
+
#repairOpenTurnItems(turnId, reason, now, actor) { const streamId = `turn:${turnId}`; const open = this.turnItems(turnId).filter((item) => item.status === "in_progress"); const repaired = []; let ordinal = this.turnItems(turnId).length + 1; for (const item of open) {
|
|
1107
|
+
const failed = { ...item, status: "failed", completedAt: now };
|
|
1108
|
+
this.#recordProjection("turn_items", failed, actor, `item.${item.type}.failed`, undefined, now, undefined, streamId);
|
|
1109
|
+
repaired.push(failed);
|
|
1110
|
+
if (item.type === "tool_call") {
|
|
1111
|
+
const callId = String(item.data.callId ?? item.id);
|
|
1112
|
+
const result = { id: `repair:${turnId}:${item.ordinal}`, turnId, ordinal: ordinal++, type: "tool_result", status: "completed", data: { callId, result: { outcome: "unknown", synthetic: true, reason } }, createdAt: now, completedAt: now };
|
|
1113
|
+
this.#admitTurnItem(result, actor, streamId);
|
|
1114
|
+
this.#insertEvent({ streamId, ts: now, actor, type: "tool.completed", data: { callId, messageId: `repair:${callId}`, result: { outcome: "unknown", synthetic: true, reason }, isError: true } });
|
|
1115
|
+
repaired.push(result);
|
|
1116
|
+
}
|
|
1117
|
+
} return repaired; }
|
|
713
1118
|
#project(projection, snapshot, actor, type, wakeId, ts, streamId) {
|
|
714
1119
|
return this.#transaction(() => this.#recordProjection(projection, snapshot, actor, type, wakeId, ts, undefined, streamId));
|
|
715
1120
|
}
|
|
716
1121
|
#recordProjection(projection, snapshot, actor, type, wakeId, ts, expectedSeq, streamId) {
|
|
717
|
-
const event = this.#insertEvent({ streamId: streamId ?? (wakeId ? wakeStream(wakeId) : controlStream(projection)), ts: ts ?? this.#now(), actor, type, data: {
|
|
1122
|
+
const event = this.#insertEvent({ streamId: streamId ?? (wakeId ? wakeStream(wakeId) : controlStream(projection)), ts: ts ?? this.#now(), actor, type, data: { snapshot }, ignorable: true }, expectedSeq, projection);
|
|
718
1123
|
this.#faultInjector?.("after_event_before_projection");
|
|
719
1124
|
this.#applyProjection(projection, snapshot, event.seq);
|
|
720
1125
|
return event;
|
|
721
1126
|
}
|
|
722
|
-
#insertEvent(input, expectedSeq) {
|
|
1127
|
+
#insertEvent(input, expectedSeq, projection) {
|
|
723
1128
|
if (!input.streamId.trim() || !input.actor.trim() || !input.type.trim())
|
|
724
1129
|
throw new Error("event streamId, actor and type are required");
|
|
725
1130
|
const streamSeq = Number(this.db.prepare("SELECT COALESCE(MAX(stream_seq),0)+1 AS next FROM events WHERE stream_id=?").get(input.streamId).next);
|
|
726
1131
|
const result = expectedSeq === undefined
|
|
727
|
-
? this.db.prepare("INSERT INTO events(stream_id,stream_seq,ts,actor,type,data,ignorable) VALUES (?,?,?,?,?,json(?)
|
|
728
|
-
: this.db.prepare("INSERT INTO events(seq,stream_id,stream_seq,ts,actor,type,data,ignorable) VALUES (?,?,?,?,?,?,json(?)
|
|
1132
|
+
? this.db.prepare("INSERT INTO events(stream_id,stream_seq,ts,actor,type,data,projection_name,ignorable) VALUES (?,?,?,?,?,json(?),?,?)").run(input.streamId, streamSeq, input.ts, input.actor, input.type, JSON.stringify(input.data), projection ?? null, input.ignorable === true ? 1 : null)
|
|
1133
|
+
: this.db.prepare("INSERT INTO events(seq,stream_id,stream_seq,ts,actor,type,data,projection_name,ignorable) VALUES (?,?,?,?,?,?,json(?),?,?)").run(expectedSeq, input.streamId, streamSeq, input.ts, input.actor, input.type, JSON.stringify(input.data), projection ?? null, input.ignorable === true ? 1 : null);
|
|
729
1134
|
return { ...input, seq: Number(result.lastInsertRowid), streamSeq };
|
|
730
1135
|
}
|
|
731
1136
|
#nextEventSeq() {
|
|
@@ -733,111 +1138,44 @@ export class SqliteLedger {
|
|
|
733
1138
|
return Number(row?.seq ?? 0) + 1;
|
|
734
1139
|
}
|
|
735
1140
|
#applyProjection(projection, raw, sourceSeq) {
|
|
736
|
-
if (projection === "
|
|
1141
|
+
if (projection === "threads") {
|
|
1142
|
+
const v = raw;
|
|
1143
|
+
this.db.prepare(`INSERT INTO threads VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET agent=excluded.agent,parent_thread_id=excluded.parent_thread_id,updated_at=excluded.updated_at`).run(v.id, v.agent, v.parentThreadId, v.createdAt, v.updatedAt);
|
|
1144
|
+
}
|
|
1145
|
+
else if (projection === "turns") {
|
|
1146
|
+
const v = raw;
|
|
1147
|
+
this.db.prepare(`INSERT INTO turns VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET binding_kind=excluded.binding_kind,specialist_role=excluded.specialist_role,goal_id=excluded.goal_id,goal_revision=excluded.goal_revision,status=excluded.status,attempt=excluded.attempt,error=excluded.error,ended_at=excluded.ended_at,lease_until=excluded.lease_until,lease_token=excluded.lease_token,runner_pid=excluded.runner_pid`).run(v.id, v.threadId, v.source, v.bindingKind, v.specialistRole, v.goalId, v.goalRevision, v.status, v.attempt, v.error === null ? null : JSON.stringify(v.error), v.startedAt, v.endedAt, v.leaseUntil, v.leaseToken, v.runnerPid, v.runnerProfileId ?? null);
|
|
1148
|
+
this.db.prepare("UPDATE threads SET updated_at=CASE WHEN updated_at<? THEN ? ELSE updated_at END WHERE id=?").run(v.endedAt ?? v.startedAt, v.endedAt ?? v.startedAt, v.threadId);
|
|
1149
|
+
}
|
|
1150
|
+
else if (projection === "turn_items") {
|
|
1151
|
+
const v = raw;
|
|
1152
|
+
this.db.prepare(`INSERT INTO turn_items VALUES (?,?,?,?,?,json(?),?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data,completed_at=excluded.completed_at`).run(v.id, v.turnId, v.ordinal, v.type, v.status, JSON.stringify(v.data), v.createdAt, v.completedAt);
|
|
1153
|
+
this.db.prepare("UPDATE threads SET updated_at=CASE WHEN updated_at<? THEN ? ELSE updated_at END WHERE id=(SELECT thread_id FROM turns WHERE id=?)").run(v.completedAt ?? v.createdAt, v.completedAt ?? v.createdAt, v.turnId);
|
|
1154
|
+
}
|
|
1155
|
+
else if (projection === "goals") {
|
|
737
1156
|
const v = normalizeGoal(raw);
|
|
738
1157
|
this.db.prepare(`INSERT INTO goals VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id,objective=excluded.objective,observation_method=excluded.observation_method,verification_method=excluded.verification_method,owner=excluded.owner,phase=excluded.phase,revision=excluded.revision`).run(v.id, v.parentId, v.objective, v.observationMethod, v.verificationMethod ?? null, v.owner, v.phase, v.revision);
|
|
739
1158
|
}
|
|
740
1159
|
else if (projection === "work_records") {
|
|
741
1160
|
const v = raw;
|
|
742
|
-
this.db.prepare(`INSERT INTO work_records VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(goal_id) DO UPDATE SET record_revision=excluded.record_revision,goal_revision=excluded.goal_revision,content=excluded.content,updated_by=excluded.updated_by,updated_in_turn=excluded.updated_in_turn,
|
|
1161
|
+
this.db.prepare(`INSERT INTO work_records VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(goal_id) DO UPDATE SET record_revision=excluded.record_revision,goal_revision=excluded.goal_revision,content=excluded.content,updated_by=excluded.updated_by,updated_in_turn=excluded.updated_in_turn,source_wake_id=excluded.source_wake_id,updated_at=excluded.updated_at,reason=excluded.reason,evidence=excluded.evidence,last_event_seq=excluded.last_event_seq`).run(v.goalId, v.recordRevision, v.goalRevision, v.content, v.updatedBy, v.updatedInTurn, v.sourceWakeId, v.updatedAt, v.reason, JSON.stringify(v.evidence), v.lastEventSeq || sourceSeq);
|
|
743
1162
|
}
|
|
744
1163
|
else if (projection === "schedule") {
|
|
745
1164
|
const v = raw;
|
|
746
|
-
this.db.prepare(`INSERT INTO schedule VALUES (
|
|
1165
|
+
this.db.prepare(`INSERT INTO schedule VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET binding_kind=excluded.binding_kind,agent=excluded.agent,specialist_role=excluded.specialist_role,next_wake_at=excluded.next_wake_at,reason=excluded.reason,set_by=excluded.set_by,status=excluded.status,resolved_at=excluded.resolved_at,goal_id=excluded.goal_id`).run(v.id, v.bindingKind, v.agent, v.specialistRole, v.nextWakeAt, v.reason, v.setBy, v.status, v.resolvedAt, v.goalId);
|
|
747
1166
|
}
|
|
748
1167
|
else if (projection === "wakes") {
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
leaseToken: old.leaseToken !== undefined ? old.leaseToken : active ? existing?.leaseToken ?? `legacy:${old.id}:${old.attempt}` : null,
|
|
756
|
-
runnerPid: old.runnerPid !== undefined ? old.runnerPid : existing?.runnerPid ?? null,
|
|
757
|
-
};
|
|
758
|
-
this.db.prepare(`INSERT INTO wakes VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET agent=excluded.agent,trigger_ref=excluded.trigger_ref,status=excluded.status,lease_until=excluded.lease_until,attempt=excluded.attempt,started_at=excluded.started_at,ended_at=excluded.ended_at,enqueued_seq=excluded.enqueued_seq,lease_token=excluded.lease_token,runner_pid=excluded.runner_pid`).run(v.id, v.agent, v.triggerRef, v.status, v.leaseUntil, v.attempt, v.startedAt, v.endedAt, v.enqueuedSeq, v.leaseToken, v.runnerPid);
|
|
1168
|
+
const v = raw;
|
|
1169
|
+
this.db.prepare(`INSERT INTO wakes VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET binding_kind=excluded.binding_kind,agent=excluded.agent,specialist_role=excluded.specialist_role,trigger_ref=excluded.trigger_ref,status=excluded.status,attempt=excluded.attempt,enqueued_seq=excluded.enqueued_seq,claimed_at=excluded.claimed_at,consumed_at=excluded.consumed_at,turn_id=excluded.turn_id,goal_id=excluded.goal_id`).run(v.id, v.bindingKind, v.agent, v.specialistRole, v.triggerRef, v.status, v.attempt, v.enqueuedSeq || sourceSeq, v.claimedAt, v.consumedAt, v.turnId, v.goalId);
|
|
1170
|
+
}
|
|
1171
|
+
else if (projection === "wake_triggers") {
|
|
1172
|
+
const v = raw;
|
|
1173
|
+
this.db.prepare(`INSERT INTO wake_triggers VALUES (?,?,?,?,?,?,?) ON CONFLICT(wake_id,trigger_ref) DO UPDATE SET status=excluded.status,resolved_at=excluded.resolved_at`).run(v.wakeId, v.agent, v.triggerRef, v.source, v.status, v.addedAt, v.resolvedAt);
|
|
759
1174
|
}
|
|
760
1175
|
else if (projection === "mailbox") {
|
|
761
1176
|
const v = raw;
|
|
762
|
-
this.db.prepare(`INSERT INTO mailbox VALUES (
|
|
1177
|
+
this.db.prepare(`INSERT INTO mailbox VALUES (?,?,?,?,?,?,?,json(?),?) ON CONFLICT(id) DO UPDATE SET route_kind=excluded.route_kind,to_agent=excluded.to_agent,from_agent=excluded.from_agent,specialist_role=excluded.specialist_role,level=excluded.level,goal_id=excluded.goal_id,body=excluded.body,read_at=excluded.read_at`).run(v.id, v.routeKind, v.to, v.from, v.specialistRole, v.level, v.goalId, JSON.stringify(v.body), v.readAt);
|
|
763
1178
|
}
|
|
764
|
-
else {
|
|
765
|
-
const old = raw;
|
|
766
|
-
const v = { ...old, connector: old.connector ?? "legacy" };
|
|
767
|
-
this.db.prepare(`INSERT INTO actions VALUES (?,?,?,?,json(?),?,json(?),?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET agent=excluded.agent,kind=excluded.kind,connector=excluded.connector,payload=excluded.payload,reason=excluded.reason,evidence=excluded.evidence,gated=excluded.gated,status=excluded.status,reconciled_at=excluded.reconciled_at,external_ref=excluded.external_ref,audit_advice=excluded.audit_advice,advice_acked=excluded.advice_acked`).run(v.id, v.agent, v.kind, v.connector, JSON.stringify(v.payload), v.reason, JSON.stringify(v.evidence), v.gated ? 1 : 0, v.status, v.reconciledAt, v.externalRef, v.auditAdvice === null ? null : JSON.stringify(v.auditAdvice), v.adviceAcked ? 1 : 0);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
#legacyWorkRecord(goal) {
|
|
771
|
-
const handoff = this.lastEvent(goal.owner, "handoff.recorded");
|
|
772
|
-
const legacy = handoff?.data && typeof handoff.data === "object" && !Array.isArray(handoff.data) && !("goalId" in handoff.data) ? handoff.data : null;
|
|
773
|
-
const notes = this.readStream(`memory:${goal.owner}`).filter((event) => event.type === "memory.appended");
|
|
774
|
-
const list = (items) => items?.length ? items.map((item) => `- ${item}`).join("\n") : "None recorded.";
|
|
775
|
-
const decisions = notes.length ? notes.map((event) => `- ${String(event.data.note ?? "")} [event:${event.seq}]`).join("\n") : "None recorded.";
|
|
776
|
-
return {
|
|
777
|
-
content: `# Current State\n\nMigrated from the previous Goal continuity model.\n\n# Observations\n\n${list(legacy?.observations)}\n\n# Work Completed\n\n${list(legacy?.results)}\n\n# Decisions\n\n${decisions}\n\n# Blockers\n\n${legacy?.blocker ?? "None recorded."}\n\n# Next Steps\n\n${list(legacy?.nextSteps)}\n`,
|
|
778
|
-
evidence: [...(handoff ? [handoff.seq] : []), ...notes.map((event) => event.seq)],
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
#migrateLegacy() {
|
|
782
|
-
this.#transaction(() => {
|
|
783
|
-
const goalColumns = new Set(this.db.prepare("PRAGMA table_info(goals)").all().map((row) => row.name));
|
|
784
|
-
const observationSource = goalColumns.has("observation_method") ? "observation_method" : "NULL";
|
|
785
|
-
const verificationSource = goalColumns.has("verification_method") ? "verification_method" : observationSource;
|
|
786
|
-
this.db.exec(`PRAGMA defer_foreign_keys=ON;
|
|
787
|
-
DROP TABLE IF EXISTS work_records;
|
|
788
|
-
DROP TRIGGER IF EXISTS events_no_update; DROP TRIGGER IF EXISTS events_no_delete; DROP TRIGGER IF EXISTS events_fts_insert;
|
|
789
|
-
DROP TRIGGER IF EXISTS wakes_valid_transition; DROP TRIGGER IF EXISTS actions_valid_transition; DROP TRIGGER IF EXISTS goals_valid_transition;
|
|
790
|
-
DROP INDEX IF EXISTS events_agent_kind_seq; DROP INDEX IF EXISTS events_wake_seq; DROP INDEX IF EXISTS events_coalesced_trigger;
|
|
791
|
-
DROP INDEX IF EXISTS events_actor_type_seq; DROP INDEX IF EXISTS events_stream_seq;
|
|
792
|
-
DROP INDEX IF EXISTS wakes_one_active_agent; DROP INDEX IF EXISTS wakes_queue_order; DROP INDEX IF EXISTS schedule_due; DROP INDEX IF EXISTS actions_agent_status;
|
|
793
|
-
DROP TABLE IF EXISTS events_fts;
|
|
794
|
-
|
|
795
|
-
ALTER TABLE goals RENAME TO goals_legacy; ${createGoals}
|
|
796
|
-
INSERT INTO goals(id,parent_id,objective,observation_method,verification_method,owner,phase,revision) SELECT id,parent_id,objective,${observationSource},${verificationSource},owner,phase,revision FROM goals_legacy;
|
|
797
|
-
DROP TABLE goals_legacy;`);
|
|
798
|
-
const wakeColumns = new Set(this.db.prepare("PRAGMA table_info(wakes)").all().map((row) => row.name));
|
|
799
|
-
if (!wakeColumns.has("enqueued_seq")) {
|
|
800
|
-
this.db.exec(`ALTER TABLE wakes RENAME TO wakes_legacy; ${createWakes}
|
|
801
|
-
INSERT INTO wakes SELECT id,agent,trigger_ref,status,lease_until,attempt,started_at,ended_at,rowid,
|
|
802
|
-
CASE WHEN status IN ('leased','running') THEN 'legacy:'||id||':'||attempt ELSE NULL END,NULL FROM wakes_legacy;
|
|
803
|
-
DROP TABLE wakes_legacy;`);
|
|
804
|
-
}
|
|
805
|
-
const actionColumns = new Set(this.db.prepare("PRAGMA table_info(actions)").all().map((row) => row.name));
|
|
806
|
-
if (!actionColumns.has("connector")) {
|
|
807
|
-
this.db.exec(`ALTER TABLE actions RENAME TO actions_legacy; ${createActions}
|
|
808
|
-
INSERT INTO actions SELECT id,agent,kind,'legacy',payload,reason,evidence,gated,status,reconciled_at,external_ref,audit_advice,advice_acked FROM actions_legacy;
|
|
809
|
-
DROP TABLE actions_legacy;`);
|
|
810
|
-
}
|
|
811
|
-
const eventColumns = new Set(this.db.prepare("PRAGMA table_info(events)").all().map((row) => row.name));
|
|
812
|
-
if (!eventColumns.has("stream_id")) {
|
|
813
|
-
this.db.exec(`ALTER TABLE events RENAME TO events_legacy;
|
|
814
|
-
CREATE TABLE events (
|
|
815
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
816
|
-
stream_id TEXT NOT NULL,
|
|
817
|
-
stream_seq INTEGER NOT NULL CHECK(stream_seq > 0),
|
|
818
|
-
ts TEXT NOT NULL,
|
|
819
|
-
actor TEXT NOT NULL,
|
|
820
|
-
type TEXT NOT NULL,
|
|
821
|
-
data TEXT NOT NULL CHECK(json_valid(data)),
|
|
822
|
-
ignorable INTEGER CHECK(ignorable IS NULL OR ignorable = 1),
|
|
823
|
-
UNIQUE(stream_id, stream_seq)
|
|
824
|
-
) STRICT;
|
|
825
|
-
INSERT INTO events(seq,stream_id,stream_seq,ts,actor,type,data,ignorable)
|
|
826
|
-
SELECT seq,stream_id,ROW_NUMBER() OVER (PARTITION BY stream_id ORDER BY seq),ts,agent,kind,data,NULL FROM (
|
|
827
|
-
SELECT seq,ts,agent,kind,data,CASE WHEN wake_id IS NULL THEN 'control:'||agent ELSE 'wake:'||wake_id END AS stream_id FROM events_legacy
|
|
828
|
-
);
|
|
829
|
-
DROP TABLE events_legacy;`);
|
|
830
|
-
}
|
|
831
|
-
else if (!eventColumns.has("ignorable")) {
|
|
832
|
-
this.db.exec("ALTER TABLE events ADD COLUMN ignorable INTEGER CHECK(ignorable IS NULL OR ignorable = 1)");
|
|
833
|
-
}
|
|
834
|
-
this.db.exec(createWorkRecords);
|
|
835
|
-
for (const goal of this.goals()) {
|
|
836
|
-
if (!this.workRecord(goal.id))
|
|
837
|
-
this.#createWorkRecord(goal, "supervisor", `migration:work-record:${goal.id}`, undefined, this.#legacyWorkRecord(goal));
|
|
838
|
-
}
|
|
839
|
-
this.db.exec(`${indexesAndTriggers} INSERT INTO events_fts(events_fts) VALUES('rebuild'); PRAGMA user_version=${SQLITE_SCHEMA_VERSION};`);
|
|
840
|
-
});
|
|
841
1179
|
}
|
|
842
1180
|
#transaction(fn) {
|
|
843
1181
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -854,12 +1192,38 @@ export class SqliteLedger {
|
|
|
854
1192
|
#now() { return this.#clock.now().toISOString(); }
|
|
855
1193
|
}
|
|
856
1194
|
function mapEvent(r) { return { seq: Number(r.seq), streamId: String(r.stream_id), streamSeq: Number(r.stream_seq), ts: String(r.ts), actor: String(r.actor), type: String(r.type), data: JSON.parse(String(r.data)), ...(Number(r.ignorable) === 1 ? { ignorable: true } : {}) }; }
|
|
1195
|
+
function mapThread(r) { return { id: String(r.id), agent: String(r.agent), parentThreadId: r.parent_thread_id === null ? null : String(r.parent_thread_id), createdAt: String(r.created_at), updatedAt: String(r.updated_at) }; }
|
|
1196
|
+
function mapTurn(r) { return { id: String(r.id), threadId: String(r.thread_id), source: String(r.source), bindingKind: String(r.binding_kind), specialistRole: r.specialist_role === null ? null : String(r.specialist_role), goalId: r.goal_id === null ? null : String(r.goal_id), goalRevision: r.goal_revision === null ? null : Number(r.goal_revision), status: String(r.status), attempt: Number(r.attempt), error: r.error === null ? null : JSON.parse(String(r.error)), startedAt: String(r.started_at), endedAt: r.ended_at === null ? null : String(r.ended_at), leaseUntil: r.lease_until === null ? null : String(r.lease_until), leaseToken: r.lease_token === null ? null : String(r.lease_token), runnerPid: r.runner_pid === null ? null : Number(r.runner_pid), ...(r.runner_profile_id === null || r.runner_profile_id === undefined ? {} : { runnerProfileId: String(r.runner_profile_id) }) }; }
|
|
1197
|
+
function mapTurnItem(r) { return { id: String(r.id), turnId: String(r.turn_id), ordinal: Number(r.ordinal), type: String(r.type), status: String(r.status), data: JSON.parse(String(r.data)), createdAt: String(r.created_at), completedAt: r.completed_at === null ? null : String(r.completed_at) }; }
|
|
857
1198
|
function normalizeGoal(goal) { return { ...goal, observationMethod: goal.observationMethod ?? null, verificationMethod: goal.verificationMethod === undefined ? goal.observationMethod ?? null : goal.verificationMethod }; }
|
|
858
1199
|
function mapGoal(r) { return { id: String(r.id), parentId: r.parent_id === null ? null : String(r.parent_id), objective: String(r.objective), observationMethod: r.observation_method === null || r.observation_method === undefined ? null : String(r.observation_method), verificationMethod: r.verification_method === null || r.verification_method === undefined ? null : String(r.verification_method), owner: String(r.owner), phase: String(r.phase), revision: Number(r.revision) }; }
|
|
859
|
-
function mapWorkRecord(r) { return { goalId: String(r.goal_id), recordRevision: Number(r.record_revision), goalRevision: Number(r.goal_revision), content: String(r.content), updatedBy: String(r.updated_by), updatedInTurn: String(r.updated_in_turn),
|
|
1200
|
+
function mapWorkRecord(r) { return { goalId: String(r.goal_id), recordRevision: Number(r.record_revision), goalRevision: Number(r.goal_revision), content: String(r.content), updatedBy: String(r.updated_by), updatedInTurn: String(r.updated_in_turn), sourceWakeId: r.source_wake_id === null ? null : String(r.source_wake_id), updatedAt: String(r.updated_at), reason: String(r.reason), evidence: JSON.parse(String(r.evidence)), lastEventSeq: Number(r.last_event_seq) }; }
|
|
860
1201
|
function mapWorkRecordEvent(event) { const data = event.data; if (!data.snapshot)
|
|
861
1202
|
throw new Error(`work record event ${event.seq} has no snapshot`); return { ...data.snapshot, lastEventSeq: event.seq }; }
|
|
862
1203
|
function initialWorkRecord() { return "# Current State\n\nGoal created. Work has not started.\n\n# Observations\n\n# Work Completed\n\n# Decisions\n\n# Blockers\n\n# Next Steps\n"; }
|
|
1204
|
+
function goalOperation(previous, next) { if (!previous)
|
|
1205
|
+
return "create"; if (previous.phase !== next.phase) {
|
|
1206
|
+
if (next.phase === "paused")
|
|
1207
|
+
return "pause";
|
|
1208
|
+
if (next.phase === "active")
|
|
1209
|
+
return "resume";
|
|
1210
|
+
if (next.phase === "blocked")
|
|
1211
|
+
return "block";
|
|
1212
|
+
if (next.phase === "complete")
|
|
1213
|
+
return "complete";
|
|
1214
|
+
} return previous.owner !== next.owner ? "reassign" : "revise"; }
|
|
1215
|
+
function defaultGoalReason(operation) { return `Goal ${operation}`; }
|
|
1216
|
+
function withoutSourceTurn(value) { const { sourceTurnId: _, ...semantic } = value; return semantic; }
|
|
1217
|
+
function projectionForEvent(type) { if (type === "thread.put")
|
|
1218
|
+
return "threads"; if (type.startsWith("turn."))
|
|
1219
|
+
return "turns"; if (type.startsWith("item."))
|
|
1220
|
+
return "turn_items"; if (type === "goal.changed")
|
|
1221
|
+
return "goals"; if (type.startsWith("schedule."))
|
|
1222
|
+
return "schedule"; if (type.startsWith("wake_trigger."))
|
|
1223
|
+
return "wake_triggers"; if (type.startsWith("wake."))
|
|
1224
|
+
return "wakes"; if (type.startsWith("mail."))
|
|
1225
|
+
return "mailbox"; if (type === "work_record.created" || type === "work_record.updated")
|
|
1226
|
+
return "work_records"; return null; }
|
|
863
1227
|
function lineDiff(from, to) {
|
|
864
1228
|
const before = from.split("\n");
|
|
865
1229
|
const after = to.split("\n");
|
|
@@ -877,8 +1241,14 @@ function lineDiff(from, to) {
|
|
|
877
1241
|
}
|
|
878
1242
|
return lines.join("\n");
|
|
879
1243
|
}
|
|
880
|
-
function mapSchedule(r) { return { id: String(r.id), agent: String(r.agent), nextWakeAt: String(r.next_wake_at), reason: String(r.reason), setBy: String(r.set_by) }; }
|
|
881
|
-
function mapWake(r) { return { id: String(r.id),
|
|
882
|
-
function
|
|
883
|
-
|
|
1244
|
+
function mapSchedule(r) { return { id: String(r.id), bindingKind: String(r.binding_kind), agent: String(r.agent), specialistRole: r.specialist_role === null ? null : String(r.specialist_role), goalId: r.goal_id === null ? null : String(r.goal_id), nextWakeAt: String(r.next_wake_at), reason: String(r.reason), setBy: String(r.set_by), status: String(r.status), resolvedAt: r.resolved_at === null ? null : String(r.resolved_at) }; }
|
|
1245
|
+
function mapWake(r) { return { id: String(r.id), bindingKind: String(r.binding_kind), agent: String(r.agent), specialistRole: r.specialist_role === null ? null : String(r.specialist_role), goalId: r.goal_id === null ? null : String(r.goal_id), triggerRef: String(r.trigger_ref), status: String(r.status), attempt: Number(r.attempt), enqueuedSeq: Number(r.enqueued_seq), claimedAt: r.claimed_at === null ? null : String(r.claimed_at), consumedAt: r.consumed_at === null ? null : String(r.consumed_at), turnId: r.turn_id === null ? null : String(r.turn_id) }; }
|
|
1246
|
+
function sameWakeTurnBinding(wake, turn) { if (wake.bindingKind !== turn.bindingKind)
|
|
1247
|
+
return false; if (wake.bindingKind === "human")
|
|
1248
|
+
return turn.bindingKind === "human" && turn.source === "human"; if (wake.bindingKind === "goal")
|
|
1249
|
+
return turn.bindingKind === "goal" && wake.goalId === turn.goalId; return turn.bindingKind === "specialist" && wake.specialistRole === turn.specialistRole && turn.source === "system"; }
|
|
1250
|
+
function mapWakeTrigger(r) { return { wakeId: String(r.wake_id), agent: String(r.agent), triggerRef: String(r.trigger_ref), source: String(r.source), status: String(r.status), addedAt: String(r.added_at), resolvedAt: r.resolved_at === null ? null : String(r.resolved_at) }; }
|
|
1251
|
+
function mapMail(r) { return { id: String(r.id), routeKind: String(r.route_kind), to: String(r.to_agent), from: String(r.from_agent), specialistRole: r.specialist_role === null ? null : String(r.specialist_role), level: String(r.level), goalId: r.goal_id === null ? null : String(r.goal_id), body: JSON.parse(String(r.body)), readAt: r.read_at === null ? null : String(r.read_at) }; }
|
|
1252
|
+
function mailIdFromTrigger(trigger) { if (!trigger.startsWith("mail:"))
|
|
1253
|
+
return null; return trigger.slice("mail:".length).split("@redelivery:")[0] || null; }
|
|
884
1254
|
//# sourceMappingURL=index.js.map
|