instar 0.28.84 → 0.28.86
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/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/server.d.ts.map +1 -1
- package/dist/commands/server.js +14 -0
- 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 +60 -0
- package/dist/core/EvolutionManager.d.ts.map +1 -1
- package/dist/core/EvolutionManager.js +118 -0
- package/dist/core/EvolutionManager.js.map +1 -1
- package/dist/core/InitiativeTracker.d.ts +256 -14
- package/dist/core/InitiativeTracker.d.ts.map +1 -1
- package/dist/core/InitiativeTracker.js +826 -34
- 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/types.d.ts +34 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +131 -14
- package/dist/server/routes.js.map +1 -1
- 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 +47 -47
- package/upgrades/0.28.85.md +64 -0
- package/upgrades/0.28.86.md +86 -0
- package/upgrades/side-effects/0.28.83.md +27 -0
- package/upgrades/side-effects/project-scope-phase1a-pr1.md +232 -0
- package/upgrades/side-effects/taskflow-phase4.md +377 -0
- package/upgrades/side-effects/threadline-discover-relay-and-trust.md +122 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase2.md +148 -0
- package/upgrades/side-effects/wikiclaim-evidence-phase3.md +125 -0
|
@@ -5,68 +5,591 @@
|
|
|
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';
|
|
40
|
+
/**
|
|
41
|
+
* Thrown by `update()` when the caller supplied `ifMatch` and it didn't
|
|
42
|
+
* equal the current `version`. The HTTP layer (Phase 1.3) translates this
|
|
43
|
+
* to a 409 response with body `{ currentVersion }`.
|
|
44
|
+
*/
|
|
45
|
+
export class OccVersionMismatchError extends Error {
|
|
46
|
+
currentVersion;
|
|
47
|
+
constructor(message, currentVersion) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'OccVersionMismatchError';
|
|
50
|
+
this.currentVersion = currentVersion;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown by `update()` when the caller attempted to change `kind`. The
|
|
55
|
+
* `kind` field is set at creation time and never changes thereafter.
|
|
56
|
+
*/
|
|
57
|
+
export class KindImmutableError extends Error {
|
|
58
|
+
constructor(message = '`kind` is immutable after initiative creation') {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'KindImmutableError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Thrown by `update()` when setting `parentProjectId` and the named parent
|
|
65
|
+
* either doesn't exist, isn't a `kind: 'project'` initiative, or doesn't
|
|
66
|
+
* list this child in any of its `rounds[].itemIds`. Bidirectional check
|
|
67
|
+
* keeps the parent/child relationship internally consistent.
|
|
68
|
+
*/
|
|
69
|
+
export class InvalidParentProjectError extends Error {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'InvalidParentProjectError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
15
75
|
/**
|
|
16
76
|
* Staleness threshold for the digest scan (7 days without an update on an
|
|
17
77
|
* active initiative triggers a 'stale' flag).
|
|
18
78
|
*/
|
|
19
79
|
export const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
80
|
+
/**
|
|
81
|
+
* TaskFlow controller identity for InitiativeTracker (Phase 4 migration).
|
|
82
|
+
* Every initiative's flow is owned by this controllerId; the registry uses
|
|
83
|
+
* it for OCC scope checks. Single instance per server process.
|
|
84
|
+
*/
|
|
85
|
+
export const INITIATIVE_TASKFLOW_CONTROLLER_ID = 'InitiativeTracker';
|
|
86
|
+
/**
|
|
87
|
+
* JSON.stringify replacer that omits any property whose value is exactly
|
|
88
|
+
* `undefined`. Native JSON.stringify already drops undefined object values,
|
|
89
|
+
* but using an explicit replacer makes intent visible AND ensures we never
|
|
90
|
+
* accidentally serialize a `null` for an optional field — the runtime
|
|
91
|
+
* guards in `update()` already reject `null`, so this is belt-and-braces.
|
|
92
|
+
*
|
|
93
|
+
* Round-trip stability: a record loaded and saved without mutation
|
|
94
|
+
* produces byte-identical output (asserted by the unit tests).
|
|
95
|
+
*/
|
|
96
|
+
function omitUndefinedReplacer(_key, value) {
|
|
97
|
+
return value === undefined ? undefined : value;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Reject `null` for any of the project-scope optional fields when supplied
|
|
101
|
+
* via `InitiativeCreateInput`. (Update inputs are field-by-field; null
|
|
102
|
+
* means "clear" for nullable fields and "reject" for the rest, enforced
|
|
103
|
+
* inline in `update()`.)
|
|
104
|
+
*/
|
|
105
|
+
function rejectNullCreateInput(input) {
|
|
106
|
+
const forbiddenNullKeys = [
|
|
107
|
+
'kind',
|
|
108
|
+
'parentProjectId',
|
|
109
|
+
'pipelineStage',
|
|
110
|
+
'specPath',
|
|
111
|
+
'prNumber',
|
|
112
|
+
'mergeCommitOid',
|
|
113
|
+
'ciCheckedAt',
|
|
114
|
+
'driftCheck',
|
|
115
|
+
'rounds',
|
|
116
|
+
'sourceDocs',
|
|
117
|
+
'autoAdvance',
|
|
118
|
+
'telegramTopicId',
|
|
119
|
+
'ownerMachineId',
|
|
120
|
+
'targetRepoPath',
|
|
121
|
+
'driftPromptTemplateVersion',
|
|
122
|
+
];
|
|
123
|
+
for (const k of forbiddenNullKeys) {
|
|
124
|
+
if (input[k] === null) {
|
|
125
|
+
throw new Error(`Initiative create input field "${k}" must not be null`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function ownerKeyForInitiative(initiativeId) {
|
|
130
|
+
return `initiative:${initiativeId}`;
|
|
131
|
+
}
|
|
132
|
+
function idempotencyKeyForInitiative(initiativeId) {
|
|
133
|
+
return `initiative:${initiativeId}`;
|
|
134
|
+
}
|
|
20
135
|
export class InitiativeTracker {
|
|
21
136
|
filePath;
|
|
137
|
+
/** In-process cache. Authoritative when TaskFlow is not wired; otherwise
|
|
138
|
+
* a read-side projection of TaskFlow's stateJson. */
|
|
22
139
|
initiatives = new Map();
|
|
140
|
+
// ── TaskFlow Phase 4 wiring ────────────────────────────────────
|
|
141
|
+
taskFlowRegistry = null;
|
|
142
|
+
taskFlowControllerInstanceId = null;
|
|
143
|
+
// ── Digest cache invalidator hook (Phase 1.1) ──────────────────
|
|
144
|
+
// PR 3 wires the real invalidator (clears the project-scope digest cache
|
|
145
|
+
// so the next read recomputes). Until then this is a no-op. The hook
|
|
146
|
+
// fires after every successful mutating call.
|
|
147
|
+
digestCacheInvalidator = () => { };
|
|
23
148
|
constructor(stateDir) {
|
|
24
149
|
this.filePath = path.join(stateDir, 'initiatives.json');
|
|
25
|
-
this.
|
|
150
|
+
this.loadFromDisk();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Register a callback invoked once after every successful mutating call
|
|
154
|
+
* (`create`, `update`, `setPhaseStatus`, `remove`). The default is a
|
|
155
|
+
* no-op so legacy callers see no change. PR 3 wires the real cache
|
|
156
|
+
* invalidator.
|
|
157
|
+
*/
|
|
158
|
+
setDigestCacheInvalidator(fn) {
|
|
159
|
+
this.digestCacheInvalidator = typeof fn === 'function' ? fn : () => { };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Wire a TaskFlow registry to make TaskFlow the source of truth.
|
|
163
|
+
* Idempotent — safe to call multiple times. After wiring, all reads come
|
|
164
|
+
* from TaskFlow and all writes go through TaskFlow APIs. The legacy JSON
|
|
165
|
+
* file is no longer written.
|
|
166
|
+
*
|
|
167
|
+
* Callers are expected to call `migrateExistingToTaskFlow()` after this
|
|
168
|
+
* to backfill any initiatives that were loaded from the legacy file.
|
|
169
|
+
*/
|
|
170
|
+
setTaskFlowRegistry(registry, controllerInstanceId) {
|
|
171
|
+
this.taskFlowRegistry = registry;
|
|
172
|
+
this.taskFlowControllerInstanceId = controllerInstanceId;
|
|
173
|
+
// Layer existing TaskFlow records over legacy-loaded data without
|
|
174
|
+
// clearing the cache: any legacy initiatives still need to be backfilled
|
|
175
|
+
// via `migrateExistingToTaskFlow()`. TaskFlow records win on collision.
|
|
176
|
+
this.layerCacheFromTaskFlow();
|
|
177
|
+
}
|
|
178
|
+
/** True when TaskFlow is wired and authoritative. */
|
|
179
|
+
isTaskFlowEnabled() {
|
|
180
|
+
return this.taskFlowRegistry !== null && this.taskFlowControllerInstanceId !== null;
|
|
26
181
|
}
|
|
27
|
-
|
|
182
|
+
taskFlowPrincipal() {
|
|
183
|
+
if (!this.taskFlowRegistry || !this.taskFlowControllerInstanceId)
|
|
184
|
+
return null;
|
|
185
|
+
return {
|
|
186
|
+
scope: 'controller',
|
|
187
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
188
|
+
controllerInstanceId: this.taskFlowControllerInstanceId,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ── Legacy JSON storage (used until TaskFlow is wired) ─────────
|
|
192
|
+
loadFromDisk() {
|
|
28
193
|
try {
|
|
29
194
|
if (!fs.existsSync(this.filePath))
|
|
30
195
|
return;
|
|
31
196
|
const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
|
|
32
197
|
if (Array.isArray(raw?.initiatives)) {
|
|
198
|
+
let backfilled = 0;
|
|
33
199
|
for (const item of raw.initiatives) {
|
|
34
200
|
if (item && typeof item.id === 'string') {
|
|
201
|
+
// Phase 1.1 idempotent backfill: legacy records (missing `kind`)
|
|
202
|
+
// get `kind: 'task'` + `schemaVersion: 1`. Records that already
|
|
203
|
+
// carry `kind` are untouched. Second load is a no-op because
|
|
204
|
+
// nothing changes after the first pass.
|
|
205
|
+
if (item.kind === undefined) {
|
|
206
|
+
item.kind = 'task';
|
|
207
|
+
if (item.schemaVersion === undefined)
|
|
208
|
+
item.schemaVersion = 1;
|
|
209
|
+
backfilled++;
|
|
210
|
+
}
|
|
35
211
|
this.initiatives.set(item.id, item);
|
|
36
212
|
}
|
|
37
213
|
}
|
|
214
|
+
// Rewrite the file exactly once if any record was backfilled. The
|
|
215
|
+
// rewrite uses the same omit-undefined replacer as `saveToDisk()`
|
|
216
|
+
// so the file is stable on a subsequent load.
|
|
217
|
+
if (backfilled > 0) {
|
|
218
|
+
this.saveToDisk();
|
|
219
|
+
}
|
|
38
220
|
}
|
|
39
221
|
}
|
|
40
222
|
catch (err) {
|
|
41
223
|
console.error(`[initiatives] Failed to load: ${err instanceof Error ? err.message : err}`);
|
|
42
224
|
}
|
|
43
225
|
}
|
|
44
|
-
|
|
226
|
+
saveToDisk() {
|
|
227
|
+
if (this.isTaskFlowEnabled())
|
|
228
|
+
return; // TaskFlow's SQLite is the durable store.
|
|
45
229
|
const dir = path.dirname(this.filePath);
|
|
46
230
|
fs.mkdirSync(dir, { recursive: true });
|
|
47
231
|
const payload = { initiatives: Array.from(this.initiatives.values()) };
|
|
48
232
|
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
49
|
-
fs.writeFileSync(tmp, JSON.stringify(payload,
|
|
233
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, omitUndefinedReplacer, 2));
|
|
50
234
|
fs.renameSync(tmp, this.filePath);
|
|
51
235
|
}
|
|
236
|
+
// ── TaskFlow ↔ Initiative serialization ─────────────────────────
|
|
237
|
+
initiativeFromFlow(flow) {
|
|
238
|
+
const state = flow.stateJson;
|
|
239
|
+
if (!state || typeof state !== 'object' || !state.initiative)
|
|
240
|
+
return null;
|
|
241
|
+
return state.initiative;
|
|
242
|
+
}
|
|
243
|
+
refreshCacheFromTaskFlow() {
|
|
244
|
+
if (!this.taskFlowRegistry)
|
|
245
|
+
return;
|
|
246
|
+
const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
|
|
247
|
+
this.initiatives.clear();
|
|
248
|
+
for (const f of flows) {
|
|
249
|
+
const init = this.initiativeFromFlow(f);
|
|
250
|
+
// Tombstone-removed initiatives are filtered out so callers can't
|
|
251
|
+
// see them via list() / get() after remove().
|
|
252
|
+
if (init && !this.isTombstoned(f)) {
|
|
253
|
+
this.initiatives.set(init.id, init);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Layer TaskFlow data on top of the existing cache without clearing it.
|
|
259
|
+
* Used during `setTaskFlowRegistry` so legacy-loaded initiatives are
|
|
260
|
+
* preserved as backfill candidates while any pre-existing TaskFlow
|
|
261
|
+
* records take precedence.
|
|
262
|
+
*/
|
|
263
|
+
layerCacheFromTaskFlow() {
|
|
264
|
+
if (!this.taskFlowRegistry)
|
|
265
|
+
return;
|
|
266
|
+
const flows = this.taskFlowRegistry.findByControllerId(INITIATIVE_TASKFLOW_CONTROLLER_ID);
|
|
267
|
+
for (const f of flows) {
|
|
268
|
+
const init = this.initiativeFromFlow(f);
|
|
269
|
+
if (init && !this.isTombstoned(f)) {
|
|
270
|
+
this.initiatives.set(init.id, init);
|
|
271
|
+
}
|
|
272
|
+
else if (this.isTombstoned(f) && init) {
|
|
273
|
+
// Tombstoned flows shadow legacy entries.
|
|
274
|
+
this.initiatives.delete(init.id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* A flow is "tombstoned" when remove() marked it as removed before
|
|
280
|
+
* cancelling. The marker lives in stateJson._removed and is the
|
|
281
|
+
* single signal that a flow's owning Initiative should be hidden from
|
|
282
|
+
* list() / get(). We can't delete TaskFlow records from this layer, so
|
|
283
|
+
* the marker is the durable equivalent of deletion.
|
|
284
|
+
*/
|
|
285
|
+
isTombstoned(flow) {
|
|
286
|
+
const state = flow.stateJson;
|
|
287
|
+
return state?._removed === true;
|
|
288
|
+
}
|
|
289
|
+
waitJsonForInitiative(init) {
|
|
290
|
+
const blocked = init.needsUser || init.blockers.length > 0;
|
|
291
|
+
if (!blocked)
|
|
292
|
+
return null;
|
|
293
|
+
const reason = init.needsUser
|
|
294
|
+
? init.needsUserReason ?? 'Initiative needs user decision'
|
|
295
|
+
: init.blockers.join('; ');
|
|
296
|
+
const question = (reason || 'Initiative blocked').slice(0, 2000);
|
|
297
|
+
return { kind: 'human-review', question };
|
|
298
|
+
}
|
|
299
|
+
/** Determine the desired TaskFlow status for an Initiative. */
|
|
300
|
+
desiredFlowStatus(init) {
|
|
301
|
+
const phase = init.phases[init.currentPhaseIndex];
|
|
302
|
+
const step = phase ? phase.id : 'unknown';
|
|
303
|
+
if (init.status === 'completed')
|
|
304
|
+
return { kind: 'succeeded', step };
|
|
305
|
+
if (init.status === 'abandoned')
|
|
306
|
+
return { kind: 'failed', step };
|
|
307
|
+
if (init.status === 'archived')
|
|
308
|
+
return { kind: 'cancelled', step };
|
|
309
|
+
const wait = this.waitJsonForInitiative(init);
|
|
310
|
+
if (wait)
|
|
311
|
+
return { kind: 'waiting', step, waitJson: wait };
|
|
312
|
+
return { kind: 'running', step };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Persist an Initiative through TaskFlow. Walks the flow state machine
|
|
316
|
+
* to the desired target (running / waiting / succeeded / failed /
|
|
317
|
+
* cancelled), updating `stateJson` along the way so reads see the latest
|
|
318
|
+
* shape. Returns the Initiative as projected from TaskFlow after
|
|
319
|
+
* transitions settle.
|
|
320
|
+
*/
|
|
321
|
+
async persistThroughTaskFlow(init) {
|
|
322
|
+
const registry = this.taskFlowRegistry;
|
|
323
|
+
const principal = this.taskFlowPrincipal();
|
|
324
|
+
if (!registry || !principal || principal.scope !== 'controller')
|
|
325
|
+
return init;
|
|
326
|
+
const ownerKey = ownerKeyForInitiative(init.id);
|
|
327
|
+
const idemKey = idempotencyKeyForInitiative(init.id);
|
|
328
|
+
const desired = this.desiredFlowStatus(init);
|
|
329
|
+
const stateJson = { initiative: init };
|
|
330
|
+
let existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
|
|
331
|
+
if (!existing) {
|
|
332
|
+
const r = await registry.createFlow({
|
|
333
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
334
|
+
controllerInstanceId: principal.controllerInstanceId,
|
|
335
|
+
ownerKey,
|
|
336
|
+
idempotencyKey: idemKey,
|
|
337
|
+
goal: init.title.slice(0, 1024),
|
|
338
|
+
currentStep: desired.step,
|
|
339
|
+
stateJson,
|
|
340
|
+
});
|
|
341
|
+
existing = r.flow;
|
|
342
|
+
}
|
|
343
|
+
let cur = registry.getFlow(existing.flowId, { bypassCache: true }) ?? existing;
|
|
344
|
+
try {
|
|
345
|
+
// Already terminal? Accept and stop — terminal flows are immutable
|
|
346
|
+
// per TaskFlow contract. The Initiative's local terminal state stays
|
|
347
|
+
// in sync via the cache, but we never re-mutate the flow.
|
|
348
|
+
if (cur.status === 'succeeded' ||
|
|
349
|
+
cur.status === 'failed' ||
|
|
350
|
+
cur.status === 'cancelled' ||
|
|
351
|
+
cur.status === 'lost') {
|
|
352
|
+
return this.initiativeFromFlow(cur) ?? init;
|
|
353
|
+
}
|
|
354
|
+
// queued → running (always; a fresh create lands in queued).
|
|
355
|
+
if (cur.status === 'queued') {
|
|
356
|
+
const r = await registry.startStep({
|
|
357
|
+
flowId: cur.flowId,
|
|
358
|
+
expectedRevision: cur.revision,
|
|
359
|
+
principal,
|
|
360
|
+
currentStep: desired.step,
|
|
361
|
+
});
|
|
362
|
+
cur = r.flow;
|
|
363
|
+
}
|
|
364
|
+
// waiting → running (resume), in case desired wait is different or
|
|
365
|
+
// we're moving to running/terminal. resumeFlow accepts statePatch so
|
|
366
|
+
// we update stateJson here too.
|
|
367
|
+
if (cur.status === 'waiting') {
|
|
368
|
+
if (cur.waitInstanceId) {
|
|
369
|
+
const r = await registry.resumeFlow({
|
|
370
|
+
flowId: cur.flowId,
|
|
371
|
+
expectedRevision: cur.revision,
|
|
372
|
+
principal,
|
|
373
|
+
waitInstanceId: cur.waitInstanceId,
|
|
374
|
+
currentStep: desired.step,
|
|
375
|
+
statePatch: stateJson,
|
|
376
|
+
});
|
|
377
|
+
cur = r.flow;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Now cur.status === 'running'. Drive to the desired terminal state.
|
|
381
|
+
if (cur.status === 'running') {
|
|
382
|
+
if (desired.kind === 'running') {
|
|
383
|
+
// Re-startStep when currentStep changed; this updates the step.
|
|
384
|
+
if (cur.currentStep !== desired.step) {
|
|
385
|
+
const r = await registry.startStep({
|
|
386
|
+
flowId: cur.flowId,
|
|
387
|
+
expectedRevision: cur.revision,
|
|
388
|
+
principal,
|
|
389
|
+
currentStep: desired.step,
|
|
390
|
+
});
|
|
391
|
+
cur = r.flow;
|
|
392
|
+
}
|
|
393
|
+
// Always patch stateJson to reflect the latest Initiative shape.
|
|
394
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
395
|
+
}
|
|
396
|
+
else if (desired.kind === 'waiting') {
|
|
397
|
+
const r = await registry.setFlowWaiting({
|
|
398
|
+
flowId: cur.flowId,
|
|
399
|
+
expectedRevision: cur.revision,
|
|
400
|
+
principal,
|
|
401
|
+
waitJson: desired.waitJson,
|
|
402
|
+
currentStep: desired.step,
|
|
403
|
+
statePatch: stateJson,
|
|
404
|
+
});
|
|
405
|
+
cur = r.flow;
|
|
406
|
+
}
|
|
407
|
+
else if (desired.kind === 'succeeded') {
|
|
408
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
409
|
+
const r = await registry.finishFlow({
|
|
410
|
+
flowId: cur.flowId,
|
|
411
|
+
expectedRevision: cur.revision,
|
|
412
|
+
principal,
|
|
413
|
+
});
|
|
414
|
+
cur = r.flow;
|
|
415
|
+
}
|
|
416
|
+
else if (desired.kind === 'failed') {
|
|
417
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
418
|
+
const r = await registry.failFlow({
|
|
419
|
+
flowId: cur.flowId,
|
|
420
|
+
expectedRevision: cur.revision,
|
|
421
|
+
principal,
|
|
422
|
+
failureReason: 'abandoned',
|
|
423
|
+
});
|
|
424
|
+
cur = r.flow;
|
|
425
|
+
}
|
|
426
|
+
else if (desired.kind === 'cancelled') {
|
|
427
|
+
cur = await this.patchStateJson(cur, stateJson);
|
|
428
|
+
const cr = await registry.requestFlowCancel({
|
|
429
|
+
flowId: cur.flowId,
|
|
430
|
+
expectedRevision: cur.revision,
|
|
431
|
+
requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
|
|
432
|
+
});
|
|
433
|
+
const r = await registry.cancelFlow({
|
|
434
|
+
flowId: cur.flowId,
|
|
435
|
+
expectedRevision: cr.flow.revision,
|
|
436
|
+
principal,
|
|
437
|
+
});
|
|
438
|
+
cur = r.flow;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
this.logTaskFlowError('persist', init.id, err);
|
|
444
|
+
}
|
|
445
|
+
const fresh = registry.getFlow(cur.flowId, { bypassCache: true }) ?? cur;
|
|
446
|
+
const persisted = this.initiativeFromFlow(fresh) ?? init;
|
|
447
|
+
this.initiatives.set(persisted.id, persisted);
|
|
448
|
+
return persisted;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Patch a running flow's stateJson without changing its observable
|
|
452
|
+
* status. Uses a brief setFlowWaiting → resumeFlow round-trip (the only
|
|
453
|
+
* atomic state-bearing transitions for `running` flows that accept
|
|
454
|
+
* `statePatch`). The wait kind `'human-review'` with a sentinel question
|
|
455
|
+
* is used; the wait is consumed in the same call, so no external
|
|
456
|
+
* observer sees `waiting`.
|
|
457
|
+
*/
|
|
458
|
+
async patchStateJson(flow, stateJson) {
|
|
459
|
+
const registry = this.taskFlowRegistry;
|
|
460
|
+
const principal = this.taskFlowPrincipal();
|
|
461
|
+
if (!registry || !principal || principal.scope !== 'controller')
|
|
462
|
+
return flow;
|
|
463
|
+
if (flow.status !== 'running')
|
|
464
|
+
return flow;
|
|
465
|
+
try {
|
|
466
|
+
const w = await registry.setFlowWaiting({
|
|
467
|
+
flowId: flow.flowId,
|
|
468
|
+
expectedRevision: flow.revision,
|
|
469
|
+
principal,
|
|
470
|
+
waitJson: { kind: 'human-review', question: '__statePatch__' },
|
|
471
|
+
statePatch: stateJson,
|
|
472
|
+
});
|
|
473
|
+
const r = await registry.resumeFlow({
|
|
474
|
+
flowId: w.flow.flowId,
|
|
475
|
+
expectedRevision: w.flow.revision,
|
|
476
|
+
principal,
|
|
477
|
+
waitInstanceId: w.flow.waitInstanceId,
|
|
478
|
+
statePatch: stateJson,
|
|
479
|
+
});
|
|
480
|
+
return r.flow;
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
this.logTaskFlowError('patchStateJson', flow.flowId, err);
|
|
484
|
+
return flow;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
findFlowIdForInitiative(id) {
|
|
488
|
+
const registry = this.taskFlowRegistry;
|
|
489
|
+
if (!registry)
|
|
490
|
+
return null;
|
|
491
|
+
const f = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKeyForInitiative(id), idempotencyKeyForInitiative(id));
|
|
492
|
+
return f?.flowId ?? null;
|
|
493
|
+
}
|
|
494
|
+
logTaskFlowError(op, key, err) {
|
|
495
|
+
if (err instanceof TaskFlowError) {
|
|
496
|
+
console.warn(`[InitiativeTracker] taskflow ${op} for ${key} skipped: ${err.code} (${err.message})`);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
console.warn(`[InitiativeTracker] taskflow ${op} for ${key} unexpected error:`, err instanceof Error ? err.message : err);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Backfill all initiatives currently in the in-memory cache (loaded from
|
|
504
|
+
* legacy JSON in the constructor) into TaskFlow. Idempotent via
|
|
505
|
+
* `findIdempotent`. Safe to call multiple times.
|
|
506
|
+
*/
|
|
507
|
+
async migrateExistingToTaskFlow() {
|
|
508
|
+
const registry = this.taskFlowRegistry;
|
|
509
|
+
const principal = this.taskFlowPrincipal();
|
|
510
|
+
if (!registry || !principal || principal.scope !== 'controller') {
|
|
511
|
+
return { created: 0, alreadyExisted: 0, advanced: 0, skipped: 0 };
|
|
512
|
+
}
|
|
513
|
+
let created = 0;
|
|
514
|
+
let alreadyExisted = 0;
|
|
515
|
+
let advanced = 0;
|
|
516
|
+
let skipped = 0;
|
|
517
|
+
const candidates = Array.from(this.initiatives.values());
|
|
518
|
+
for (const init of candidates) {
|
|
519
|
+
try {
|
|
520
|
+
const ownerKey = ownerKeyForInitiative(init.id);
|
|
521
|
+
const idemKey = idempotencyKeyForInitiative(init.id);
|
|
522
|
+
const existing = registry.findByIdempotency(INITIATIVE_TASKFLOW_CONTROLLER_ID, ownerKey, idemKey);
|
|
523
|
+
if (existing) {
|
|
524
|
+
alreadyExisted++;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
await registry.createFlow({
|
|
528
|
+
controllerId: INITIATIVE_TASKFLOW_CONTROLLER_ID,
|
|
529
|
+
controllerInstanceId: principal.controllerInstanceId,
|
|
530
|
+
ownerKey,
|
|
531
|
+
idempotencyKey: idemKey,
|
|
532
|
+
goal: init.title.slice(0, 1024),
|
|
533
|
+
currentStep: init.phases[init.currentPhaseIndex]?.id ?? 'start',
|
|
534
|
+
stateJson: { initiative: init },
|
|
535
|
+
});
|
|
536
|
+
created++;
|
|
537
|
+
}
|
|
538
|
+
const persisted = await this.persistThroughTaskFlow(init);
|
|
539
|
+
if (persisted)
|
|
540
|
+
advanced++;
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
skipped++;
|
|
544
|
+
this.logTaskFlowError('migrate', init.id, err);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
this.refreshCacheFromTaskFlow();
|
|
548
|
+
return { created, alreadyExisted, advanced, skipped };
|
|
549
|
+
}
|
|
550
|
+
// ── Public API ─────────────────────────────────────────────────
|
|
52
551
|
list(filter) {
|
|
552
|
+
if (this.isTaskFlowEnabled())
|
|
553
|
+
this.refreshCacheFromTaskFlow();
|
|
53
554
|
const all = Array.from(this.initiatives.values());
|
|
54
555
|
const filtered = filter?.status ? all.filter((i) => i.status === filter.status) : all;
|
|
55
556
|
return filtered.sort((a, b) => b.lastTouchedAt.localeCompare(a.lastTouchedAt));
|
|
56
557
|
}
|
|
57
558
|
get(id) {
|
|
559
|
+
if (this.isTaskFlowEnabled()) {
|
|
560
|
+
const fid = this.findFlowIdForInitiative(id);
|
|
561
|
+
if (!fid)
|
|
562
|
+
return undefined;
|
|
563
|
+
const f = this.taskFlowRegistry.getFlow(fid, { bypassCache: true });
|
|
564
|
+
if (!f)
|
|
565
|
+
return undefined;
|
|
566
|
+
if (this.isTombstoned(f)) {
|
|
567
|
+
this.initiatives.delete(id);
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
const init = this.initiativeFromFlow(f);
|
|
571
|
+
if (init)
|
|
572
|
+
this.initiatives.set(id, init);
|
|
573
|
+
return init ?? undefined;
|
|
574
|
+
}
|
|
58
575
|
return this.initiatives.get(id);
|
|
59
576
|
}
|
|
60
|
-
create(input) {
|
|
61
|
-
if (this.initiatives.has(input.id)) {
|
|
62
|
-
throw new Error(`Initiative "${input.id}" already exists`);
|
|
63
|
-
}
|
|
577
|
+
async create(input) {
|
|
64
578
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.id)) {
|
|
65
579
|
throw new Error('Initiative id must be lowercase kebab-case, 1–63 chars');
|
|
66
580
|
}
|
|
67
581
|
if (!input.phases.length) {
|
|
68
582
|
throw new Error('Initiative must have at least one phase');
|
|
69
583
|
}
|
|
584
|
+
rejectNullCreateInput(input);
|
|
585
|
+
if (this.isTaskFlowEnabled()) {
|
|
586
|
+
if (this.findFlowIdForInitiative(input.id)) {
|
|
587
|
+
throw new Error(`Initiative "${input.id}" already exists`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else if (this.initiatives.has(input.id)) {
|
|
591
|
+
throw new Error(`Initiative "${input.id}" already exists`);
|
|
592
|
+
}
|
|
70
593
|
const now = new Date().toISOString();
|
|
71
594
|
const phases = input.phases.map((p) => ({
|
|
72
595
|
id: p.id,
|
|
@@ -74,7 +597,6 @@ export class InitiativeTracker {
|
|
|
74
597
|
summary: p.summary,
|
|
75
598
|
status: p.status ?? 'pending',
|
|
76
599
|
}));
|
|
77
|
-
// currentPhaseIndex: first non-'done' phase, else last phase.
|
|
78
600
|
const firstOpen = phases.findIndex((p) => p.status !== 'done');
|
|
79
601
|
const currentPhaseIndex = firstOpen === -1 ? phases.length - 1 : firstOpen;
|
|
80
602
|
const allDone = phases.every((p) => p.status === 'done');
|
|
@@ -93,15 +615,76 @@ export class InitiativeTracker {
|
|
|
93
615
|
links: input.links ?? [],
|
|
94
616
|
createdAt: now,
|
|
95
617
|
updatedAt: now,
|
|
618
|
+
// Project-scope: default kind to 'task' and start version at 1. Both
|
|
619
|
+
// are persisted from create-time so subsequent backfill is a no-op.
|
|
620
|
+
kind: input.kind ?? 'task',
|
|
621
|
+
schemaVersion: 1,
|
|
622
|
+
version: 1,
|
|
96
623
|
};
|
|
624
|
+
// Optional project-layer fields are passed through if present. Stored
|
|
625
|
+
// as-is; runtime validation (parentProjectId existence, etc.) happens
|
|
626
|
+
// on subsequent updates that mutate the relationship.
|
|
627
|
+
if (input.parentProjectId !== undefined)
|
|
628
|
+
initiative.parentProjectId = input.parentProjectId;
|
|
629
|
+
if (input.pipelineStage !== undefined)
|
|
630
|
+
initiative.pipelineStage = input.pipelineStage;
|
|
631
|
+
if (input.specPath !== undefined)
|
|
632
|
+
initiative.specPath = input.specPath;
|
|
633
|
+
if (input.prNumber !== undefined)
|
|
634
|
+
initiative.prNumber = input.prNumber;
|
|
635
|
+
if (input.mergeCommitOid !== undefined)
|
|
636
|
+
initiative.mergeCommitOid = input.mergeCommitOid;
|
|
637
|
+
if (input.ciCheckedAt !== undefined)
|
|
638
|
+
initiative.ciCheckedAt = input.ciCheckedAt;
|
|
639
|
+
if (input.driftCheck !== undefined)
|
|
640
|
+
initiative.driftCheck = input.driftCheck;
|
|
641
|
+
if (input.rounds !== undefined)
|
|
642
|
+
initiative.rounds = input.rounds;
|
|
643
|
+
if (input.sourceDocs !== undefined)
|
|
644
|
+
initiative.sourceDocs = input.sourceDocs;
|
|
645
|
+
if (input.autoAdvance !== undefined)
|
|
646
|
+
initiative.autoAdvance = input.autoAdvance;
|
|
647
|
+
if (input.telegramTopicId !== undefined)
|
|
648
|
+
initiative.telegramTopicId = input.telegramTopicId;
|
|
649
|
+
if (input.ownerMachineId !== undefined)
|
|
650
|
+
initiative.ownerMachineId = input.ownerMachineId;
|
|
651
|
+
if (input.targetRepoPath !== undefined)
|
|
652
|
+
initiative.targetRepoPath = input.targetRepoPath;
|
|
653
|
+
if (input.driftPromptTemplateVersion !== undefined) {
|
|
654
|
+
initiative.driftPromptTemplateVersion = input.driftPromptTemplateVersion;
|
|
655
|
+
}
|
|
97
656
|
this.initiatives.set(initiative.id, initiative);
|
|
98
|
-
|
|
99
|
-
|
|
657
|
+
let result;
|
|
658
|
+
if (this.isTaskFlowEnabled()) {
|
|
659
|
+
result = await this.persistThroughTaskFlow(initiative);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
this.saveToDisk();
|
|
663
|
+
result = initiative;
|
|
664
|
+
}
|
|
665
|
+
this.digestCacheInvalidator();
|
|
666
|
+
return result;
|
|
100
667
|
}
|
|
101
|
-
update(id, input) {
|
|
102
|
-
const existing = this.
|
|
668
|
+
async update(id, input) {
|
|
669
|
+
const existing = this.get(id);
|
|
103
670
|
if (!existing)
|
|
104
671
|
throw new Error(`Initiative "${id}" not found`);
|
|
672
|
+
// ── kind immutability check (Phase 1.1) ────────────────────────
|
|
673
|
+
// The `kind` field is set at creation and never mutates. We treat the
|
|
674
|
+
// current kind as 'task' when undefined (legacy records pre-backfill).
|
|
675
|
+
if (input.kind !== undefined) {
|
|
676
|
+
const currentKind = existing.kind ?? 'task';
|
|
677
|
+
if (input.kind !== currentKind) {
|
|
678
|
+
throw new KindImmutableError(`Cannot change kind from "${currentKind}" to "${input.kind}"`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
// ── OCC version check (Phase 1.1) ──────────────────────────────
|
|
682
|
+
// Only enforce when caller supplied ifMatch — preserves backward
|
|
683
|
+
// compatibility for legacy callers that never knew about versioning.
|
|
684
|
+
const currentVersion = existing.version ?? 1;
|
|
685
|
+
if (input.ifMatch !== undefined && input.ifMatch !== currentVersion) {
|
|
686
|
+
throw new OccVersionMismatchError(`Initiative "${id}" version mismatch: expected ${input.ifMatch}, current ${currentVersion}`, currentVersion);
|
|
687
|
+
}
|
|
105
688
|
const now = new Date().toISOString();
|
|
106
689
|
const next = { ...existing, updatedAt: now, lastTouchedAt: now };
|
|
107
690
|
if (input.title !== undefined)
|
|
@@ -122,18 +705,113 @@ export class InitiativeTracker {
|
|
|
122
705
|
next.blockers = input.blockers;
|
|
123
706
|
if (input.links !== undefined)
|
|
124
707
|
next.links = input.links;
|
|
708
|
+
// ── Project-scope field updates ────────────────────────────────
|
|
709
|
+
// Bidirectional parentProjectId validation: when setting (not clearing),
|
|
710
|
+
// the named parent must exist as kind:'project' AND list this child in
|
|
711
|
+
// rounds[].itemIds. Clearing (null) skips validation.
|
|
712
|
+
if (input.parentProjectId !== undefined) {
|
|
713
|
+
if (input.parentProjectId === null) {
|
|
714
|
+
next.parentProjectId = undefined;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
this.assertValidParentProject(input.parentProjectId, id);
|
|
718
|
+
next.parentProjectId = input.parentProjectId;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (input.pipelineStage !== undefined)
|
|
722
|
+
next.pipelineStage = input.pipelineStage;
|
|
723
|
+
if (input.specPath !== undefined) {
|
|
724
|
+
next.specPath = input.specPath === null ? undefined : input.specPath;
|
|
725
|
+
}
|
|
726
|
+
if (input.prNumber !== undefined) {
|
|
727
|
+
next.prNumber = input.prNumber === null ? undefined : input.prNumber;
|
|
728
|
+
}
|
|
729
|
+
if (input.mergeCommitOid !== undefined) {
|
|
730
|
+
next.mergeCommitOid = input.mergeCommitOid === null ? undefined : input.mergeCommitOid;
|
|
731
|
+
}
|
|
732
|
+
if (input.ciCheckedAt !== undefined) {
|
|
733
|
+
next.ciCheckedAt = input.ciCheckedAt === null ? undefined : input.ciCheckedAt;
|
|
734
|
+
}
|
|
735
|
+
if (input.skippedAt !== undefined) {
|
|
736
|
+
next.skippedAt = input.skippedAt === null ? undefined : input.skippedAt;
|
|
737
|
+
}
|
|
738
|
+
if (input.skippedBy !== undefined) {
|
|
739
|
+
next.skippedBy = input.skippedBy === null ? undefined : input.skippedBy;
|
|
740
|
+
}
|
|
741
|
+
if (input.skippedReason !== undefined) {
|
|
742
|
+
next.skippedReason = input.skippedReason === null ? undefined : input.skippedReason;
|
|
743
|
+
}
|
|
744
|
+
if (input.unskippedAt !== undefined) {
|
|
745
|
+
next.unskippedAt = input.unskippedAt === null ? undefined : input.unskippedAt;
|
|
746
|
+
}
|
|
747
|
+
if (input.driftCheck !== undefined)
|
|
748
|
+
next.driftCheck = input.driftCheck;
|
|
749
|
+
if (input.rounds !== undefined)
|
|
750
|
+
next.rounds = input.rounds;
|
|
751
|
+
if (input.sourceDocs !== undefined)
|
|
752
|
+
next.sourceDocs = input.sourceDocs;
|
|
753
|
+
if (input.autoAdvance !== undefined)
|
|
754
|
+
next.autoAdvance = input.autoAdvance;
|
|
755
|
+
if (input.telegramTopicId !== undefined) {
|
|
756
|
+
next.telegramTopicId = input.telegramTopicId === null ? undefined : input.telegramTopicId;
|
|
757
|
+
}
|
|
758
|
+
if (input.ownerMachineId !== undefined) {
|
|
759
|
+
next.ownerMachineId = input.ownerMachineId === null ? undefined : input.ownerMachineId;
|
|
760
|
+
}
|
|
761
|
+
if (input.targetRepoPath !== undefined)
|
|
762
|
+
next.targetRepoPath = input.targetRepoPath;
|
|
763
|
+
if (input.unacknowledgedAdvanceCount !== undefined) {
|
|
764
|
+
next.unacknowledgedAdvanceCount = input.unacknowledgedAdvanceCount;
|
|
765
|
+
}
|
|
766
|
+
if (input.firstLaunchAckAt !== undefined) {
|
|
767
|
+
next.firstLaunchAckAt =
|
|
768
|
+
input.firstLaunchAckAt === null ? undefined : input.firstLaunchAckAt;
|
|
769
|
+
}
|
|
770
|
+
if (input.lastAckedRoundIndex !== undefined) {
|
|
771
|
+
next.lastAckedRoundIndex = input.lastAckedRoundIndex;
|
|
772
|
+
}
|
|
773
|
+
if (input.awaitingReconciliation !== undefined) {
|
|
774
|
+
next.awaitingReconciliation = input.awaitingReconciliation;
|
|
775
|
+
}
|
|
776
|
+
if (input.driftPromptTemplateVersion !== undefined) {
|
|
777
|
+
next.driftPromptTemplateVersion = input.driftPromptTemplateVersion;
|
|
778
|
+
}
|
|
779
|
+
// Bump version on every successful write.
|
|
780
|
+
next.version = currentVersion + 1;
|
|
125
781
|
this.initiatives.set(id, next);
|
|
126
|
-
|
|
127
|
-
|
|
782
|
+
let result;
|
|
783
|
+
if (this.isTaskFlowEnabled()) {
|
|
784
|
+
result = await this.persistThroughTaskFlow(next);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
this.saveToDisk();
|
|
788
|
+
result = next;
|
|
789
|
+
}
|
|
790
|
+
this.digestCacheInvalidator();
|
|
791
|
+
return result;
|
|
128
792
|
}
|
|
129
793
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
794
|
+
* Verify that `parentId` names an existing `kind: 'project'` initiative
|
|
795
|
+
* AND that one of its rounds lists `childId` in itemIds. Throws
|
|
796
|
+
* `InvalidParentProjectError` on any failure. Used by `update()` when
|
|
797
|
+
* a child's `parentProjectId` is set to a non-null value.
|
|
134
798
|
*/
|
|
135
|
-
|
|
136
|
-
const
|
|
799
|
+
assertValidParentProject(parentId, childId) {
|
|
800
|
+
const parent = this.get(parentId);
|
|
801
|
+
if (!parent) {
|
|
802
|
+
throw new InvalidParentProjectError(`Parent project "${parentId}" not found`);
|
|
803
|
+
}
|
|
804
|
+
if ((parent.kind ?? 'task') !== 'project') {
|
|
805
|
+
throw new InvalidParentProjectError(`Initiative "${parentId}" is not a project (kind="${parent.kind ?? 'task'}")`);
|
|
806
|
+
}
|
|
807
|
+
const rounds = parent.rounds ?? [];
|
|
808
|
+
const listed = rounds.some((r) => Array.isArray(r.itemIds) && r.itemIds.includes(childId));
|
|
809
|
+
if (!listed) {
|
|
810
|
+
throw new InvalidParentProjectError(`Project "${parentId}" does not list child "${childId}" in any round`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async setPhaseStatus(id, phaseId, status) {
|
|
814
|
+
const existing = this.get(id);
|
|
137
815
|
if (!existing)
|
|
138
816
|
throw new Error(`Initiative "${id}" not found`);
|
|
139
817
|
const phases = existing.phases.map((p) => ({ ...p }));
|
|
@@ -155,23 +833,141 @@ export class InitiativeTracker {
|
|
|
155
833
|
status: allDone ? 'completed' : existing.status === 'completed' ? 'active' : existing.status,
|
|
156
834
|
updatedAt: now,
|
|
157
835
|
lastTouchedAt: now,
|
|
836
|
+
version: (existing.version ?? 1) + 1,
|
|
158
837
|
};
|
|
159
838
|
this.initiatives.set(id, next);
|
|
160
|
-
|
|
161
|
-
|
|
839
|
+
let result;
|
|
840
|
+
if (this.isTaskFlowEnabled()) {
|
|
841
|
+
result = await this.persistThroughTaskFlow(next);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
this.saveToDisk();
|
|
845
|
+
result = next;
|
|
846
|
+
}
|
|
847
|
+
this.digestCacheInvalidator();
|
|
848
|
+
return result;
|
|
162
849
|
}
|
|
163
|
-
remove(id) {
|
|
850
|
+
async remove(id) {
|
|
851
|
+
if (this.isTaskFlowEnabled()) {
|
|
852
|
+
const fid = this.findFlowIdForInitiative(id);
|
|
853
|
+
if (!fid) {
|
|
854
|
+
const removed = this.initiatives.delete(id);
|
|
855
|
+
if (removed)
|
|
856
|
+
this.digestCacheInvalidator();
|
|
857
|
+
return removed;
|
|
858
|
+
}
|
|
859
|
+
const registry = this.taskFlowRegistry;
|
|
860
|
+
const principal = this.taskFlowPrincipal();
|
|
861
|
+
let flow = registry.getFlow(fid, { bypassCache: true });
|
|
862
|
+
if (flow && principal && principal.scope === 'controller') {
|
|
863
|
+
try {
|
|
864
|
+
if (flow.status === 'queued') {
|
|
865
|
+
const r = await registry.startStep({
|
|
866
|
+
flowId: flow.flowId,
|
|
867
|
+
expectedRevision: flow.revision,
|
|
868
|
+
principal,
|
|
869
|
+
currentStep: flow.currentStep ?? 'remove',
|
|
870
|
+
});
|
|
871
|
+
flow = r.flow;
|
|
872
|
+
}
|
|
873
|
+
if (flow.status === 'waiting' && flow.waitInstanceId) {
|
|
874
|
+
const r = await registry.resumeFlow({
|
|
875
|
+
flowId: flow.flowId,
|
|
876
|
+
expectedRevision: flow.revision,
|
|
877
|
+
principal,
|
|
878
|
+
waitInstanceId: flow.waitInstanceId,
|
|
879
|
+
});
|
|
880
|
+
flow = r.flow;
|
|
881
|
+
}
|
|
882
|
+
// Stamp the tombstone marker into stateJson so subsequent reads
|
|
883
|
+
// hide the initiative. Done before cancel because terminal flows
|
|
884
|
+
// are immutable.
|
|
885
|
+
const init = this.initiativeFromFlow(flow);
|
|
886
|
+
if (flow.status === 'running' && init) {
|
|
887
|
+
const tomb = await this.patchStateJson(flow, { initiative: init, _removed: true });
|
|
888
|
+
flow = tomb;
|
|
889
|
+
}
|
|
890
|
+
if (flow.status !== 'succeeded' &&
|
|
891
|
+
flow.status !== 'failed' &&
|
|
892
|
+
flow.status !== 'cancelled' &&
|
|
893
|
+
flow.status !== 'lost') {
|
|
894
|
+
const cr = await registry.requestFlowCancel({
|
|
895
|
+
flowId: flow.flowId,
|
|
896
|
+
expectedRevision: flow.revision,
|
|
897
|
+
requesterOrigin: { kind: 'system', id: 'InitiativeTracker' },
|
|
898
|
+
});
|
|
899
|
+
await registry.cancelFlow({
|
|
900
|
+
flowId: flow.flowId,
|
|
901
|
+
expectedRevision: cr.flow.revision,
|
|
902
|
+
principal,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
this.logTaskFlowError('remove', id, err);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
this.initiatives.delete(id);
|
|
911
|
+
this.digestCacheInvalidator();
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
164
914
|
const removed = this.initiatives.delete(id);
|
|
165
|
-
if (removed)
|
|
166
|
-
this.
|
|
915
|
+
if (removed) {
|
|
916
|
+
this.saveToDisk();
|
|
917
|
+
this.digestCacheInvalidator();
|
|
918
|
+
}
|
|
167
919
|
return removed;
|
|
168
920
|
}
|
|
169
921
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
922
|
+
* One-time backfill helper for TaskFlow-enabled installs: scans all
|
|
923
|
+
* records, identifies those missing `kind`, and updates them through
|
|
924
|
+
* TaskFlow. Idempotent — running twice produces no change after the
|
|
925
|
+
* first pass. The legacy-JSON path runs an equivalent backfill inside
|
|
926
|
+
* `loadFromDisk()` on first load.
|
|
927
|
+
*
|
|
928
|
+
* Returns counts so callers can log/observe what happened.
|
|
929
|
+
*/
|
|
930
|
+
async backfillKindAndSchema() {
|
|
931
|
+
if (!this.isTaskFlowEnabled()) {
|
|
932
|
+
// Legacy path is backfilled during load. A no-op here is correct
|
|
933
|
+
// because the in-memory cache already reflects the backfill.
|
|
934
|
+
let backfilled = 0;
|
|
935
|
+
for (const init of this.initiatives.values()) {
|
|
936
|
+
if (init.kind === undefined) {
|
|
937
|
+
init.kind = 'task';
|
|
938
|
+
if (init.schemaVersion === undefined)
|
|
939
|
+
init.schemaVersion = 1;
|
|
940
|
+
backfilled++;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (backfilled > 0)
|
|
944
|
+
this.saveToDisk();
|
|
945
|
+
return { backfilled, scanned: this.initiatives.size };
|
|
946
|
+
}
|
|
947
|
+
this.refreshCacheFromTaskFlow();
|
|
948
|
+
let backfilled = 0;
|
|
949
|
+
const records = Array.from(this.initiatives.values());
|
|
950
|
+
for (const init of records) {
|
|
951
|
+
if (init.kind !== undefined)
|
|
952
|
+
continue;
|
|
953
|
+
const patched = {
|
|
954
|
+
...init,
|
|
955
|
+
kind: 'task',
|
|
956
|
+
schemaVersion: init.schemaVersion ?? 1,
|
|
957
|
+
};
|
|
958
|
+
this.initiatives.set(init.id, patched);
|
|
959
|
+
await this.persistThroughTaskFlow(patched);
|
|
960
|
+
backfilled++;
|
|
961
|
+
}
|
|
962
|
+
return { backfilled, scanned: records.length };
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Scan active initiatives for anything actionable. Empty items[] means
|
|
966
|
+
* "quiet day, don't spam the user."
|
|
173
967
|
*/
|
|
174
968
|
digest(now = new Date()) {
|
|
969
|
+
if (this.isTaskFlowEnabled())
|
|
970
|
+
this.refreshCacheFromTaskFlow();
|
|
175
971
|
const items = [];
|
|
176
972
|
const nowMs = now.getTime();
|
|
177
973
|
for (const initiative of this.initiatives.values()) {
|
|
@@ -198,10 +994,6 @@ export class InitiativeTracker {
|
|
|
198
994
|
continue;
|
|
199
995
|
}
|
|
200
996
|
}
|
|
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
997
|
const current = initiative.phases[initiative.currentPhaseIndex];
|
|
206
998
|
const previous = initiative.currentPhaseIndex > 0
|
|
207
999
|
? initiative.phases[initiative.currentPhaseIndex - 1]
|