@planu/cli 4.11.7 → 4.11.8
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/CHANGELOG.md +10 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/browser-validator.js +26 -21
- package/dist/engine/crash-shield/file-collector.d.ts +20 -3
- package/dist/engine/crash-shield/file-collector.js +137 -8
- package/dist/engine/crash-shield/index.d.ts +18 -1
- package/dist/engine/crash-shield/index.js +58 -17
- package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
- package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
- package/dist/engine/figma/visual-qa.d.ts +2 -1
- package/dist/engine/figma/visual-qa.js +8 -7
- package/dist/engine/qa-gate.js +2 -1
- package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
- package/dist/engine/spec-state-machine/transition-spec.js +19 -4
- package/dist/engine/triagier/classifier.d.ts +2 -2
- package/dist/engine/triagier/classifier.js +12 -15
- package/dist/index.js +12 -4
- package/dist/storage/approval-operation-lock.d.ts +10 -0
- package/dist/storage/approval-operation-lock.js +44 -0
- package/dist/storage/approval-store.d.ts +2 -0
- package/dist/storage/approval-store.js +9 -1
- package/dist/storage/spec-store.d.ts +29 -2
- package/dist/storage/spec-store.js +307 -7
- package/dist/tools/approval-handler.js +255 -124
- package/dist/tools/browser-validate-handler.js +17 -3
- package/dist/tools/dogfood-watch.d.ts +6 -0
- package/dist/tools/dogfood-watch.js +48 -0
- package/dist/tools/figma/visual-qa.js +2 -1
- package/dist/tools/tool-registry/core-tools.js +12 -0
- package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
- package/dist/tools/update-status/file-sync.js +3 -2
- package/dist/tools/update-status/index.d.ts +2 -0
- package/dist/tools/update-status/index.js +1083 -821
- package/dist/tools/update-status/response-builder.js +11 -0
- package/dist/tools/update-status/side-effects.d.ts +16 -1
- package/dist/tools/update-status/side-effects.js +140 -0
- package/dist/tools/update-status/transition-guard.js +1 -1
- package/dist/tools/update-status-actions.d.ts +10 -2
- package/dist/tools/update-status-actions.js +166 -192
- package/dist/tools/update-status-convention-gate.d.ts +3 -1
- package/dist/tools/update-status-convention-gate.js +135 -7
- package/dist/types/browser-validator.d.ts +2 -0
- package/dist/types/dogfooding.d.ts +34 -0
- package/dist/types/dogfooding.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +28 -1
- package/package.json +25 -25
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class ApprovalOperationBusyError extends Error {
|
|
2
|
+
constructor(specId: string);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Serialize approval and lifecycle operations for one spec across local calls and
|
|
6
|
+
* processes. Reentrancy lets request_changes invoke update_status while retaining
|
|
7
|
+
* the same outer exclusion boundary.
|
|
8
|
+
*/
|
|
9
|
+
export declare function withApprovalSpecLock<T>(projectPath: string, specId: string, fn: () => Promise<T>): Promise<T>;
|
|
10
|
+
//# sourceMappingURL=approval-operation-lock.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { withFileLock } from './file-mutex.js';
|
|
5
|
+
import { LockBusyError, withSpecLock } from '../engine/safety/cross-process-lock.js';
|
|
6
|
+
const heldApprovalLocks = new AsyncLocalStorage();
|
|
7
|
+
function approvalLockId(specId) {
|
|
8
|
+
const digest = createHash('sha256').update(specId).digest('hex').slice(0, 24);
|
|
9
|
+
return `__approval-operation-${digest}`;
|
|
10
|
+
}
|
|
11
|
+
export class ApprovalOperationBusyError extends Error {
|
|
12
|
+
constructor(specId) {
|
|
13
|
+
super(`Approval operation for ${specId} is busy in another process. Retry shortly.`);
|
|
14
|
+
this.name = 'ApprovalOperationBusyError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Serialize approval and lifecycle operations for one spec across local calls and
|
|
19
|
+
* processes. Reentrancy lets request_changes invoke update_status while retaining
|
|
20
|
+
* the same outer exclusion boundary.
|
|
21
|
+
*/
|
|
22
|
+
export async function withApprovalSpecLock(projectPath, specId, fn) {
|
|
23
|
+
const normalizedProjectPath = resolve(projectPath);
|
|
24
|
+
const lockId = approvalLockId(specId);
|
|
25
|
+
const localLockKey = `${normalizedProjectPath}\0${lockId}`;
|
|
26
|
+
const heldLocks = heldApprovalLocks.getStore();
|
|
27
|
+
if (heldLocks?.has(localLockKey)) {
|
|
28
|
+
return fn();
|
|
29
|
+
}
|
|
30
|
+
return withFileLock(localLockKey, async () => {
|
|
31
|
+
try {
|
|
32
|
+
return await withSpecLock(normalizedProjectPath, lockId, async () => heldApprovalLocks.run(new Set([...(heldLocks ?? []), localLockKey]), async () => fn()), {
|
|
33
|
+
reason: `approval/lifecycle operation for ${specId}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof LockBusyError) {
|
|
38
|
+
throw new ApprovalOperationBusyError(specId);
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=approval-operation-lock.js.map
|
|
@@ -7,6 +7,8 @@ export declare function savePolicy(projectId: string, policy: ApprovalPolicy): P
|
|
|
7
7
|
export declare function getRecords(projectId: string, specId: string): Promise<ApprovalRecord[]>;
|
|
8
8
|
/** Append a new approval record. Returns the full updated list. */
|
|
9
9
|
export declare function appendRecord(projectId: string, specId: string, record: ApprovalRecord): Promise<ApprovalRecord[]>;
|
|
10
|
+
/** Atomically replace all approval records for a spec. */
|
|
11
|
+
export declare function replaceRecords(projectId: string, specId: string, records: ApprovalRecord[]): Promise<ApprovalRecord[]>;
|
|
10
12
|
/** Delete all approval records for a spec (e.g. when changes are requested). */
|
|
11
13
|
export declare function clearRecords(projectId: string, specId: string): Promise<void>;
|
|
12
14
|
//# sourceMappingURL=approval-store.d.ts.map
|
|
@@ -43,8 +43,16 @@ export async function appendRecord(projectId, specId, record) {
|
|
|
43
43
|
return updated;
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
+
/** Atomically replace all approval records for a spec. */
|
|
47
|
+
export async function replaceRecords(projectId, specId, records) {
|
|
48
|
+
const filePath = approvalRecordsPath(projectId, specId);
|
|
49
|
+
return withFileLock(filePath, async () => {
|
|
50
|
+
await writeJson(filePath, records);
|
|
51
|
+
return records;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
46
54
|
/** Delete all approval records for a spec (e.g. when changes are requested). */
|
|
47
55
|
export async function clearRecords(projectId, specId) {
|
|
48
|
-
await
|
|
56
|
+
await replaceRecords(projectId, specId, []);
|
|
49
57
|
}
|
|
50
58
|
//# sourceMappingURL=approval-store.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Spec, SpecStatus, SpecVersion, SpecSummary, DefinitionOfReady, DefinitionOfDone } from '../types/index.js';
|
|
1
|
+
import type { Spec, SpecStatus, SpecVersion, SpecSummary, DefinitionOfReady, DefinitionOfDone, PostCommitTaskClaim } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Thrown when code tries to write `status` via `updateSpec` directly.
|
|
4
4
|
* All status transitions must go through `handleUpdateStatus` → `transitionSpec`.
|
|
@@ -16,6 +16,8 @@ export declare function listSpecs(projectId: string): Promise<Spec[]>;
|
|
|
16
16
|
* Get a single spec by ID. Returns `null` when not found.
|
|
17
17
|
*/
|
|
18
18
|
export declare function getSpec(projectId: string, specId: string): Promise<Spec | null>;
|
|
19
|
+
/** Reload a spec from disk after the caller acquires its cross-process lock. */
|
|
20
|
+
export declare function getSpecFresh(projectId: string, specId: string): Promise<Spec | null>;
|
|
19
21
|
/** SPEC-601: Lookup a spec by its globally unique UUID. */
|
|
20
22
|
export declare function getSpecByUuid(projectId: string, uuid: string): Promise<Spec | null>;
|
|
21
23
|
/**
|
|
@@ -27,7 +29,32 @@ export declare function createSpec(projectId: string, spec: Spec): Promise<Spec>
|
|
|
27
29
|
* Importable exclusively from `src/engine/spec-state-machine/transition-spec.ts`.
|
|
28
30
|
* All status transitions must flow through `transitionSpec` → `__internalSetStatus`.
|
|
29
31
|
*/
|
|
30
|
-
export declare function __internalSetStatus(projectId: string, specId: string, status: SpecStatus): Promise<Spec>;
|
|
32
|
+
export declare function __internalSetStatus(projectId: string, specId: string, status: SpecStatus, updates?: Omit<Partial<Spec>, 'status'>, pendingBackgroundActions?: readonly string[], expectedSnapshot?: Pick<Spec, 'status' | 'updatedAt'>): Promise<Spec>;
|
|
33
|
+
/**
|
|
34
|
+
* Restore the exact snapshot captured before a lifecycle transition.
|
|
35
|
+
* This is only used by transitionSpec compensation while the caller still
|
|
36
|
+
* holds the per-spec cross-process lock.
|
|
37
|
+
*/
|
|
38
|
+
export declare function __internalRestoreSpecSnapshot(projectId: string, specId: string, snapshot: Spec, expectedCurrentSnapshot: Spec): Promise<Spec>;
|
|
39
|
+
/**
|
|
40
|
+
* Atomically claim one durable post-commit task.
|
|
41
|
+
* Legacy transition receipts are materialized only when the task was explicitly planned.
|
|
42
|
+
*/
|
|
43
|
+
export declare function claimPostCommitTask(projectId: string, specId: string, transitionId: string, taskName: string, options?: {
|
|
44
|
+
now?: string;
|
|
45
|
+
leaseMs?: number;
|
|
46
|
+
}): Promise<PostCommitTaskClaim>;
|
|
47
|
+
/** Extend a running task lease while the owning execution is still alive. */
|
|
48
|
+
export declare function renewPostCommitTaskLease(projectId: string, specId: string, transitionId: string, taskName: string, executionId: string, options?: {
|
|
49
|
+
now?: string;
|
|
50
|
+
leaseMs?: number;
|
|
51
|
+
}): Promise<boolean>;
|
|
52
|
+
/** Finalize a claimed task if the execution lease still belongs to the caller. */
|
|
53
|
+
export declare function settlePostCommitTask(projectId: string, specId: string, transitionId: string, taskName: string, executionId: string, outcome: {
|
|
54
|
+
status: 'done' | 'failed';
|
|
55
|
+
error?: string;
|
|
56
|
+
now?: string;
|
|
57
|
+
}): Promise<boolean>;
|
|
31
58
|
/**
|
|
32
59
|
* Replace an existing spec entirely (non-status fields only).
|
|
33
60
|
* Throws `DirectStatusWriteForbiddenError` if `updates` contains a `status` field.
|
|
@@ -4,6 +4,8 @@ import { withFileLock } from './file-mutex.js';
|
|
|
4
4
|
import { discoverProjectDataFile } from './data-dir-discovery.js';
|
|
5
5
|
import { computeHealthScore } from '../engine/spec-health-scorer.js';
|
|
6
6
|
import { loadSpecContent } from '../engine/validation-loop.js';
|
|
7
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { acquireLock, releaseLock } from '../engine/safety/cross-process-lock.js';
|
|
7
9
|
// ---------------------------------------------------------------------------
|
|
8
10
|
// SPEC-720: Guard error — direct status write forbidden
|
|
9
11
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +46,43 @@ function normalizeSpec(raw) {
|
|
|
44
46
|
// Populated on first read; invalidated (updated) on every mutation.
|
|
45
47
|
// Eliminates repeated disk I/O for a local single-user MCP server.
|
|
46
48
|
const specsCache = new Map();
|
|
49
|
+
const STORE_LOCK_RETRY_MS = 25;
|
|
50
|
+
const STORE_LOCK_TIMEOUT_MS = 2_000;
|
|
51
|
+
const STORE_LOCK_ID = '__project-spec-store__';
|
|
52
|
+
const DEFAULT_POST_COMMIT_LEASE_MS = 30_000;
|
|
53
|
+
const MAX_POST_COMMIT_ATTEMPTS = 3;
|
|
54
|
+
const COMPLETED_OUTBOX_RETENTION = 20;
|
|
55
|
+
async function withSpecMutationLock(projectId, fn) {
|
|
56
|
+
return withFileLock(specsFile(projectId), async () => {
|
|
57
|
+
const lockRoot = projectDataDir(projectId);
|
|
58
|
+
const deadline = Date.now() + STORE_LOCK_TIMEOUT_MS;
|
|
59
|
+
let handle = await acquireLock(lockRoot, STORE_LOCK_ID, {
|
|
60
|
+
reason: `spec-store mutation for ${projectId}`,
|
|
61
|
+
});
|
|
62
|
+
while (handle === null && Date.now() < deadline) {
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, STORE_LOCK_RETRY_MS));
|
|
64
|
+
handle = await acquireLock(lockRoot, STORE_LOCK_ID, {
|
|
65
|
+
reason: `spec-store mutation for ${projectId}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (handle === null) {
|
|
69
|
+
throw new Error(`[Planu] Spec store is busy for project ${projectId}; retry the operation.`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// Another process may have written since this process populated its cache.
|
|
73
|
+
specsCache.delete(projectId);
|
|
74
|
+
return await fn();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await releaseLock(handle).catch((error) => {
|
|
78
|
+
console.warn('[planu:spec-store] failed to release mutation lock', {
|
|
79
|
+
projectId,
|
|
80
|
+
error: error instanceof Error ? error.message : String(error),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
47
86
|
/**
|
|
48
87
|
* Load all specs — from cache on subsequent calls, disk only on first access.
|
|
49
88
|
*
|
|
@@ -99,6 +138,11 @@ export async function getSpec(projectId, specId) {
|
|
|
99
138
|
const specs = await loadAll(projectId);
|
|
100
139
|
return specs.find((s) => s.id === specId) ?? null;
|
|
101
140
|
}
|
|
141
|
+
/** Reload a spec from disk after the caller acquires its cross-process lock. */
|
|
142
|
+
export async function getSpecFresh(projectId, specId) {
|
|
143
|
+
specsCache.delete(projectId);
|
|
144
|
+
return getSpec(projectId, specId);
|
|
145
|
+
}
|
|
102
146
|
/** SPEC-601: Lookup a spec by its globally unique UUID. */
|
|
103
147
|
export async function getSpecByUuid(projectId, uuid) {
|
|
104
148
|
const specs = await loadAll(projectId);
|
|
@@ -108,7 +152,7 @@ export async function getSpecByUuid(projectId, uuid) {
|
|
|
108
152
|
* Create a new spec. Throws if a spec with the same ID already exists.
|
|
109
153
|
*/
|
|
110
154
|
export async function createSpec(projectId, spec) {
|
|
111
|
-
return
|
|
155
|
+
return withSpecMutationLock(projectId, async () => {
|
|
112
156
|
const specs = await loadAll(projectId);
|
|
113
157
|
if (specs.some((s) => s.id === spec.id)) {
|
|
114
158
|
throw new Error(`Spec "${spec.id}" already exists in project "${projectId}"`);
|
|
@@ -128,8 +172,8 @@ export async function createSpec(projectId, spec) {
|
|
|
128
172
|
* NOT exported from the barrel. Called only by `__internalSetStatus` and itself.
|
|
129
173
|
* All external callers must use `updateSpec` (non-status) or `transitionSpec` (status).
|
|
130
174
|
*/
|
|
131
|
-
async function __internalUpdateSpec(projectId, specId, updates) {
|
|
132
|
-
return
|
|
175
|
+
async function __internalUpdateSpec(projectId, specId, updates, pendingBackgroundActions = [], expectedSnapshot) {
|
|
176
|
+
return withSpecMutationLock(projectId, async () => {
|
|
133
177
|
const specs = await loadAll(projectId);
|
|
134
178
|
const idx = specs.findIndex((s) => s.id === specId);
|
|
135
179
|
if (idx === -1) {
|
|
@@ -139,11 +183,42 @@ async function __internalUpdateSpec(projectId, specId, updates) {
|
|
|
139
183
|
if (!existing) {
|
|
140
184
|
throw new Error(`Spec "${specId}" not found in project "${projectId}"`);
|
|
141
185
|
}
|
|
186
|
+
if (expectedSnapshot &&
|
|
187
|
+
(existing.status !== expectedSnapshot.status ||
|
|
188
|
+
existing.updatedAt !== expectedSnapshot.updatedAt)) {
|
|
189
|
+
throw new Error(`Spec "${specId}" changed after lifecycle gates ran; retry update_status with the fresh state.`);
|
|
190
|
+
}
|
|
142
191
|
const now = new Date().toISOString();
|
|
143
192
|
const incomingStatus = updates.status;
|
|
144
193
|
let updatedStatusHistory = existing.statusHistory;
|
|
145
194
|
if (incomingStatus !== undefined && incomingStatus !== existing.status) {
|
|
146
|
-
const
|
|
195
|
+
const transitionId = createHash('sha256')
|
|
196
|
+
.update([
|
|
197
|
+
'update_status:v1',
|
|
198
|
+
projectId,
|
|
199
|
+
specId,
|
|
200
|
+
existing.status,
|
|
201
|
+
incomingStatus,
|
|
202
|
+
now,
|
|
203
|
+
randomUUID(),
|
|
204
|
+
].join('\0'))
|
|
205
|
+
.digest('hex')
|
|
206
|
+
.slice(0, 24);
|
|
207
|
+
const entry = {
|
|
208
|
+
status: incomingStatus,
|
|
209
|
+
changedAt: now,
|
|
210
|
+
fromStatus: existing.status,
|
|
211
|
+
transitionId,
|
|
212
|
+
...(pendingBackgroundActions.length > 0 && { pendingBackgroundActions }),
|
|
213
|
+
...(pendingBackgroundActions.length > 0 && {
|
|
214
|
+
postCommitTasks: pendingBackgroundActions.map((name) => ({
|
|
215
|
+
name,
|
|
216
|
+
status: 'pending',
|
|
217
|
+
attempts: 0,
|
|
218
|
+
updatedAt: now,
|
|
219
|
+
})),
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
147
222
|
updatedStatusHistory = [...(existing.statusHistory ?? []), entry];
|
|
148
223
|
}
|
|
149
224
|
const merged = {
|
|
@@ -168,8 +243,233 @@ async function __internalUpdateSpec(projectId, specId, updates) {
|
|
|
168
243
|
* Importable exclusively from `src/engine/spec-state-machine/transition-spec.ts`.
|
|
169
244
|
* All status transitions must flow through `transitionSpec` → `__internalSetStatus`.
|
|
170
245
|
*/
|
|
171
|
-
export async function __internalSetStatus(projectId, specId, status) {
|
|
172
|
-
return __internalUpdateSpec(projectId, specId, { status });
|
|
246
|
+
export async function __internalSetStatus(projectId, specId, status, updates = {}, pendingBackgroundActions = [], expectedSnapshot) {
|
|
247
|
+
return __internalUpdateSpec(projectId, specId, { ...updates, status }, pendingBackgroundActions, expectedSnapshot);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Restore the exact snapshot captured before a lifecycle transition.
|
|
251
|
+
* This is only used by transitionSpec compensation while the caller still
|
|
252
|
+
* holds the per-spec cross-process lock.
|
|
253
|
+
*/
|
|
254
|
+
export async function __internalRestoreSpecSnapshot(projectId, specId, snapshot, expectedCurrentSnapshot) {
|
|
255
|
+
return withSpecMutationLock(projectId, async () => {
|
|
256
|
+
const specs = await loadAll(projectId);
|
|
257
|
+
const idx = specs.findIndex((candidate) => candidate.id === specId);
|
|
258
|
+
if (idx === -1) {
|
|
259
|
+
throw new Error(`Spec "${specId}" not found in project "${projectId}"`);
|
|
260
|
+
}
|
|
261
|
+
if (snapshot.id !== specId) {
|
|
262
|
+
throw new Error(`Cannot restore snapshot "${snapshot.id}" over "${specId}" in project "${projectId}"`);
|
|
263
|
+
}
|
|
264
|
+
const existing = specs[idx];
|
|
265
|
+
if (!existing) {
|
|
266
|
+
throw new Error(`Spec "${specId}" not found in project "${projectId}"`);
|
|
267
|
+
}
|
|
268
|
+
if (JSON.stringify(existing) !== JSON.stringify(expectedCurrentSnapshot)) {
|
|
269
|
+
throw new Error(`Spec "${specId}" changed after the transition commit; refusing unsafe compensation.`);
|
|
270
|
+
}
|
|
271
|
+
const restored = { ...snapshot };
|
|
272
|
+
const nextSpecs = specs.map((current, currentIdx) => (currentIdx === idx ? restored : current));
|
|
273
|
+
await saveAll(projectId, nextSpecs);
|
|
274
|
+
return restored;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function updateOutboxReceipt(history, transitionId, update) {
|
|
278
|
+
if (!history) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const index = history.findIndex((entry) => entry.transitionId === transitionId);
|
|
282
|
+
if (index === -1) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return history.map((entry, currentIndex) => (currentIndex === index ? update(entry) : entry));
|
|
286
|
+
}
|
|
287
|
+
function materializePostCommitTasks(entry) {
|
|
288
|
+
if (entry.postCommitTasks) {
|
|
289
|
+
return entry.postCommitTasks;
|
|
290
|
+
}
|
|
291
|
+
return (entry.pendingBackgroundActions ?? []).map((name) => ({
|
|
292
|
+
name,
|
|
293
|
+
status: 'pending',
|
|
294
|
+
attempts: 0,
|
|
295
|
+
updatedAt: entry.changedAt,
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
function isPendingPostCommitTask(task) {
|
|
299
|
+
return (task.status !== 'done' &&
|
|
300
|
+
!(task.status === 'failed' && task.attempts >= MAX_POST_COMMIT_ATTEMPTS));
|
|
301
|
+
}
|
|
302
|
+
function pruneCompletedOutbox(history) {
|
|
303
|
+
let completedOutboxesKept = 0;
|
|
304
|
+
return [...history]
|
|
305
|
+
.reverse()
|
|
306
|
+
.map((entry) => {
|
|
307
|
+
const tasks = entry.postCommitTasks;
|
|
308
|
+
const isCompletedOutbox = tasks !== undefined && tasks.length > 0 && tasks.every((task) => task.status === 'done');
|
|
309
|
+
if (!isCompletedOutbox) {
|
|
310
|
+
return entry;
|
|
311
|
+
}
|
|
312
|
+
completedOutboxesKept += 1;
|
|
313
|
+
if (completedOutboxesKept <= COMPLETED_OUTBOX_RETENTION) {
|
|
314
|
+
return entry;
|
|
315
|
+
}
|
|
316
|
+
const { postCommitTasks: _postCommitTasks, pendingBackgroundActions: _pending, ...receipt } = entry;
|
|
317
|
+
return receipt;
|
|
318
|
+
})
|
|
319
|
+
.reverse();
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Atomically claim one durable post-commit task.
|
|
323
|
+
* Legacy transition receipts are materialized only when the task was explicitly planned.
|
|
324
|
+
*/
|
|
325
|
+
export async function claimPostCommitTask(projectId, specId, transitionId, taskName, options = {}) {
|
|
326
|
+
return withSpecMutationLock(projectId, async () => {
|
|
327
|
+
const specs = await loadAll(projectId);
|
|
328
|
+
const specIndex = specs.findIndex((candidate) => candidate.id === specId);
|
|
329
|
+
const existing = specs[specIndex];
|
|
330
|
+
if (!existing) {
|
|
331
|
+
return { claimed: false, reason: 'missing' };
|
|
332
|
+
}
|
|
333
|
+
const now = options.now ?? new Date().toISOString();
|
|
334
|
+
const nowMs = Date.parse(now);
|
|
335
|
+
const claimResult = {
|
|
336
|
+
value: { claimed: false, reason: 'missing' },
|
|
337
|
+
};
|
|
338
|
+
const nextHistory = updateOutboxReceipt(existing.statusHistory, transitionId, (entry) => {
|
|
339
|
+
const tasks = materializePostCommitTasks(entry);
|
|
340
|
+
const taskIndex = tasks.findIndex((task) => task.name === taskName);
|
|
341
|
+
const current = tasks[taskIndex];
|
|
342
|
+
if (!current) {
|
|
343
|
+
return entry;
|
|
344
|
+
}
|
|
345
|
+
if (current.status === 'done') {
|
|
346
|
+
claimResult.value = { claimed: false, reason: 'done' };
|
|
347
|
+
return entry;
|
|
348
|
+
}
|
|
349
|
+
if (current.attempts >= MAX_POST_COMMIT_ATTEMPTS) {
|
|
350
|
+
claimResult.value = { claimed: false, reason: 'done' };
|
|
351
|
+
return entry;
|
|
352
|
+
}
|
|
353
|
+
if (current.status === 'running' &&
|
|
354
|
+
current.leaseExpiresAt &&
|
|
355
|
+
Date.parse(current.leaseExpiresAt) > nowMs) {
|
|
356
|
+
claimResult.value = {
|
|
357
|
+
claimed: false,
|
|
358
|
+
reason: 'running',
|
|
359
|
+
retryAt: current.leaseExpiresAt,
|
|
360
|
+
};
|
|
361
|
+
return entry;
|
|
362
|
+
}
|
|
363
|
+
const executionId = randomUUID();
|
|
364
|
+
const attempts = current.attempts + 1;
|
|
365
|
+
const claimedTask = {
|
|
366
|
+
name: current.name,
|
|
367
|
+
status: 'running',
|
|
368
|
+
attempts,
|
|
369
|
+
updatedAt: now,
|
|
370
|
+
executionId,
|
|
371
|
+
leaseExpiresAt: new Date(nowMs + (options.leaseMs ?? DEFAULT_POST_COMMIT_LEASE_MS)).toISOString(),
|
|
372
|
+
};
|
|
373
|
+
claimResult.value = { claimed: true, executionId, attempts };
|
|
374
|
+
const nextTasks = tasks.map((task, index) => (index === taskIndex ? claimedTask : task));
|
|
375
|
+
return {
|
|
376
|
+
...entry,
|
|
377
|
+
postCommitTasks: nextTasks,
|
|
378
|
+
pendingBackgroundActions: nextTasks
|
|
379
|
+
.filter(isPendingPostCommitTask)
|
|
380
|
+
.map((task) => task.name),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
if (!nextHistory || !claimResult.value.claimed) {
|
|
384
|
+
return claimResult.value;
|
|
385
|
+
}
|
|
386
|
+
const updated = { ...existing, statusHistory: nextHistory };
|
|
387
|
+
await saveAll(projectId, specs.map((spec, index) => (index === specIndex ? updated : spec)));
|
|
388
|
+
return claimResult.value;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** Extend a running task lease while the owning execution is still alive. */
|
|
392
|
+
export async function renewPostCommitTaskLease(projectId, specId, transitionId, taskName, executionId, options = {}) {
|
|
393
|
+
return withSpecMutationLock(projectId, async () => {
|
|
394
|
+
const specs = await loadAll(projectId);
|
|
395
|
+
const specIndex = specs.findIndex((candidate) => candidate.id === specId);
|
|
396
|
+
const existing = specs[specIndex];
|
|
397
|
+
if (!existing) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const now = options.now ?? new Date().toISOString();
|
|
401
|
+
const nowMs = Date.parse(now);
|
|
402
|
+
const renewed = { value: false };
|
|
403
|
+
const nextHistory = updateOutboxReceipt(existing.statusHistory, transitionId, (entry) => {
|
|
404
|
+
const tasks = materializePostCommitTasks(entry);
|
|
405
|
+
const taskIndex = tasks.findIndex((task) => task.name === taskName);
|
|
406
|
+
const current = tasks[taskIndex];
|
|
407
|
+
if (current?.status !== 'running' || current.executionId !== executionId) {
|
|
408
|
+
return entry;
|
|
409
|
+
}
|
|
410
|
+
renewed.value = true;
|
|
411
|
+
const renewedTask = {
|
|
412
|
+
...current,
|
|
413
|
+
updatedAt: now,
|
|
414
|
+
leaseExpiresAt: new Date(nowMs + (options.leaseMs ?? DEFAULT_POST_COMMIT_LEASE_MS)).toISOString(),
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
...entry,
|
|
418
|
+
postCommitTasks: tasks.map((task, index) => (index === taskIndex ? renewedTask : task)),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
if (!nextHistory || !renewed.value) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
await saveAll(projectId, specs.map((spec, index) => index === specIndex ? { ...existing, statusHistory: nextHistory } : spec));
|
|
425
|
+
return true;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/** Finalize a claimed task if the execution lease still belongs to the caller. */
|
|
429
|
+
export async function settlePostCommitTask(projectId, specId, transitionId, taskName, executionId, outcome) {
|
|
430
|
+
return withSpecMutationLock(projectId, async () => {
|
|
431
|
+
const specs = await loadAll(projectId);
|
|
432
|
+
const specIndex = specs.findIndex((candidate) => candidate.id === specId);
|
|
433
|
+
const existing = specs[specIndex];
|
|
434
|
+
if (!existing) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
const now = outcome.now ?? new Date().toISOString();
|
|
438
|
+
const settlement = { completed: false };
|
|
439
|
+
const nextHistory = updateOutboxReceipt(existing.statusHistory, transitionId, (entry) => {
|
|
440
|
+
const tasks = materializePostCommitTasks(entry);
|
|
441
|
+
const taskIndex = tasks.findIndex((task) => task.name === taskName);
|
|
442
|
+
const current = tasks[taskIndex];
|
|
443
|
+
if (current?.status !== 'running' || current.executionId !== executionId) {
|
|
444
|
+
return entry;
|
|
445
|
+
}
|
|
446
|
+
settlement.completed = true;
|
|
447
|
+
const settledTask = {
|
|
448
|
+
name: current.name,
|
|
449
|
+
status: outcome.status,
|
|
450
|
+
attempts: current.attempts,
|
|
451
|
+
updatedAt: now,
|
|
452
|
+
...(outcome.status === 'failed' && outcome.error ? { lastError: outcome.error } : {}),
|
|
453
|
+
};
|
|
454
|
+
const nextTasks = tasks.map((task, index) => (index === taskIndex ? settledTask : task));
|
|
455
|
+
return {
|
|
456
|
+
...entry,
|
|
457
|
+
postCommitTasks: nextTasks,
|
|
458
|
+
pendingBackgroundActions: nextTasks
|
|
459
|
+
.filter(isPendingPostCommitTask)
|
|
460
|
+
.map((task) => task.name),
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
if (!nextHistory || !settlement.completed) {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
const updated = {
|
|
467
|
+
...existing,
|
|
468
|
+
statusHistory: pruneCompletedOutbox(nextHistory),
|
|
469
|
+
};
|
|
470
|
+
await saveAll(projectId, specs.map((spec, index) => (index === specIndex ? updated : spec)));
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
173
473
|
}
|
|
174
474
|
/**
|
|
175
475
|
* Replace an existing spec entirely (non-status fields only).
|
|
@@ -198,7 +498,7 @@ export async function updateSpecStatus(_projectId, specId, status) {
|
|
|
198
498
|
* Delete a spec by ID. Returns `true` if removed, `false` if not found.
|
|
199
499
|
*/
|
|
200
500
|
export async function deleteSpec(projectId, specId) {
|
|
201
|
-
return
|
|
501
|
+
return withSpecMutationLock(projectId, async () => {
|
|
202
502
|
const specs = await loadAll(projectId);
|
|
203
503
|
const idx = specs.findIndex((s) => s.id === specId);
|
|
204
504
|
if (idx === -1) {
|