instar 0.28.83 → 0.28.85
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/dashboard/index.html +239 -0
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/intent.d.ts.map +1 -1
- package/dist/commands/intent.js +29 -2
- package/dist/commands/intent.js.map +1 -1
- package/dist/commands/memoryBackfillEvidence.d.ts +69 -0
- package/dist/commands/memoryBackfillEvidence.d.ts.map +1 -0
- package/dist/commands/memoryBackfillEvidence.js +220 -0
- package/dist/commands/memoryBackfillEvidence.js.map +1 -0
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +86 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/DecisionJournal.d.ts +66 -3
- package/dist/core/DecisionJournal.d.ts.map +1 -1
- package/dist/core/DecisionJournal.js +116 -3
- package/dist/core/DecisionJournal.js.map +1 -1
- package/dist/core/DispatchExecutor.d.ts +74 -1
- package/dist/core/DispatchExecutor.d.ts.map +1 -1
- package/dist/core/DispatchExecutor.js +120 -1
- package/dist/core/DispatchExecutor.js.map +1 -1
- package/dist/core/EvolutionManager.d.ts +133 -0
- package/dist/core/EvolutionManager.d.ts.map +1 -1
- package/dist/core/EvolutionManager.js +494 -0
- package/dist/core/EvolutionManager.js.map +1 -1
- package/dist/core/InitiativeTracker.d.ts +82 -15
- package/dist/core/InitiativeTracker.d.ts.map +1 -1
- package/dist/core/InitiativeTracker.js +502 -31
- package/dist/core/InitiativeTracker.js.map +1 -1
- package/dist/core/LearnSkillBridge.d.ts +119 -0
- package/dist/core/LearnSkillBridge.d.ts.map +1 -0
- package/dist/core/LearnSkillBridge.js +160 -0
- package/dist/core/LearnSkillBridge.js.map +1 -0
- package/dist/core/SharedStateLedger.d.ts.map +1 -1
- package/dist/core/SharedStateLedger.js +4 -0
- package/dist/core/SharedStateLedger.js.map +1 -1
- package/dist/core/types.d.ts +124 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +8 -0
- package/dist/core/types.js.map +1 -1
- package/dist/memory/EvidenceRenderer.d.ts +64 -0
- package/dist/memory/EvidenceRenderer.d.ts.map +1 -0
- package/dist/memory/EvidenceRenderer.js +119 -0
- package/dist/memory/EvidenceRenderer.js.map +1 -0
- package/dist/memory/SemanticMemory.d.ts +61 -1
- package/dist/memory/SemanticMemory.d.ts.map +1 -1
- package/dist/memory/SemanticMemory.js +381 -11
- package/dist/memory/SemanticMemory.js.map +1 -1
- package/dist/server/AgentServer.d.ts +4 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +2 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +8 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +500 -14
- package/dist/server/routes.js.map +1 -1
- package/dist/tasks/DivergenceChecker.d.ts +89 -0
- package/dist/tasks/DivergenceChecker.d.ts.map +1 -0
- package/dist/tasks/DivergenceChecker.js +255 -0
- package/dist/tasks/DivergenceChecker.js.map +1 -0
- package/dist/tasks/LruCache.d.ts +33 -0
- package/dist/tasks/LruCache.d.ts.map +1 -0
- package/dist/tasks/LruCache.js +88 -0
- package/dist/tasks/LruCache.js.map +1 -0
- package/dist/tasks/RateLimiter.d.ts +61 -0
- package/dist/tasks/RateLimiter.d.ts.map +1 -0
- package/dist/tasks/RateLimiter.js +108 -0
- package/dist/tasks/RateLimiter.js.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts +29 -0
- package/dist/tasks/TaskFlowDueWaker.d.ts.map +1 -0
- package/dist/tasks/TaskFlowDueWaker.js +73 -0
- package/dist/tasks/TaskFlowDueWaker.js.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts +40 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.d.ts.map +1 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js +131 -0
- package/dist/tasks/TaskFlowMaintenanceSweeper.js.map +1 -0
- package/dist/tasks/TaskFlowRegistry.d.ts +180 -0
- package/dist/tasks/TaskFlowRegistry.d.ts.map +1 -0
- package/dist/tasks/TaskFlowRegistry.js +791 -0
- package/dist/tasks/TaskFlowRegistry.js.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts +54 -0
- package/dist/tasks/ThreadlineFlowBridge.d.ts.map +1 -0
- package/dist/tasks/ThreadlineFlowBridge.js +129 -0
- package/dist/tasks/ThreadlineFlowBridge.js.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts +77 -0
- package/dist/tasks/task-flow-registry.store.sqlite.d.ts.map +1 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js +350 -0
- package/dist/tasks/task-flow-registry.store.sqlite.js.map +1 -0
- package/dist/tasks/task-flow-types.d.ts +229 -0
- package/dist/tasks/task-flow-types.d.ts.map +1 -0
- package/dist/tasks/task-flow-types.js +128 -0
- package/dist/tasks/task-flow-types.js.map +1 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts +32 -0
- package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.js +82 -5
- package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
- package/dist/threadline/mcp-stdio-entry.js +56 -0
- package/dist/threadline/mcp-stdio-entry.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +78 -78
- package/upgrades/0.28.84.md +89 -0
- package/upgrades/0.28.85.md +64 -0
- package/upgrades/side-effects/0.28.83.md +27 -0
- package/upgrades/side-effects/taskflow-phase1.md +130 -0
- package/upgrades/side-effects/taskflow-phase2.md +110 -0
- package/upgrades/side-effects/taskflow-phase3a.md +138 -0
- package/upgrades/side-effects/taskflow-phase4.md +377 -0
- package/upgrades/side-effects/taskflow-phase5.md +234 -0
- package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase1.md +138 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase4.md +129 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase5.md +285 -0
|
@@ -5,26 +5,99 @@
|
|
|
5
5
|
* scheduled Jobs (recurring cron task). An Initiative represents a
|
|
6
6
|
* bounded-but-multi-week effort with phases, each advancing in order.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Storage:
|
|
9
|
+
* - When TaskFlow is wired (`setTaskFlowRegistry`), TaskFlow is the
|
|
10
|
+
* **single source of truth** (per OPENCLAW-IMPORT-TASKFLOW-SPEC.md
|
|
11
|
+
* § Phase 4, lines 645–648). Each initiative is one TaskFlow record:
|
|
12
|
+
* `controllerId="InitiativeTracker"`, `ownerKey="initiative:<id>"`,
|
|
13
|
+
* `idempotencyKey="initiative:<id>"`. The full Initiative shape is
|
|
14
|
+
* persisted in `stateJson`. The active phase id maps to `currentStep`;
|
|
15
|
+
* `needsUser` / blockers map to `setFlowWaiting({kind:"human-review"})`.
|
|
16
|
+
* - When TaskFlow is NOT wired, behavior falls back to legacy
|
|
17
|
+
* `.instar/initiatives.json`. This keeps the disabled-feature path
|
|
18
|
+
* working for installs where `taskFlow.enabled` is false.
|
|
19
|
+
*
|
|
20
|
+
* Migration:
|
|
21
|
+
* - On startup, the server calls `migrateExistingToTaskFlow()` once when
|
|
22
|
+
* TaskFlow is enabled. It backfills any initiatives present in the
|
|
23
|
+
* legacy JSON file into TaskFlow. Idempotent via `findIdempotent` on
|
|
24
|
+
* `(controllerId, ownerKey, idempotencyKey)` — running twice produces
|
|
25
|
+
* no duplicates.
|
|
26
|
+
*
|
|
27
|
+
* API shape:
|
|
28
|
+
* - All public mutators are `async` to allow TaskFlow's promise-based
|
|
29
|
+
* write API to complete before returning. Reads are `sync` (TaskFlow's
|
|
30
|
+
* read API uses `better-sqlite3` synchronously through the in-memory
|
|
31
|
+
* cache).
|
|
32
|
+
*
|
|
9
33
|
* Consumers: HTTP routes (`/initiatives/*`), dashboard "Initiatives" tab,
|
|
10
34
|
* daily digest job (alerts when initiatives go stale / need user input /
|
|
11
35
|
* are ready to advance).
|
|
12
36
|
*/
|
|
13
37
|
import fs from 'node:fs';
|
|
14
38
|
import path from 'node:path';
|
|
39
|
+
import { TaskFlowError } from '../tasks/task-flow-types.js';
|
|
15
40
|
/**
|
|
16
41
|
* Staleness threshold for the digest scan (7 days without an update on an
|
|
17
42
|
* active initiative triggers a 'stale' flag).
|
|
18
43
|
*/
|
|
19
44
|
export const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
/**
|
|
46
|
+
* TaskFlow controller identity for InitiativeTracker (Phase 4 migration).
|
|
47
|
+
* Every initiative's flow is owned by this controllerId; the registry uses
|
|
48
|
+
* it for OCC scope checks. Single instance per server process.
|
|
49
|
+
*/
|
|
50
|
+
export const INITIATIVE_TASKFLOW_CONTROLLER_ID = 'InitiativeTracker';
|
|
51
|
+
function ownerKeyForInitiative(initiativeId) {
|
|
52
|
+
return `initiative:${initiativeId}`;
|
|
53
|
+
}
|
|
54
|
+
function idempotencyKeyForInitiative(initiativeId) {
|
|
55
|
+
return `initiative:${initiativeId}`;
|
|
56
|
+
}
|
|
20
57
|
export class InitiativeTracker {
|
|
21
58
|
filePath;
|
|
59
|
+
/** In-process cache. Authoritative when TaskFlow is not wired; otherwise
|
|
60
|
+
* a read-side projection of TaskFlow's stateJson. */
|
|
22
61
|
initiatives = new Map();
|
|
62
|
+
// ── TaskFlow Phase 4 wiring ────────────────────────────────────
|
|
63
|
+
taskFlowRegistry = null;
|
|
64
|
+
taskFlowControllerInstanceId = null;
|
|
23
65
|
constructor(stateDir) {
|
|
24
66
|
this.filePath = path.join(stateDir, 'initiatives.json');
|
|
25
|
-
this.
|
|
67
|
+
this.loadFromDisk();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Wire a TaskFlow registry to make TaskFlow the source of truth.
|
|
71
|
+
* Idempotent — safe to call multiple times. After wiring, all reads come
|
|
72
|
+
* from TaskFlow and all writes go through TaskFlow APIs. The legacy JSON
|
|
73
|
+
* file is no longer written.
|
|
74
|
+
*
|
|
75
|
+
* Callers are expected to call `migrateExistingToTaskFlow()` after this
|
|
76
|
+
* to backfill any initiatives that were loaded from the legacy file.
|
|
77
|
+
*/
|
|
78
|
+
setTaskFlowRegistry(registry, controllerInstanceId) {
|
|
79
|
+
this.taskFlowRegistry = registry;
|
|
80
|
+
this.taskFlowControllerInstanceId = controllerInstanceId;
|
|
81
|
+
// Layer existing TaskFlow records over legacy-loaded data without
|
|
82
|
+
// clearing the cache: any legacy initiatives still need to be backfilled
|
|
83
|
+
// via `migrateExistingToTaskFlow()`. TaskFlow records win on collision.
|
|
84
|
+
this.layerCacheFromTaskFlow();
|
|
85
|
+
}
|
|
86
|
+
/** True when TaskFlow is wired and authoritative. */
|
|
87
|
+
isTaskFlowEnabled() {
|
|
88
|
+
return this.taskFlowRegistry !== null && this.taskFlowControllerInstanceId !== null;
|
|
89
|
+
}
|
|
90
|
+
taskFlowPrincipal() {
|
|
91
|
+
if (!this.taskFlowRegistry || !this.taskFlowControllerInstanceId)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
scope: 'controller',
|
|
95
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
96
|
+
controllerInstanceId: this.taskFlowControllerInstanceId,
|
|
97
|
+
};
|
|
26
98
|
}
|
|
27
|
-
|
|
99
|
+
// ── Legacy JSON storage (used until TaskFlow is wired) ─────────
|
|
100
|
+
loadFromDisk() {
|
|
28
101
|
try {
|
|
29
102
|
if (!fs.existsSync(this.filePath))
|
|
30
103
|
return;
|
|
@@ -41,7 +114,9 @@ export class InitiativeTracker {
|
|
|
41
114
|
console.error(`[initiatives] Failed to load: ${err instanceof Error ? err.message : err}`);
|
|
42
115
|
}
|
|
43
116
|
}
|
|
44
|
-
|
|
117
|
+
saveToDisk() {
|
|
118
|
+
if (this.isTaskFlowEnabled())
|
|
119
|
+
return; // TaskFlow's SQLite is the durable store.
|
|
45
120
|
const dir = path.dirname(this.filePath);
|
|
46
121
|
fs.mkdirSync(dir, { recursive: true });
|
|
47
122
|
const payload = { initiatives: Array.from(this.initiatives.values()) };
|
|
@@ -49,24 +124,362 @@ export class InitiativeTracker {
|
|
|
49
124
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
50
125
|
fs.renameSync(tmp, this.filePath);
|
|
51
126
|
}
|
|
127
|
+
// ── TaskFlow ↔ Initiative serialization ─────────────────────────
|
|
128
|
+
initiativeFromFlow(flow) {
|
|
129
|
+
const state = flow.stateJson;
|
|
130
|
+
if (!state || typeof state !== 'object' || !state.initiative)
|
|
131
|
+
return null;
|
|
132
|
+
return state.initiative;
|
|
133
|
+
}
|
|
134
|
+
refreshCacheFromTaskFlow() {
|
|
135
|
+
if (!this.taskFlowRegistry)
|
|
136
|
+
return;
|
|
137
|
+
const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
|
|
138
|
+
this.initiatives.clear();
|
|
139
|
+
for (const f of flows) {
|
|
140
|
+
const init = this.initiativeFromFlow(f);
|
|
141
|
+
// Tombstone-removed initiatives are filtered out so callers can't
|
|
142
|
+
// see them via list() / get() after remove().
|
|
143
|
+
if (init && !this.isTombstoned(f)) {
|
|
144
|
+
this.initiatives.set(init.id, init);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Layer TaskFlow data on top of the existing cache without clearing it.
|
|
150
|
+
* Used during `setTaskFlowRegistry` so legacy-loaded initiatives are
|
|
151
|
+
* preserved as backfill candidates while any pre-existing TaskFlow
|
|
152
|
+
* records take precedence.
|
|
153
|
+
*/
|
|
154
|
+
layerCacheFromTaskFlow() {
|
|
155
|
+
if (!this.taskFlowRegistry)
|
|
156
|
+
return;
|
|
157
|
+
const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
|
|
158
|
+
for (const f of flows) {
|
|
159
|
+
const init = this.initiativeFromFlow(f);
|
|
160
|
+
if (init && !this.isTombstoned(f)) {
|
|
161
|
+
this.initiatives.set(init.id, init);
|
|
162
|
+
}
|
|
163
|
+
else if (this.isTombstoned(f) && init) {
|
|
164
|
+
// Tombstoned flows shadow legacy entries.
|
|
165
|
+
this.initiatives.delete(init.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A flow is "tombstoned" when remove() marked it as removed before
|
|
171
|
+
* cancelling. The marker lives in stateJson._removed and is the
|
|
172
|
+
* single signal that a flow's owning Initiative should be hidden from
|
|
173
|
+
* list() / get(). We can't delete TaskFlow records from this layer, so
|
|
174
|
+
* the marker is the durable equivalent of deletion.
|
|
175
|
+
*/
|
|
176
|
+
isTombstoned(flow) {
|
|
177
|
+
const state = flow.stateJson;
|
|
178
|
+
return state?._removed === true;
|
|
179
|
+
}
|
|
180
|
+
waitJsonForInitiative(init) {
|
|
181
|
+
const blocked = init.needsUser || init.blockers.length > 0;
|
|
182
|
+
if (!blocked)
|
|
183
|
+
return null;
|
|
184
|
+
const reason = init.needsUser
|
|
185
|
+
? init.needsUserReason ?? 'Initiative needs user decision'
|
|
186
|
+
: init.blockers.join('; ');
|
|
187
|
+
const question = (reason || 'Initiative blocked').slice(0, 2000);
|
|
188
|
+
return { kind: 'human-review', question };
|
|
189
|
+
}
|
|
190
|
+
/** Determine the desired TaskFlow status for an Initiative. */
|
|
191
|
+
desiredFlowStatus(init) {
|
|
192
|
+
const phase = init.phases[init.currentPhaseIndex];
|
|
193
|
+
const step = phase ? phase.id : 'unknown';
|
|
194
|
+
if (init.status === 'completed')
|
|
195
|
+
return { kind: 'succeeded', step };
|
|
196
|
+
if (init.status === 'abandoned')
|
|
197
|
+
return { kind: 'failed', step };
|
|
198
|
+
if (init.status === 'archived')
|
|
199
|
+
return { kind: 'cancelled', step };
|
|
200
|
+
const wait = this.waitJsonForInitiative(init);
|
|
201
|
+
if (wait)
|
|
202
|
+
return { kind: 'waiting', step, waitJson: wait };
|
|
203
|
+
return { kind: 'running', step };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Persist an Initiative through TaskFlow. Walks the flow state machine
|
|
207
|
+
* to the desired target (running / waiting / succeeded / failed /
|
|
208
|
+
* cancelled), updating `stateJson` along the way so reads see the latest
|
|
209
|
+
* shape. Returns the Initiative as projected from TaskFlow after
|
|
210
|
+
* transitions settle.
|
|
211
|
+
*/
|
|
212
|
+
async persistThroughTaskFlow(init) {
|
|
213
|
+
const registry = this.taskFlowRegistry;
|
|
214
|
+
const principal = this.taskFlowPrincipal();
|
|
215
|
+
if (!registry || !principal || principal.scope !== 'controller')
|
|
216
|
+
return init;
|
|
217
|
+
const ownerKey = ownerKeyForInitiative(init.id);
|
|
218
|
+
const idemKey = idempotencyKeyForInitiative(init.id);
|
|
219
|
+
const desired = this.desiredFlowStatus(init);
|
|
220
|
+
const stateJson = { initiative: init };
|
|
221
|
+
let existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
|
|
222
|
+
if (!existing) {
|
|
223
|
+
const r = await registry.createFlow({
|
|
224
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
225
|
+
controllerInstanceId: principal.controllerInstanceId,
|
|
226
|
+
ownerKey,
|
|
227
|
+
idempotencyKey: idemKey,
|
|
228
|
+
goal: init.title.slice(0, 1024),
|
|
229
|
+
currentStep: desired.step,
|
|
230
|
+
stateJson,
|
|
231
|
+
});
|
|
232
|
+
existing = r.flow;
|
|
233
|
+
}
|
|
234
|
+
let cur = registry.getFlow(existing.flowId, { bypassCache: true }) ?? existing;
|
|
235
|
+
try {
|
|
236
|
+
// Already terminal? Accept and stop — terminal flows are immutable
|
|
237
|
+
// per TaskFlow contract. The Initiative's local terminal state stays
|
|
238
|
+
// in sync via the cache, but we never re-mutate the flow.
|
|
239
|
+
if (cur.status === 'succeeded' ||
|
|
240
|
+
cur.status === 'failed' ||
|
|
241
|
+
cur.status === 'cancelled' ||
|
|
242
|
+
cur.status === 'lost') {
|
|
243
|
+
return this.initiativeFromFlow(cur) ?? init;
|
|
244
|
+
}
|
|
245
|
+
// queued → running (always; a fresh create lands in queued).
|
|
246
|
+
if (cur.status === 'queued') {
|
|
247
|
+
const r = await registry.startStep({
|
|
248
|
+
flowId: cur.flowId,
|
|
249
|
+
expectedRevision: cur.revision,
|
|
250
|
+
principal,
|
|
251
|
+
currentStep: desired.step,
|
|
252
|
+
});
|
|
253
|
+
cur = r.flow;
|
|
254
|
+
}
|
|
255
|
+
// waiting → running (resume), in case desired wait is different or
|
|
256
|
+
// we're moving to running/terminal. resumeFlow accepts statePatch so
|
|
257
|
+
// we update stateJson here too.
|
|
258
|
+
if (cur.status === 'waiting') {
|
|
259
|
+
if (cur.waitInstanceId) {
|
|
260
|
+
const r = await registry.resumeFlow({
|
|
261
|
+
flowId: cur.flowId,
|
|
262
|
+
expectedRevision: cur.revision,
|
|
263
|
+
principal,
|
|
264
|
+
waitInstanceId: cur.waitInstanceId,
|
|
265
|
+
currentStep: desired.step,
|
|
266
|
+
statePatch: stateJson,
|
|
267
|
+
});
|
|
268
|
+
cur = r.flow;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Now cur.status === 'running'. Drive to the desired terminal state.
|
|
272
|
+
if (cur.status === 'running') {
|
|
273
|
+
if (desired.kind === 'running') {
|
|
274
|
+
// Re-startStep when currentStep changed; this updates the step.
|
|
275
|
+
if (cur.currentStep !== desired.step) {
|
|
276
|
+
const r = await registry.startStep({
|
|
277
|
+
flowId: cur.flowId,
|
|
278
|
+
expectedRevision: cur.revision,
|
|
279
|
+
principal,
|
|
280
|
+
currentStep: desired.step,
|
|
281
|
+
});
|
|
282
|
+
cur = r.flow;
|
|
283
|
+
}
|
|
284
|
+
// Always patch stateJson to reflect the latest Initiative shape.
|
|
285
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
286
|
+
}
|
|
287
|
+
else if (desired.kind === 'waiting') {
|
|
288
|
+
const r = await registry.setFlowWaiting({
|
|
289
|
+
flowId: cur.flowId,
|
|
290
|
+
expectedRevision: cur.revision,
|
|
291
|
+
principal,
|
|
292
|
+
waitJson: desired.waitJson,
|
|
293
|
+
currentStep: desired.step,
|
|
294
|
+
statePatch: stateJson,
|
|
295
|
+
});
|
|
296
|
+
cur = r.flow;
|
|
297
|
+
}
|
|
298
|
+
else if (desired.kind === 'succeeded') {
|
|
299
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
300
|
+
const r = await registry.finishFlow({
|
|
301
|
+
flowId: cur.flowId,
|
|
302
|
+
expectedRevision: cur.revision,
|
|
303
|
+
principal,
|
|
304
|
+
});
|
|
305
|
+
cur = r.flow;
|
|
306
|
+
}
|
|
307
|
+
else if (desired.kind === 'failed') {
|
|
308
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
309
|
+
const r = await registry.failFlow({
|
|
310
|
+
flowId: cur.flowId,
|
|
311
|
+
expectedRevision: cur.revision,
|
|
312
|
+
principal,
|
|
313
|
+
failureReason: 'abandoned',
|
|
314
|
+
});
|
|
315
|
+
cur = r.flow;
|
|
316
|
+
}
|
|
317
|
+
else if (desired.kind === 'cancelled') {
|
|
318
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
319
|
+
const cr = await registry.requestFlowCancel({
|
|
320
|
+
flowId: cur.flowId,
|
|
321
|
+
expectedRevision: cur.revision,
|
|
322
|
+
requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
|
|
323
|
+
});
|
|
324
|
+
const r = await registry.cancelFlow({
|
|
325
|
+
flowId: cur.flowId,
|
|
326
|
+
expectedRevision: cr.flow.revision,
|
|
327
|
+
principal,
|
|
328
|
+
});
|
|
329
|
+
cur = r.flow;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
this.logTaskFlowError('persist', init.id, err);
|
|
335
|
+
}
|
|
336
|
+
const fresh = registry.getFlow(cur.flowId, { bypassCache: true }) ?? cur;
|
|
337
|
+
const persisted = this.initiativeFromFlow(fresh) ?? init;
|
|
338
|
+
this.initiatives.set(persisted.id, persisted);
|
|
339
|
+
return persisted;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Patch a running flow's stateJson without changing its observable
|
|
343
|
+
* status. Uses a brief setFlowWaiting → resumeFlow round-trip (the only
|
|
344
|
+
* atomic state-bearing transitions for `running` flows that accept
|
|
345
|
+
* `statePatch`). The wait kind `'human-review'` with a sentinel question
|
|
346
|
+
* is used; the wait is consumed in the same call, so no external
|
|
347
|
+
* observer sees `waiting`.
|
|
348
|
+
*/
|
|
349
|
+
async patchStateJson(flow, stateJson) {
|
|
350
|
+
const registry = this.taskFlowRegistry;
|
|
351
|
+
const principal = this.taskFlowPrincipal();
|
|
352
|
+
if (!registry || !principal || principal.scope !== 'controller')
|
|
353
|
+
return flow;
|
|
354
|
+
if (flow.status !== 'running')
|
|
355
|
+
return flow;
|
|
356
|
+
try {
|
|
357
|
+
const w = await registry.setFlowWaiting({
|
|
358
|
+
flowId: flow.flowId,
|
|
359
|
+
expectedRevision: flow.revision,
|
|
360
|
+
principal,
|
|
361
|
+
waitJson: { kind: 'human-review', question: '__statePatch__' },
|
|
362
|
+
statePatch: stateJson,
|
|
363
|
+
});
|
|
364
|
+
const r = await registry.resumeFlow({
|
|
365
|
+
flowId: w.flow.flowId,
|
|
366
|
+
expectedRevision: w.flow.revision,
|
|
367
|
+
principal,
|
|
368
|
+
waitInstanceId: w.flow.waitInstanceId,
|
|
369
|
+
statePatch: stateJson,
|
|
370
|
+
});
|
|
371
|
+
return r.flow;
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
this.logTaskFlowError('patchStateJson', flow.flowId, err);
|
|
375
|
+
return flow;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
findFlowIdForInitiative(id) {
|
|
379
|
+
const registry = this.taskFlowRegistry;
|
|
380
|
+
if (!registry)
|
|
381
|
+
return null;
|
|
382
|
+
const f = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKeyForInitiative(id), idempotencyKeyForInitiative(id));
|
|
383
|
+
return f?.flowId ?? null;
|
|
384
|
+
}
|
|
385
|
+
logTaskFlowError(op, key, err) {
|
|
386
|
+
if (err instanceof TaskFlowError) {
|
|
387
|
+
console.warn(`[InitiativeTracker] taskflow ${op} for ${key} skipped: ${err.code} (${err.message})`);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
console.warn(`[InitiativeTracker] taskflow ${op} for ${key} unexpected error:`, err instanceof Error ? err.message : err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Backfill all initiatives currently in the in-memory cache (loaded from
|
|
395
|
+
* legacy JSON in the constructor) into TaskFlow. Idempotent via
|
|
396
|
+
* `findIdempotent`. Safe to call multiple times.
|
|
397
|
+
*/
|
|
398
|
+
async migrateExistingToTaskFlow() {
|
|
399
|
+
const registry = this.taskFlowRegistry;
|
|
400
|
+
const principal = this.taskFlowPrincipal();
|
|
401
|
+
if (!registry || !principal || principal.scope !== 'controller') {
|
|
402
|
+
return { created: 0, alreadyExisted: 0, advanced: 0, skipped: 0 };
|
|
403
|
+
}
|
|
404
|
+
let created = 0;
|
|
405
|
+
let alreadyExisted = 0;
|
|
406
|
+
let advanced = 0;
|
|
407
|
+
let skipped = 0;
|
|
408
|
+
const candidates = Array.from(this.initiatives.values());
|
|
409
|
+
for (const init of candidates) {
|
|
410
|
+
try {
|
|
411
|
+
const ownerKey = ownerKeyForInitiative(init.id);
|
|
412
|
+
const idemKey = idempotencyKeyForInitiative(init.id);
|
|
413
|
+
const existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
|
|
414
|
+
if (existing) {
|
|
415
|
+
alreadyExisted++;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
await registry.createFlow({
|
|
419
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
420
|
+
controllerInstanceId: principal.controllerInstanceId,
|
|
421
|
+
ownerKey,
|
|
422
|
+
idempotencyKey: idemKey,
|
|
423
|
+
goal: init.title.slice(0, 1024),
|
|
424
|
+
currentStep: init.phases[init.currentPhaseIndex]?.id ?? 'start',
|
|
425
|
+
stateJson: { initiative: init },
|
|
426
|
+
});
|
|
427
|
+
created++;
|
|
428
|
+
}
|
|
429
|
+
const persisted = await this.persistThroughTaskFlow(init);
|
|
430
|
+
if (persisted)
|
|
431
|
+
advanced++;
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
skipped++;
|
|
435
|
+
this.logTaskFlowError('migrate', init.id, err);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
this.refreshCacheFromTaskFlow();
|
|
439
|
+
return { created, alreadyExisted, advanced, skipped };
|
|
440
|
+
}
|
|
441
|
+
// ── Public API ─────────────────────────────────────────────────
|
|
52
442
|
list(filter) {
|
|
443
|
+
if (this.isTaskFlowEnabled())
|
|
444
|
+
this.refreshCacheFromTaskFlow();
|
|
53
445
|
const all = Array.from(this.initiatives.values());
|
|
54
446
|
const filtered = filter?.status ? all.filter((i) => i.status === filter.status) : all;
|
|
55
447
|
return filtered.sort((a, b) => b.lastTouchedAt.localeCompare(a.lastTouchedAt));
|
|
56
448
|
}
|
|
57
449
|
get(id) {
|
|
450
|
+
if (this.isTaskFlowEnabled()) {
|
|
451
|
+
const fid = this.findFlowIdForInitiative(id);
|
|
452
|
+
if (!fid)
|
|
453
|
+
return undefined;
|
|
454
|
+
const f = this.taskFlowRegistry.getFlow(fid, { bypassCache: true });
|
|
455
|
+
if (!f)
|
|
456
|
+
return undefined;
|
|
457
|
+
if (this.isTombstoned(f)) {
|
|
458
|
+
this.initiatives.delete(id);
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
const init = this.initiativeFromFlow(f);
|
|
462
|
+
if (init)
|
|
463
|
+
this.initiatives.set(id, init);
|
|
464
|
+
return init ?? undefined;
|
|
465
|
+
}
|
|
58
466
|
return this.initiatives.get(id);
|
|
59
467
|
}
|
|
60
|
-
create(input) {
|
|
61
|
-
if (this.initiatives.has(input.id)) {
|
|
62
|
-
throw new Error(`Initiative "${input.id}" already exists`);
|
|
63
|
-
}
|
|
468
|
+
async create(input) {
|
|
64
469
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.id)) {
|
|
65
470
|
throw new Error('Initiative id must be lowercase kebab-case, 1–63 chars');
|
|
66
471
|
}
|
|
67
472
|
if (!input.phases.length) {
|
|
68
473
|
throw new Error('Initiative must have at least one phase');
|
|
69
474
|
}
|
|
475
|
+
if (this.isTaskFlowEnabled()) {
|
|
476
|
+
if (this.findFlowIdForInitiative(input.id)) {
|
|
477
|
+
throw new Error(`Initiative "${input.id}" already exists`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else if (this.initiatives.has(input.id)) {
|
|
481
|
+
throw new Error(`Initiative "${input.id}" already exists`);
|
|
482
|
+
}
|
|
70
483
|
const now = new Date().toISOString();
|
|
71
484
|
const phases = input.phases.map((p) => ({
|
|
72
485
|
id: p.id,
|
|
@@ -74,7 +487,6 @@ export class InitiativeTracker {
|
|
|
74
487
|
summary: p.summary,
|
|
75
488
|
status: p.status ?? 'pending',
|
|
76
489
|
}));
|
|
77
|
-
// currentPhaseIndex: first non-'done' phase, else last phase.
|
|
78
490
|
const firstOpen = phases.findIndex((p) => p.status !== 'done');
|
|
79
491
|
const currentPhaseIndex = firstOpen === -1 ? phases.length - 1 : firstOpen;
|
|
80
492
|
const allDone = phases.every((p) => p.status === 'done');
|
|
@@ -95,11 +507,14 @@ export class InitiativeTracker {
|
|
|
95
507
|
updatedAt: now,
|
|
96
508
|
};
|
|
97
509
|
this.initiatives.set(initiative.id, initiative);
|
|
98
|
-
this.
|
|
510
|
+
if (this.isTaskFlowEnabled()) {
|
|
511
|
+
return await this.persistThroughTaskFlow(initiative);
|
|
512
|
+
}
|
|
513
|
+
this.saveToDisk();
|
|
99
514
|
return initiative;
|
|
100
515
|
}
|
|
101
|
-
update(id, input) {
|
|
102
|
-
const existing = this.
|
|
516
|
+
async update(id, input) {
|
|
517
|
+
const existing = this.get(id);
|
|
103
518
|
if (!existing)
|
|
104
519
|
throw new Error(`Initiative "${id}" not found`);
|
|
105
520
|
const now = new Date().toISOString();
|
|
@@ -123,17 +538,14 @@ export class InitiativeTracker {
|
|
|
123
538
|
if (input.links !== undefined)
|
|
124
539
|
next.links = input.links;
|
|
125
540
|
this.initiatives.set(id, next);
|
|
126
|
-
this.
|
|
541
|
+
if (this.isTaskFlowEnabled()) {
|
|
542
|
+
return await this.persistThroughTaskFlow(next);
|
|
543
|
+
}
|
|
544
|
+
this.saveToDisk();
|
|
127
545
|
return next;
|
|
128
546
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
* earliest non-done phase; marks the whole initiative 'completed' when
|
|
132
|
-
* all phases are 'done'. Sets startedAt/completedAt the first time a
|
|
133
|
-
* phase reaches 'in-progress' / 'done' respectively.
|
|
134
|
-
*/
|
|
135
|
-
setPhaseStatus(id, phaseId, status) {
|
|
136
|
-
const existing = this.initiatives.get(id);
|
|
547
|
+
async setPhaseStatus(id, phaseId, status) {
|
|
548
|
+
const existing = this.get(id);
|
|
137
549
|
if (!existing)
|
|
138
550
|
throw new Error(`Initiative "${id}" not found`);
|
|
139
551
|
const phases = existing.phases.map((p) => ({ ...p }));
|
|
@@ -157,21 +569,84 @@ export class InitiativeTracker {
|
|
|
157
569
|
lastTouchedAt: now,
|
|
158
570
|
};
|
|
159
571
|
this.initiatives.set(id, next);
|
|
160
|
-
this.
|
|
572
|
+
if (this.isTaskFlowEnabled()) {
|
|
573
|
+
return await this.persistThroughTaskFlow(next);
|
|
574
|
+
}
|
|
575
|
+
this.saveToDisk();
|
|
161
576
|
return next;
|
|
162
577
|
}
|
|
163
|
-
remove(id) {
|
|
578
|
+
async remove(id) {
|
|
579
|
+
if (this.isTaskFlowEnabled()) {
|
|
580
|
+
const fid = this.findFlowIdForInitiative(id);
|
|
581
|
+
if (!fid) {
|
|
582
|
+
return this.initiatives.delete(id);
|
|
583
|
+
}
|
|
584
|
+
const registry = this.taskFlowRegistry;
|
|
585
|
+
const principal = this.taskFlowPrincipal();
|
|
586
|
+
let flow = registry.getFlow(fid, { bypassCache: true });
|
|
587
|
+
if (flow && principal && principal.scope === 'controller') {
|
|
588
|
+
try {
|
|
589
|
+
if (flow.status === 'queued') {
|
|
590
|
+
const r = await registry.startStep({
|
|
591
|
+
flowId: flow.flowId,
|
|
592
|
+
expectedRevision: flow.revision,
|
|
593
|
+
principal,
|
|
594
|
+
currentStep: flow.currentStep ?? 'remove',
|
|
595
|
+
});
|
|
596
|
+
flow = r.flow;
|
|
597
|
+
}
|
|
598
|
+
if (flow.status === 'waiting' && flow.waitInstanceId) {
|
|
599
|
+
const r = await registry.resumeFlow({
|
|
600
|
+
flowId: flow.flowId,
|
|
601
|
+
expectedRevision: flow.revision,
|
|
602
|
+
principal,
|
|
603
|
+
waitInstanceId: flow.waitInstanceId,
|
|
604
|
+
});
|
|
605
|
+
flow = r.flow;
|
|
606
|
+
}
|
|
607
|
+
// Stamp the tombstone marker into stateJson so subsequent reads
|
|
608
|
+
// hide the initiative. Done before cancel because terminal flows
|
|
609
|
+
// are immutable.
|
|
610
|
+
const init = this.initiativeFromFlow(flow);
|
|
611
|
+
if (flow.status === 'running' && init) {
|
|
612
|
+
const tomb = await this.patchStateJson(flow, { initiative: init, _removed: true });
|
|
613
|
+
flow = tomb;
|
|
614
|
+
}
|
|
615
|
+
if (flow.status !== 'succeeded' &&
|
|
616
|
+
flow.status !== 'failed' &&
|
|
617
|
+
flow.status !== 'cancelled' &&
|
|
618
|
+
flow.status !== 'lost') {
|
|
619
|
+
const cr = await registry.requestFlowCancel({
|
|
620
|
+
flowId: flow.flowId,
|
|
621
|
+
expectedRevision: flow.revision,
|
|
622
|
+
requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
|
|
623
|
+
});
|
|
624
|
+
await registry.cancelFlow({
|
|
625
|
+
flowId: flow.flowId,
|
|
626
|
+
expectedRevision: cr.flow.revision,
|
|
627
|
+
principal,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
this.logTaskFlowError('remove', id, err);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
this.initiatives.delete(id);
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
164
638
|
const removed = this.initiatives.delete(id);
|
|
165
639
|
if (removed)
|
|
166
|
-
this.
|
|
640
|
+
this.saveToDisk();
|
|
167
641
|
return removed;
|
|
168
642
|
}
|
|
169
643
|
/**
|
|
170
|
-
* Scan active initiatives for anything actionable.
|
|
171
|
-
*
|
|
172
|
-
* means "quiet day, don't spam the user."
|
|
644
|
+
* Scan active initiatives for anything actionable. Empty items[] means
|
|
645
|
+
* "quiet day, don't spam the user."
|
|
173
646
|
*/
|
|
174
647
|
digest(now = new Date()) {
|
|
648
|
+
if (this.isTaskFlowEnabled())
|
|
649
|
+
this.refreshCacheFromTaskFlow();
|
|
175
650
|
const items = [];
|
|
176
651
|
const nowMs = now.getTime();
|
|
177
652
|
for (const initiative of this.initiatives.values()) {
|
|
@@ -198,10 +673,6 @@ export class InitiativeTracker {
|
|
|
198
673
|
continue;
|
|
199
674
|
}
|
|
200
675
|
}
|
|
201
|
-
// Ready-to-advance: the previous phase is done AND the current
|
|
202
|
-
// phase is still untouched ('pending'). Once the current phase
|
|
203
|
-
// is 'in-progress' or 'blocked', the advance has already begun,
|
|
204
|
-
// so we stop emitting this signal.
|
|
205
676
|
const current = initiative.phases[initiative.currentPhaseIndex];
|
|
206
677
|
const previous = initiative.currentPhaseIndex > 0
|
|
207
678
|
? initiative.phases[initiative.currentPhaseIndex - 1]
|