@openprd/cli 0.1.18 → 0.1.21
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/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
- package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
- package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
- package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-generated-change/design.md +50 -0
- package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
- package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
- package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
- package/.openprd/design/active/asset-spec.md +19 -14
- package/.openprd/design/active/direction-plan.md +19 -3
- package/.openprd/design/active/facts-sheet.md +16 -7
- package/.openprd/design/active/image-preflight.md +6 -5
- package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
- package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
- package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
- package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
- package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
- package/.openprd/design/active/selected-direction.md +23 -9
- package/.openprd/engagements/active/control-plane-architecture.json +203 -0
- package/.openprd/engagements/active/control-plane-intake.json +418 -0
- package/.openprd/engagements/active/prd.md +186 -100
- package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
- package/.openprd/i18n-config.json +12 -0
- package/.openprd/ledger/events.jsonl +6 -0
- package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
- package/.openprd/manifest.json +21 -0
- package/AGENTS.md +1 -0
- package/README.md +22 -0
- package/README_EN.md +27 -0
- package/package.json +1 -1
- package/skills/openprd-frontend-design/SKILL.md +16 -0
- package/skills/openprd-harness/SKILL.md +1 -1
- package/skills/openprd-quality/SKILL.md +1 -1
- package/skills/openprd-requirement-intake/SKILL.md +1 -1
- package/skills/openprd-shared/SKILL.md +1 -1
- package/src/adapters/adapter-spi.js +193 -0
- package/src/adapters/capability-envelope.js +98 -0
- package/src/adapters/event-normalizer.js +55 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/install-safety.js +249 -0
- package/src/agent-canonical-content.js +6 -2
- package/src/agent-integration.js +147 -42
- package/src/canvas-app-client-app.js +838 -0
- package/src/canvas-app-client-helpers.js +1138 -0
- package/src/canvas-app-styles.js +898 -0
- package/src/canvas-app.html.js +6 -2781
- package/src/canvas-binding.js +414 -0
- package/src/canvas-codex-relay.js +430 -0
- package/src/canvas-i18n.js +8 -0
- package/src/canvas-scene.js +377 -0
- package/src/canvas-session-store.js +148 -0
- package/src/canvas-workspace.js +59 -1066
- package/src/cli/args.js +63 -4
- package/src/cli/gate-print.js +17 -0
- package/src/cli/quality-commands.js +18 -0
- package/src/cli/quality-print.js +10 -0
- package/src/cli/runtime-print.js +24 -0
- package/src/codex-hook-runner-template.mjs +87 -77
- package/src/codex-runtime.js +48 -5
- package/src/context/cache.js +245 -0
- package/src/context/compiler.js +438 -0
- package/src/context/constants.js +30 -0
- package/src/context/index.js +39 -0
- package/src/context/redaction.js +84 -0
- package/src/context/stable.js +69 -0
- package/src/context/telemetry.js +42 -0
- package/src/dev-standards.js +57 -0
- package/src/fleet.js +112 -95
- package/src/gates/index.js +2 -0
- package/src/gates/scoped-gates.js +256 -0
- package/src/gates/store.js +126 -0
- package/src/gates/workspace.js +41 -0
- package/src/html-artifacts.js +725 -28
- package/src/kernel/atomic-store.js +299 -0
- package/src/kernel/event-envelope.js +166 -0
- package/src/kernel/index.js +4 -0
- package/src/kernel/project-ledger.js +467 -0
- package/src/kernel/project-manifest.js +205 -0
- package/src/knowledge-v3/index.js +1 -0
- package/src/knowledge-v3/lifecycle.js +290 -0
- package/src/knowledge.js +14 -7
- package/src/openprd.js +71 -2
- package/src/review-model.js +413 -0
- package/src/review-presentation.js +1 -1
- package/src/run-harness.js +431 -37
- package/src/runtime/cli_runtime_README.md +28 -0
- package/src/runtime/errors.js +66 -0
- package/src/runtime/index.js +44 -0
- package/src/runtime/lane-schema.js +141 -0
- package/src/runtime/lane-store.js +279 -0
- package/src/runtime/task-runtime.js +449 -0
- package/src/runtime/workspace.js +179 -0
- package/src/runtime/write-set.js +206 -0
- package/src/session-binding.js +16 -3
- package/src/session-registry.js +59 -1
- package/src/upgrade/fleet-mutation.js +166 -0
- package/src/upgrade/fleet-transaction.js +398 -0
- package/src/upgrade/transaction-store.js +416 -0
- package/src/visual-compare-core.js +66 -27
- package/src/visual-compare.js +18 -12
- package/src/workspace-core.js +109 -7
- package/src/workspace-registry.js +39 -1
- package/src/workspace-workflow.js +18 -15
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ## 核心功能
|
|
3
|
+
* 提供可跨 Agent/进程安全竞争的 Task claim、租约心跳、释放、完成与过期恢复流程。
|
|
4
|
+
*
|
|
5
|
+
* ## 输入
|
|
6
|
+
* 接收 lane/task 标识、claim 绑定信息、write-set、lease、expectedRevision 与完成证据。
|
|
7
|
+
*
|
|
8
|
+
* ## 输出
|
|
9
|
+
* 导出 TaskRuntime;所有变更返回最新 lane/task/claim revision,冲突返回结构化错误码。
|
|
10
|
+
*
|
|
11
|
+
* ## 定位
|
|
12
|
+
* 位于 runtime 领域层,在 LaneStore 原子事务之上实现任务所有权和并行写范围规则。
|
|
13
|
+
*
|
|
14
|
+
* ## 依赖
|
|
15
|
+
* 依赖 Node.js crypto,以及同目录 lane-store、lane-schema、write-set、errors 模块。
|
|
16
|
+
*
|
|
17
|
+
* ## 维护规则
|
|
18
|
+
* claim token 与 task revision 校验顺序不可弱化;过期或旧 claim 的 heartbeat 永远不能覆盖新 claim。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
revisionConflictError,
|
|
25
|
+
taskAlreadyClaimedError,
|
|
26
|
+
writeSetConflictError,
|
|
27
|
+
} from './errors.js';
|
|
28
|
+
import { createTaskState, isoTime, normalizeRuntimeId } from './lane-schema.js';
|
|
29
|
+
import { LaneStore } from './lane-store.js';
|
|
30
|
+
import { explainWriteSetIntersection, normalizeWriteSet } from './write-set.js';
|
|
31
|
+
|
|
32
|
+
const FINISH_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
33
|
+
|
|
34
|
+
function clone(value) {
|
|
35
|
+
return structuredClone(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeOptionalText(value) {
|
|
39
|
+
if (value === null || value === undefined) return null;
|
|
40
|
+
const text = String(value).trim();
|
|
41
|
+
return text || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeEvidence(value) {
|
|
45
|
+
if (value === null || value === undefined) return [];
|
|
46
|
+
return clone(Array.isArray(value) ? value : [value]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeEvidence(...lists) {
|
|
50
|
+
return lists.flatMap((value) => normalizeEvidence(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function milliseconds(value) {
|
|
54
|
+
if (typeof value === 'string') return Date.parse(value);
|
|
55
|
+
if (value instanceof Date) return value.valueOf();
|
|
56
|
+
return Number(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requirePositiveDuration(value, label = 'leaseDurationMs') {
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
if (!Number.isFinite(number) || number <= 0) throw new TypeError(`OpenPrd ${label} must be a positive number.`);
|
|
62
|
+
return Math.round(number);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function taskFrom(lane, taskIdValue) {
|
|
66
|
+
const taskId = normalizeRuntimeId(taskIdValue, 'taskId');
|
|
67
|
+
if (!Object.hasOwn(lane.tasks, taskId)) {
|
|
68
|
+
const error = new Error(`OpenPrd task not found: ${lane.laneId}/${taskId}`);
|
|
69
|
+
error.code = 'ENOENT';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return lane.tasks[taskId];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function claimIdFrom(value) {
|
|
76
|
+
const claimId = normalizeOptionalText(
|
|
77
|
+
typeof value === 'string' ? value : (value?.claimId ?? value?.token),
|
|
78
|
+
);
|
|
79
|
+
if (!claimId) throw new TypeError('OpenPrd claimId is required.');
|
|
80
|
+
return claimId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function claimBinding(options) {
|
|
84
|
+
const client = normalizeOptionalText(options.client ?? options.clientId);
|
|
85
|
+
const session = normalizeOptionalText(options.session ?? options.sessionId);
|
|
86
|
+
const thread = normalizeOptionalText(options.thread ?? options.threadId);
|
|
87
|
+
return {
|
|
88
|
+
client,
|
|
89
|
+
clientId: client,
|
|
90
|
+
session,
|
|
91
|
+
sessionId: session,
|
|
92
|
+
thread,
|
|
93
|
+
threadId: thread,
|
|
94
|
+
worktree: normalizeOptionalText(options.worktree ?? options.worktreePath),
|
|
95
|
+
branch: normalizeOptionalText(options.branch),
|
|
96
|
+
baseCommit: normalizeOptionalText(options.baseCommit),
|
|
97
|
+
evidence: normalizeEvidence(options.evidence),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function activeClaim(task, nowMs) {
|
|
102
|
+
return task.status === 'claimed'
|
|
103
|
+
&& task.claim
|
|
104
|
+
&& milliseconds(task.claim.leaseExpiresAt) > nowMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function revisionCheck(laneId, task, expectedRevision, operation) {
|
|
108
|
+
if (expectedRevision === undefined || expectedRevision === task.revision) return;
|
|
109
|
+
throw revisionConflictError({
|
|
110
|
+
laneId,
|
|
111
|
+
taskId: task.taskId,
|
|
112
|
+
expectedRevision,
|
|
113
|
+
actualRevision: task.revision,
|
|
114
|
+
operation,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertCurrentClaim(lane, task, claimId, nowMs, operation) {
|
|
119
|
+
if (!task.claim || task.claim.claimId !== claimId) {
|
|
120
|
+
throw taskAlreadyClaimedError({
|
|
121
|
+
laneId: lane.laneId,
|
|
122
|
+
taskId: task.taskId,
|
|
123
|
+
suppliedClaimId: claimId,
|
|
124
|
+
activeClaimId: task.claim?.claimId ?? null,
|
|
125
|
+
activeBinding: task.claim ? claimBinding(task.claim) : null,
|
|
126
|
+
reason: task.claim ? 'claim-mismatch' : 'claim-not-active',
|
|
127
|
+
operation,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (!activeClaim(task, nowMs)) {
|
|
131
|
+
throw taskAlreadyClaimedError({
|
|
132
|
+
laneId: lane.laneId,
|
|
133
|
+
taskId: task.taskId,
|
|
134
|
+
suppliedClaimId: claimId,
|
|
135
|
+
activeClaimId: task.claim.claimId,
|
|
136
|
+
leaseExpiresAt: task.claim.leaseExpiresAt,
|
|
137
|
+
reason: 'lease-expired',
|
|
138
|
+
operation,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return task.claim;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function recoverExpiredClaims(lane, nowMs, nowIso, onlyTaskId = null) {
|
|
145
|
+
const recovered = [];
|
|
146
|
+
for (const task of Object.values(lane.tasks)) {
|
|
147
|
+
if (onlyTaskId !== null && task.taskId !== onlyTaskId) continue;
|
|
148
|
+
if (task.status !== 'claimed' || !task.claim) continue;
|
|
149
|
+
if (milliseconds(task.claim.leaseExpiresAt) > nowMs) continue;
|
|
150
|
+
const expiredClaim = clone(task.claim);
|
|
151
|
+
task.status = 'pending';
|
|
152
|
+
task.claim = null;
|
|
153
|
+
task.lastClaim = {
|
|
154
|
+
...expiredClaim,
|
|
155
|
+
outcome: 'expired',
|
|
156
|
+
endedAt: nowIso,
|
|
157
|
+
};
|
|
158
|
+
task.revision += 1;
|
|
159
|
+
task.updatedAt = nowIso;
|
|
160
|
+
recovered.push({
|
|
161
|
+
taskId: task.taskId,
|
|
162
|
+
claimId: expiredClaim.claimId,
|
|
163
|
+
generation: expiredClaim.generation,
|
|
164
|
+
leaseExpiresAt: expiredClaim.leaseExpiresAt,
|
|
165
|
+
revision: task.revision,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return recovered;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function taskResult(transaction, taskId, extra = {}) {
|
|
172
|
+
return {
|
|
173
|
+
laneId: transaction.lane.laneId,
|
|
174
|
+
laneRevision: transaction.lane.revision,
|
|
175
|
+
task: clone(transaction.lane.tasks[taskId]),
|
|
176
|
+
...extra,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export class TaskRuntime {
|
|
181
|
+
constructor(rootOrStore, options = {}) {
|
|
182
|
+
this.store = rootOrStore instanceof LaneStore
|
|
183
|
+
? rootOrStore
|
|
184
|
+
: new LaneStore(rootOrStore, options);
|
|
185
|
+
this.clock = options.clock ?? this.store.clock;
|
|
186
|
+
this.defaultLeaseDurationMs = requirePositiveDuration(options.defaultLeaseDurationMs ?? 300_000);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nowMs() {
|
|
190
|
+
const now = milliseconds(this.clock.now());
|
|
191
|
+
if (!Number.isFinite(now)) throw new TypeError('OpenPrd runtime clock returned an invalid time.');
|
|
192
|
+
return now;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
createLane(input) {
|
|
196
|
+
return this.store.create(input);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ensureLane(input) {
|
|
200
|
+
return this.store.ensure(input);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getLane(laneId) {
|
|
204
|
+
return this.store.read(laneId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
listLanes() {
|
|
208
|
+
return this.store.list();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async addTask(laneId, input, options = {}) {
|
|
212
|
+
const nowMs = this.nowMs();
|
|
213
|
+
const task = createTaskState(input, nowMs);
|
|
214
|
+
const transaction = await this.store.transact(laneId, {
|
|
215
|
+
expectedRevision: options.expectedLaneRevision,
|
|
216
|
+
operation: 'add-task',
|
|
217
|
+
}, (lane) => {
|
|
218
|
+
if (Object.hasOwn(lane.tasks, task.taskId)) {
|
|
219
|
+
throw revisionConflictError({
|
|
220
|
+
laneId: lane.laneId,
|
|
221
|
+
taskId: task.taskId,
|
|
222
|
+
expectedRevision: -1,
|
|
223
|
+
actualRevision: lane.tasks[task.taskId].revision,
|
|
224
|
+
operation: 'add-task',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
lane.tasks[task.taskId] = task;
|
|
228
|
+
return { result: { taskId: task.taskId } };
|
|
229
|
+
});
|
|
230
|
+
return taskResult(transaction, task.taskId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async claimTask(laneId, taskIdValue, options = {}) {
|
|
234
|
+
const taskId = normalizeRuntimeId(taskIdValue, 'taskId');
|
|
235
|
+
const nowMs = this.nowMs();
|
|
236
|
+
const nowIso = isoTime(nowMs);
|
|
237
|
+
const leaseDurationMs = requirePositiveDuration(options.leaseDurationMs ?? this.defaultLeaseDurationMs);
|
|
238
|
+
const transaction = await this.store.transact(laneId, {
|
|
239
|
+
expectedRevision: options.expectedLaneRevision,
|
|
240
|
+
operation: 'claim-task',
|
|
241
|
+
}, (lane) => {
|
|
242
|
+
const recovered = recoverExpiredClaims(lane, nowMs, nowIso);
|
|
243
|
+
const task = taskFrom(lane, taskId);
|
|
244
|
+
revisionCheck(lane.laneId, task, options.expectedRevision, 'claim-task');
|
|
245
|
+
|
|
246
|
+
if (activeClaim(task, nowMs)) {
|
|
247
|
+
throw taskAlreadyClaimedError({
|
|
248
|
+
laneId: lane.laneId,
|
|
249
|
+
taskId,
|
|
250
|
+
activeClaimId: task.claim.claimId,
|
|
251
|
+
activeBinding: claimBinding(task.claim),
|
|
252
|
+
leaseExpiresAt: task.claim.leaseExpiresAt,
|
|
253
|
+
reason: 'active-claim',
|
|
254
|
+
operation: 'claim-task',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (task.status !== 'pending') {
|
|
258
|
+
throw taskAlreadyClaimedError({
|
|
259
|
+
laneId: lane.laneId,
|
|
260
|
+
taskId,
|
|
261
|
+
activeClaimId: null,
|
|
262
|
+
taskStatus: task.status,
|
|
263
|
+
reason: 'terminal-task',
|
|
264
|
+
operation: 'claim-task',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const writeSet = normalizeWriteSet(options.writeSet ?? task.writeSet);
|
|
269
|
+
for (const otherTask of Object.values(lane.tasks)) {
|
|
270
|
+
if (otherTask.taskId === taskId || !activeClaim(otherTask, nowMs)) continue;
|
|
271
|
+
const comparison = explainWriteSetIntersection(writeSet, otherTask.claim.writeSet ?? otherTask.writeSet);
|
|
272
|
+
if (!comparison.intersects) continue;
|
|
273
|
+
throw writeSetConflictError({
|
|
274
|
+
laneId: lane.laneId,
|
|
275
|
+
taskId,
|
|
276
|
+
writeSet,
|
|
277
|
+
conflictingTaskId: otherTask.taskId,
|
|
278
|
+
conflictingClaimId: otherTask.claim.claimId,
|
|
279
|
+
conflictingBinding: claimBinding(otherTask.claim),
|
|
280
|
+
conflictingWriteSet: otherTask.claim.writeSet ?? otherTask.writeSet,
|
|
281
|
+
overlap: {
|
|
282
|
+
requestedPattern: comparison.leftPattern,
|
|
283
|
+
activePattern: comparison.rightPattern,
|
|
284
|
+
overlapPattern: comparison.overlapPattern,
|
|
285
|
+
conservative: comparison.conservative,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
task.claimGeneration += 1;
|
|
291
|
+
task.attemptCount += 1;
|
|
292
|
+
const claim = {
|
|
293
|
+
claimId: normalizeOptionalText(options.claimId) ?? crypto.randomUUID(),
|
|
294
|
+
generation: task.claimGeneration,
|
|
295
|
+
...claimBinding(options),
|
|
296
|
+
writeSet,
|
|
297
|
+
claimedAt: nowIso,
|
|
298
|
+
heartbeatAt: nowIso,
|
|
299
|
+
heartbeatSequence: 0,
|
|
300
|
+
leaseDurationMs,
|
|
301
|
+
leaseExpiresAt: isoTime(nowMs + leaseDurationMs),
|
|
302
|
+
};
|
|
303
|
+
task.status = 'claimed';
|
|
304
|
+
task.claim = claim;
|
|
305
|
+
task.revision += 1;
|
|
306
|
+
task.updatedAt = nowIso;
|
|
307
|
+
return { result: { claim: clone(claim), recovered } };
|
|
308
|
+
});
|
|
309
|
+
return taskResult(transaction, taskId, {
|
|
310
|
+
claim: clone(transaction.result.claim),
|
|
311
|
+
recovered: clone(transaction.result.recovered),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async heartbeatTask(laneId, taskIdValue, claimValue, options = {}) {
|
|
316
|
+
const taskId = normalizeRuntimeId(taskIdValue, 'taskId');
|
|
317
|
+
const claimId = claimIdFrom(claimValue);
|
|
318
|
+
const nowMs = this.nowMs();
|
|
319
|
+
const nowIso = isoTime(nowMs);
|
|
320
|
+
const transaction = await this.store.transact(laneId, {
|
|
321
|
+
expectedRevision: options.expectedLaneRevision,
|
|
322
|
+
operation: 'heartbeat-task',
|
|
323
|
+
}, (lane) => {
|
|
324
|
+
const task = taskFrom(lane, taskId);
|
|
325
|
+
const claim = assertCurrentClaim(lane, task, claimId, nowMs, 'heartbeat-task');
|
|
326
|
+
revisionCheck(lane.laneId, task, options.expectedRevision, 'heartbeat-task');
|
|
327
|
+
const leaseDurationMs = requirePositiveDuration(options.leaseDurationMs ?? claim.leaseDurationMs);
|
|
328
|
+
claim.heartbeatAt = nowIso;
|
|
329
|
+
claim.heartbeatSequence = Number(claim.heartbeatSequence ?? 0) + 1;
|
|
330
|
+
claim.leaseDurationMs = leaseDurationMs;
|
|
331
|
+
claim.leaseExpiresAt = isoTime(nowMs + leaseDurationMs);
|
|
332
|
+
if (options.evidence !== undefined) {
|
|
333
|
+
claim.evidence = mergeEvidence(claim.evidence, options.evidence);
|
|
334
|
+
}
|
|
335
|
+
task.revision += 1;
|
|
336
|
+
task.updatedAt = nowIso;
|
|
337
|
+
return { result: { claim: clone(claim) } };
|
|
338
|
+
});
|
|
339
|
+
return taskResult(transaction, taskId, { claim: clone(transaction.result.claim) });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async releaseTask(laneId, taskIdValue, claimValue, options = {}) {
|
|
343
|
+
const taskId = normalizeRuntimeId(taskIdValue, 'taskId');
|
|
344
|
+
const claimId = claimIdFrom(claimValue);
|
|
345
|
+
const nowMs = this.nowMs();
|
|
346
|
+
const nowIso = isoTime(nowMs);
|
|
347
|
+
const transaction = await this.store.transact(laneId, {
|
|
348
|
+
expectedRevision: options.expectedLaneRevision,
|
|
349
|
+
operation: 'release-task',
|
|
350
|
+
}, (lane) => {
|
|
351
|
+
const task = taskFrom(lane, taskId);
|
|
352
|
+
if (!task.claim && task.lastClaim?.claimId === claimId && task.lastClaim?.outcome === 'released') {
|
|
353
|
+
return { changed: false, result: { idempotent: true } };
|
|
354
|
+
}
|
|
355
|
+
const claim = assertCurrentClaim(lane, task, claimId, nowMs, 'release-task');
|
|
356
|
+
revisionCheck(lane.laneId, task, options.expectedRevision, 'release-task');
|
|
357
|
+
task.status = 'pending';
|
|
358
|
+
task.claim = null;
|
|
359
|
+
task.lastClaim = {
|
|
360
|
+
...clone(claim),
|
|
361
|
+
outcome: 'released',
|
|
362
|
+
reason: normalizeOptionalText(options.reason),
|
|
363
|
+
evidence: mergeEvidence(claim.evidence, options.evidence),
|
|
364
|
+
endedAt: nowIso,
|
|
365
|
+
};
|
|
366
|
+
task.revision += 1;
|
|
367
|
+
task.updatedAt = nowIso;
|
|
368
|
+
return { result: { idempotent: false } };
|
|
369
|
+
});
|
|
370
|
+
return taskResult(transaction, taskId, { idempotent: transaction.result.idempotent });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async finishTask(laneId, taskIdValue, claimValue, options = {}) {
|
|
374
|
+
const taskId = normalizeRuntimeId(taskIdValue, 'taskId');
|
|
375
|
+
const claimId = claimIdFrom(claimValue);
|
|
376
|
+
const nowMs = this.nowMs();
|
|
377
|
+
const nowIso = isoTime(nowMs);
|
|
378
|
+
const finishStatus = options.status ?? 'completed';
|
|
379
|
+
if (!FINISH_STATUSES.has(finishStatus)) throw new TypeError(`Unsupported OpenPrd finish status: ${finishStatus}`);
|
|
380
|
+
|
|
381
|
+
const transaction = await this.store.transact(laneId, {
|
|
382
|
+
expectedRevision: options.expectedLaneRevision,
|
|
383
|
+
operation: 'finish-task',
|
|
384
|
+
}, (lane) => {
|
|
385
|
+
const task = taskFrom(lane, taskId);
|
|
386
|
+
const previous = task.lastClaim;
|
|
387
|
+
if (!task.claim && previous?.claimId === claimId && previous?.outcome === 'finished') {
|
|
388
|
+
if ((options.status !== undefined && previous.status !== finishStatus)
|
|
389
|
+
|| (options.result !== undefined && JSON.stringify(previous.result) !== JSON.stringify(options.result))) {
|
|
390
|
+
throw revisionConflictError({
|
|
391
|
+
laneId: lane.laneId,
|
|
392
|
+
taskId,
|
|
393
|
+
expectedRevision: options.expectedRevision ?? task.revision,
|
|
394
|
+
actualRevision: task.revision,
|
|
395
|
+
operation: 'finish-task',
|
|
396
|
+
reason: 'idempotent-payload-mismatch',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return { changed: false, result: { idempotent: true } };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const claim = assertCurrentClaim(lane, task, claimId, nowMs, 'finish-task');
|
|
403
|
+
revisionCheck(lane.laneId, task, options.expectedRevision, 'finish-task');
|
|
404
|
+
const evidence = mergeEvidence(claim.evidence, options.evidence);
|
|
405
|
+
const result = clone(options.result ?? null);
|
|
406
|
+
task.status = finishStatus;
|
|
407
|
+
task.claim = null;
|
|
408
|
+
task.result = result;
|
|
409
|
+
task.evidence = mergeEvidence(task.evidence, evidence);
|
|
410
|
+
task.lastClaim = {
|
|
411
|
+
...clone(claim),
|
|
412
|
+
outcome: 'finished',
|
|
413
|
+
status: finishStatus,
|
|
414
|
+
result,
|
|
415
|
+
evidence,
|
|
416
|
+
endedAt: nowIso,
|
|
417
|
+
};
|
|
418
|
+
task.revision += 1;
|
|
419
|
+
task.updatedAt = nowIso;
|
|
420
|
+
return { result: { idempotent: false } };
|
|
421
|
+
});
|
|
422
|
+
return taskResult(transaction, taskId, { idempotent: transaction.result.idempotent });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async recoverExpired(laneId, options = {}) {
|
|
426
|
+
const nowMs = this.nowMs();
|
|
427
|
+
const nowIso = isoTime(nowMs);
|
|
428
|
+
const onlyTaskId = options.taskId === undefined ? null : normalizeRuntimeId(options.taskId, 'taskId');
|
|
429
|
+
const transaction = await this.store.transact(laneId, {
|
|
430
|
+
expectedRevision: options.expectedLaneRevision,
|
|
431
|
+
operation: 'recover-expired',
|
|
432
|
+
}, (lane) => {
|
|
433
|
+
if (onlyTaskId !== null) {
|
|
434
|
+
const task = taskFrom(lane, onlyTaskId);
|
|
435
|
+
revisionCheck(lane.laneId, task, options.expectedRevision, 'recover-expired');
|
|
436
|
+
}
|
|
437
|
+
const recovered = recoverExpiredClaims(lane, nowMs, nowIso, onlyTaskId);
|
|
438
|
+
return recovered.length > 0
|
|
439
|
+
? { result: { recovered } }
|
|
440
|
+
: { changed: false, result: { recovered: [] } };
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
laneId: transaction.lane.laneId,
|
|
444
|
+
laneRevision: transaction.lane.revision,
|
|
445
|
+
recovered: clone(transaction.result.recovered),
|
|
446
|
+
changed: transaction.changed,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 核心功能
|
|
3
|
+
* 将 Lane/Task Runtime 公共 API 暴露为稳定 CLI workspace 契约。
|
|
4
|
+
*
|
|
5
|
+
* 输入
|
|
6
|
+
* 接收 action、lane/task、claim/lease、write-set 与客户端绑定信息。
|
|
7
|
+
*
|
|
8
|
+
* 输出
|
|
9
|
+
* 返回结构化运行时结果与稳定错误码,供 Codex、Claude Code、Cursor 共用。
|
|
10
|
+
*
|
|
11
|
+
* 定位
|
|
12
|
+
* 位于 CLI 应用层与 runtime 领域层之间,不实现锁和 claim 规则。
|
|
13
|
+
*
|
|
14
|
+
* 维护规则
|
|
15
|
+
* 新 action 必须保留 JSON 返回字段与退出码兼容;所有测试必须使用临时项目目录。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { ensureProjectManifest, readProjectManifest } from '../kernel/index.js';
|
|
20
|
+
import { TaskRuntime } from './task-runtime.js';
|
|
21
|
+
|
|
22
|
+
const MUTATING_ACTIONS = new Set([
|
|
23
|
+
'create-lane',
|
|
24
|
+
'ensure-lane',
|
|
25
|
+
'add-task',
|
|
26
|
+
'claim',
|
|
27
|
+
'heartbeat',
|
|
28
|
+
'release',
|
|
29
|
+
'finish',
|
|
30
|
+
'recover',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function list(value) {
|
|
34
|
+
if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
|
|
35
|
+
return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function required(value, label) {
|
|
39
|
+
const normalized = String(value ?? '').trim();
|
|
40
|
+
if (!normalized) throw new TypeError(`runtime ${label} is required.`);
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function number(value) {
|
|
45
|
+
if (value === null || value === undefined || value === '') return undefined;
|
|
46
|
+
const parsed = Number(value);
|
|
47
|
+
if (!Number.isFinite(parsed)) throw new TypeError('runtime numeric option must be finite.');
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runtimeRoot(projectRoot) {
|
|
52
|
+
return path.join(path.resolve(projectRoot), '.openprd', 'runtime');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function taskRuntimeWorkspaceImpl(projectRoot, options = {}) {
|
|
56
|
+
const action = String(options.action ?? 'status').trim() || 'status';
|
|
57
|
+
if (MUTATING_ACTIONS.has(action)) await ensureProjectManifest(projectRoot);
|
|
58
|
+
else await readProjectManifest(projectRoot, { mode: 'read' });
|
|
59
|
+
const runtime = new TaskRuntime(runtimeRoot(projectRoot), {
|
|
60
|
+
defaultLeaseDurationMs: number(options.leaseDurationMs) ?? 300_000,
|
|
61
|
+
});
|
|
62
|
+
const laneId = options.laneId ?? options.lane;
|
|
63
|
+
const taskId = options.taskId ?? options.task;
|
|
64
|
+
let result;
|
|
65
|
+
|
|
66
|
+
if (action === 'status') {
|
|
67
|
+
result = laneId ? await runtime.getLane(laneId) : await runtime.listLanes();
|
|
68
|
+
} else if (action === 'create-lane' || action === 'ensure-lane') {
|
|
69
|
+
const input = {
|
|
70
|
+
laneId: required(laneId, 'lane'),
|
|
71
|
+
title: options.title ?? laneId,
|
|
72
|
+
tasks: options.tasks ?? [],
|
|
73
|
+
metadata: options.metadata ?? {},
|
|
74
|
+
};
|
|
75
|
+
result = action === 'create-lane'
|
|
76
|
+
? await runtime.createLane(input)
|
|
77
|
+
: await runtime.ensureLane(input);
|
|
78
|
+
} else if (action === 'add-task') {
|
|
79
|
+
result = await runtime.addTask(required(laneId, 'lane'), {
|
|
80
|
+
taskId: required(taskId, 'task'),
|
|
81
|
+
title: options.title ?? taskId,
|
|
82
|
+
writeSet: list(options.writeSet),
|
|
83
|
+
metadata: options.metadata ?? {},
|
|
84
|
+
}, {
|
|
85
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
86
|
+
});
|
|
87
|
+
} else if (action === 'claim') {
|
|
88
|
+
result = await runtime.claimTask(required(laneId, 'lane'), required(taskId, 'task'), {
|
|
89
|
+
claimId: options.claimId,
|
|
90
|
+
client: options.client ?? options.agent,
|
|
91
|
+
session: options.session,
|
|
92
|
+
thread: options.thread,
|
|
93
|
+
worktree: options.worktree,
|
|
94
|
+
branch: options.branch,
|
|
95
|
+
baseCommit: options.baseCommit,
|
|
96
|
+
evidence: options.evidence,
|
|
97
|
+
writeSet: list(options.writeSet),
|
|
98
|
+
leaseDurationMs: number(options.leaseDurationMs),
|
|
99
|
+
expectedRevision: number(options.expectedRevision),
|
|
100
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
101
|
+
});
|
|
102
|
+
} else if (action === 'heartbeat') {
|
|
103
|
+
result = await runtime.heartbeatTask(
|
|
104
|
+
required(laneId, 'lane'),
|
|
105
|
+
required(taskId, 'task'),
|
|
106
|
+
required(options.claimId, 'claim-id'),
|
|
107
|
+
{
|
|
108
|
+
evidence: options.evidence,
|
|
109
|
+
leaseDurationMs: number(options.leaseDurationMs),
|
|
110
|
+
expectedRevision: number(options.expectedRevision),
|
|
111
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
} else if (action === 'release') {
|
|
115
|
+
result = await runtime.releaseTask(
|
|
116
|
+
required(laneId, 'lane'),
|
|
117
|
+
required(taskId, 'task'),
|
|
118
|
+
required(options.claimId, 'claim-id'),
|
|
119
|
+
{
|
|
120
|
+
reason: options.reason,
|
|
121
|
+
evidence: options.evidence,
|
|
122
|
+
expectedRevision: number(options.expectedRevision),
|
|
123
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
} else if (action === 'finish') {
|
|
127
|
+
result = await runtime.finishTask(
|
|
128
|
+
required(laneId, 'lane'),
|
|
129
|
+
required(taskId, 'task'),
|
|
130
|
+
required(options.claimId, 'claim-id'),
|
|
131
|
+
{
|
|
132
|
+
status: options.status ?? 'completed',
|
|
133
|
+
result: options.result ?? null,
|
|
134
|
+
evidence: options.evidence,
|
|
135
|
+
expectedRevision: number(options.expectedRevision),
|
|
136
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
} else if (action === 'recover') {
|
|
140
|
+
result = await runtime.recoverExpired(required(laneId, 'lane'), {
|
|
141
|
+
...(taskId ? { taskId } : {}),
|
|
142
|
+
expectedLaneRevision: number(options.expectedLaneRevision),
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
throw new TypeError(`Unsupported runtime action: ${action}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
action: `runtime-${action}`,
|
|
151
|
+
projectRoot: path.resolve(projectRoot),
|
|
152
|
+
runtimeRoot: runtimeRoot(projectRoot),
|
|
153
|
+
laneId: laneId ?? result?.laneId ?? null,
|
|
154
|
+
taskId: taskId ?? result?.task?.taskId ?? null,
|
|
155
|
+
result,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function taskRuntimeWorkspace(projectRoot, options = {}) {
|
|
160
|
+
try {
|
|
161
|
+
return await taskRuntimeWorkspaceImpl(projectRoot, options);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
action: `runtime-${options.action ?? 'status'}`,
|
|
166
|
+
projectRoot: path.resolve(projectRoot),
|
|
167
|
+
runtimeRoot: runtimeRoot(projectRoot),
|
|
168
|
+
laneId: options.laneId ?? options.lane ?? null,
|
|
169
|
+
taskId: options.taskId ?? options.task ?? null,
|
|
170
|
+
error: {
|
|
171
|
+
code: error?.code ?? 'OPENPRD_RUNTIME_ERROR',
|
|
172
|
+
message: error instanceof Error ? error.message : String(error),
|
|
173
|
+
details: Object.fromEntries(Object.entries(error ?? {}).filter(([key]) => key !== 'stack')),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { MUTATING_ACTIONS, runtimeRoot, taskRuntimeWorkspace, taskRuntimeWorkspaceImpl };
|