@zq-silk/yui 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/artifactCapability.js +14 -8
- package/dist/artifacts/artifactCommitLock.js +7 -5
- package/dist/artifacts/jobArtifact.js +93 -0
- package/dist/artifacts/jobArtifactTransport.js +13 -0
- package/dist/artifacts/taskArtifactRepository.js +58 -10
- package/dist/cli/commandCatalog.js +45 -14
- package/dist/cli/commandDiscovery.js +1 -1
- package/dist/commands/durableJobCommands.js +31 -6
- package/dist/commands/globalRoleCommands.js +5 -0
- package/dist/commands/scheduleCommands.js +14 -3
- package/dist/commands/taskCommands.js +5 -2
- package/dist/context/taskCatalog.js +6 -1
- package/dist/controlPlaneCli.js +7 -2
- package/dist/controller/agentJobControl.js +391 -0
- package/dist/controller/controller.js +24 -3
- package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
- package/dist/controller/globalInputDelivery.js +8 -1
- package/dist/controller/jobArtifactControl.js +111 -0
- package/dist/controller/jobControl.js +4 -0
- package/dist/controller/jobDefinitionControl.js +191 -19
- package/dist/controller/jobSupervisor.js +83 -3
- package/dist/controller/jobTaskDelegationControl.js +193 -0
- package/dist/controller/runtime.js +7 -1
- package/dist/controller/scheduleControl.js +83 -20
- package/dist/integration/gitIntegrationService.js +2 -0
- package/dist/job/agentJob.js +131 -0
- package/dist/job/agentJobObservation.js +61 -0
- package/dist/job/agentRoleScope.js +33 -0
- package/dist/job/durableJob.js +63 -28
- package/dist/job/jobAssignmentScope.js +1 -0
- package/dist/job/jobBusinessResult.js +72 -0
- package/dist/job/jobCleanupInspection.js +4 -0
- package/dist/job/jobDefinition.js +26 -2
- package/dist/job/jobRunner.js +4 -3
- package/dist/job/projectRefreshRunner.js +54 -18
- package/dist/job/projectSync.js +162 -0
- package/dist/job/scheduleService.js +55 -0
- package/dist/job/taskDelegation.js +48 -0
- package/dist/message/messageContinuation.js +1 -1
- package/dist/resources/projectResourceService.js +1 -1
- package/dist/resources/resourceGc.js +1 -1
- package/dist/role/role.js +12 -0
- package/dist/schedule/schedule.js +65 -15
- package/dist/scheduler/wakeReason.js +4 -0
- package/dist/storage/agentJobSchema.js +10 -0
- package/dist/storage/recordValidation.js +2 -0
- package/dist/storage/sqliteSchema.js +77 -6
- package/dist/storage/sqliteStore.js +53 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +3 -0
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
- package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
- package/dist/task/archivePreflight.js +15 -0
- package/dist/task/completionReadiness.js +7 -0
- package/dist/task/task.js +17 -1
- package/dist/verification/verificationGateService.js +4 -0
- package/dist/web/assets/client/app/catalog.js +16 -0
- package/dist/web/assets/client/i18n/en.js +29 -2
- package/dist/web/assets/client/i18n/zh-CN.js +29 -2
- package/dist/web/assets/client/layout/manageShell.js +9 -0
- package/dist/web/assets/client/layout/router.js +1 -0
- package/dist/web/assets/client/views/jobs.js +166 -37
- package/dist/web/assets/client/views/task/files.js +5 -4
- package/dist/web/assets/client/views/task/page.js +5 -1
- package/dist/web/taskPreviews.js +2 -2
- package/dist/web/webJobs.js +36 -11
- package/dist/web/webMaterials.js +21 -9
- package/dist/web/webServer.js +6 -5
- package/dist/web/webTaskSurface.js +1 -0
- package/docs/job-runs.md +392 -17
- package/docs/release-workflow.md +42 -0
- package/docs/release-workflow.zh-CN.md +31 -0
- package/docs/schedules.md +69 -15
- package/docs/storage-baseline.md +38 -1
- package/docs/storage-baseline.zh-CN.md +27 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** A Job execution role is not a second Operator. Normal global Roles retain
|
|
2
|
+
* their contract; this narrows only explicitly Job-owned managed CLI callers.
|
|
3
|
+
* Job RPCs still authorize exact scope/Run and current native identity. */
|
|
4
|
+
export function assertJobAgentCommand(role, args) {
|
|
5
|
+
if (!role.jobOwner)
|
|
6
|
+
return;
|
|
7
|
+
const [command, action, operation, target] = args;
|
|
8
|
+
const allowed = command === "job" && ["show", "runs", "run-get", "run-result", "run-context",
|
|
9
|
+
"run-claim", "run-report", "run-evidence", "run-confirm", "artifact-save", "artifact-list",
|
|
10
|
+
"artifact-read", "artifact-preview", "artifact-register"].includes(action ?? "")
|
|
11
|
+
|| command === "session" && (action === "skills" || action === "context" && operation === role.name)
|
|
12
|
+
|| command === "role" && action === "message" && ["list", "show"].includes(operation ?? "") && target === role.name
|
|
13
|
+
|| command === "inbox" && ["list", "show", "claim", "ack"].includes(action ?? "")
|
|
14
|
+
|| ["help", "version"].includes(command ?? "");
|
|
15
|
+
if (!allowed)
|
|
16
|
+
throw new Error("Job Agent authority is limited to its own context, inbox and exact Job execution; management requires its owner.");
|
|
17
|
+
}
|
|
18
|
+
/** Global runtime transport does not give a Job's execution Role global authority. */
|
|
19
|
+
export function assertJobRoleRunnable(store, role) {
|
|
20
|
+
const owner = role.jobOwner;
|
|
21
|
+
if (!owner)
|
|
22
|
+
return;
|
|
23
|
+
const job = store.getJobDefinition(owner.scope, owner.jobId);
|
|
24
|
+
if (!job || job.configuration.kind !== "agent" || job.configuration.target.kind !== "job"
|
|
25
|
+
|| job.configuration.target.agentId !== role.activeAgentId)
|
|
26
|
+
throw new Error("Job-owned Role configuration changed; inspect the original Job and pending input.");
|
|
27
|
+
if (owner.scope.kind === "task") {
|
|
28
|
+
const task = store.getTask(owner.scope.taskId);
|
|
29
|
+
if (task?.status !== "active" || task.executionGate.state !== "enabled") {
|
|
30
|
+
throw new Error("Job-owned Role cannot execute while its owning Task is stopped or terminal.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/job/durableJob.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { artifactsAppendOnly, validateRunArtifacts } from "../artifacts/jobArtifact.js";
|
|
3
|
+
import { processBusinessResult, runReference, validateBusinessResult } from "./jobBusinessResult.js";
|
|
4
|
+
import { validateTaskDelegation } from "./taskDelegation.js";
|
|
5
|
+
import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
3
6
|
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
4
7
|
import { recordOperationEvidence, validateOperationFacts } from "../kernel/operationFacts.js";
|
|
5
8
|
export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 1;
|
|
@@ -82,7 +85,7 @@ export function touchDurableJobHeartbeat(job, now) {
|
|
|
82
85
|
updatedAt: now.toISOString()
|
|
83
86
|
});
|
|
84
87
|
}
|
|
85
|
-
export function completeDurableJob(job, result, now) {
|
|
88
|
+
export function completeDurableJob(job, result, now, businessResult = processBusinessResult(job, result)) {
|
|
86
89
|
validateDurableJob(job);
|
|
87
90
|
if (job.status !== "running") {
|
|
88
91
|
throw new Error(`DurableJob can only complete from running: ${job.status}.`);
|
|
@@ -93,13 +96,14 @@ export function completeDurableJob(job, result, now) {
|
|
|
93
96
|
const timestamp = now.toISOString();
|
|
94
97
|
return validateDurableJob({
|
|
95
98
|
...job,
|
|
96
|
-
status:
|
|
99
|
+
status: businessResult.outcome,
|
|
97
100
|
operation: recordOperationEvidence(job.operation, {
|
|
98
101
|
partialResultRefs: result.steps.map((step) => step.logPath),
|
|
99
102
|
receiptRefs: result.evidenceSource === undefined ? []
|
|
100
103
|
: [`${job.artifactsLocator}/${result.evidenceSource === "checkpoint" ? "checkpoint.json" : "exit.json"}`]
|
|
101
104
|
}),
|
|
102
105
|
result: normalizeDurableJobResult(result),
|
|
106
|
+
businessResult: validateBusinessResult(businessResult, runReference(job)),
|
|
103
107
|
terminalAt: timestamp,
|
|
104
108
|
updatedAt: timestamp
|
|
105
109
|
});
|
|
@@ -111,13 +115,14 @@ export function rejectQueuedDurableJob(job, reason, now) {
|
|
|
111
115
|
throw new Error("Only an unattempted queued Job can be rejected.");
|
|
112
116
|
}
|
|
113
117
|
const timestamp = now.toISOString();
|
|
118
|
+
const result = {
|
|
119
|
+
outcome: "failed", exitCode: null, signal: null,
|
|
120
|
+
unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
|
|
121
|
+
};
|
|
114
122
|
return validateDurableJob({
|
|
115
123
|
...job,
|
|
116
124
|
status: "failed",
|
|
117
|
-
result:
|
|
118
|
-
outcome: "failed", exitCode: null, signal: null,
|
|
119
|
-
unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
|
|
120
|
-
},
|
|
125
|
+
result, businessResult: processBusinessResult(job, result),
|
|
121
126
|
terminalAt: timestamp,
|
|
122
127
|
updatedAt: timestamp
|
|
123
128
|
});
|
|
@@ -128,19 +133,18 @@ export function markDurableJobUnknown(job, unknownReason, completedSteps, now) {
|
|
|
128
133
|
throw new Error(`DurableJob can only be marked unknown after an attempted effect: ${job.status}.`);
|
|
129
134
|
}
|
|
130
135
|
const timestamp = now.toISOString();
|
|
136
|
+
const result = {
|
|
137
|
+
outcome: "unknown-needs-attention", exitCode: null, signal: null,
|
|
138
|
+
unknownReason: requireText(unknownReason, "DurableJob unknown reason"),
|
|
139
|
+
steps: completedSteps.map(step => ({ ...step }))
|
|
140
|
+
};
|
|
131
141
|
return validateDurableJob({
|
|
132
142
|
...job,
|
|
133
143
|
status: "unknown-needs-attention",
|
|
134
144
|
operation: recordOperationEvidence(job.operation, {
|
|
135
145
|
partialResultRefs: completedSteps.map((step) => step.logPath)
|
|
136
146
|
}),
|
|
137
|
-
result:
|
|
138
|
-
outcome: "unknown-needs-attention",
|
|
139
|
-
exitCode: null,
|
|
140
|
-
signal: null,
|
|
141
|
-
unknownReason: requireText(unknownReason, "DurableJob unknown reason"),
|
|
142
|
-
steps: completedSteps.map((step) => ({ ...step }))
|
|
143
|
-
},
|
|
147
|
+
result, businessResult: processBusinessResult(job, result),
|
|
144
148
|
terminalAt: timestamp,
|
|
145
149
|
updatedAt: timestamp
|
|
146
150
|
});
|
|
@@ -173,15 +177,11 @@ export function cancelQueuedDurableJob(job, now) {
|
|
|
173
177
|
throw new Error(`DurableJob can only cancel-queued from queued: ${job.status}.`);
|
|
174
178
|
}
|
|
175
179
|
const timestamp = now.toISOString();
|
|
180
|
+
const result = { outcome: "cancelled", exitCode: null, signal: null, steps: [] };
|
|
176
181
|
return validateDurableJob({
|
|
177
182
|
...job,
|
|
178
183
|
status: "cancelled",
|
|
179
|
-
result:
|
|
180
|
-
outcome: "cancelled",
|
|
181
|
-
exitCode: null,
|
|
182
|
-
signal: null,
|
|
183
|
-
steps: []
|
|
184
|
-
},
|
|
184
|
+
result, businessResult: processBusinessResult(job, result),
|
|
185
185
|
terminalAt: timestamp,
|
|
186
186
|
updatedAt: timestamp
|
|
187
187
|
});
|
|
@@ -235,6 +235,23 @@ export function retryDurableJobIdempotencyKey(previousKey, previousJobId) {
|
|
|
235
235
|
.digest("hex");
|
|
236
236
|
}
|
|
237
237
|
export function validateDurableJob(job) {
|
|
238
|
+
if (job.delegation !== undefined) {
|
|
239
|
+
validateTaskDelegation(job.delegation);
|
|
240
|
+
if (job.process !== undefined || job.result !== undefined || job.status === "queued") {
|
|
241
|
+
throw new Error("Task delegation cannot carry process execution/result evidence.");
|
|
242
|
+
}
|
|
243
|
+
if (job.steps !== undefined || job.env !== undefined || job.workspace !== undefined
|
|
244
|
+
|| job.head !== undefined || job.projectId !== undefined || job.checkpoint !== undefined
|
|
245
|
+
|| ("action" in job && job.action !== undefined)
|
|
246
|
+
|| ("actionInput" in job && job.actionInput !== undefined)
|
|
247
|
+
|| ("serviceAuthorization" in job && job.serviceAuthorization !== undefined)) {
|
|
248
|
+
throw new Error("Task delegation cannot contain a process specification.");
|
|
249
|
+
}
|
|
250
|
+
if (job.businessResult?.outcome === "succeeded" && !job.businessResult.evidence.some(ref => (ref.kind === "task-result" || ref.kind === "work-item-result") && ref.owner.taskId === job.delegation.taskId)) {
|
|
251
|
+
throw new Error("Successful delegation requires exact target delivery evidence.");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
validateRunArtifacts(job);
|
|
238
255
|
if (job.schemaVersion !== CURRENT_DURABLE_JOB_SCHEMA_VERSION) {
|
|
239
256
|
throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
|
|
240
257
|
}
|
|
@@ -248,17 +265,25 @@ export function validateDurableJob(job) {
|
|
|
248
265
|
if ("taskId" in job) {
|
|
249
266
|
validateTaskRecordReference({ taskId: job.taskId, localId: job.id }, "durableJob");
|
|
250
267
|
validateDurableJobOwner(job.owner, job.taskId);
|
|
251
|
-
|
|
268
|
+
if (!job.delegation)
|
|
269
|
+
requireIdentity(job.projectId, "DurableJob project id");
|
|
252
270
|
}
|
|
253
271
|
else {
|
|
254
272
|
if (job.scope !== "global")
|
|
255
273
|
throw new Error("Invalid global JobRun scope.");
|
|
256
274
|
requireIdentity(job.id, "JobRun id");
|
|
257
|
-
|
|
275
|
+
if (!job.delegation)
|
|
276
|
+
requireText(job.action, "JobRun builtin action");
|
|
277
|
+
if (job.serviceAuthorization !== undefined) {
|
|
278
|
+
requireKnownFields(job.serviceAuthorization, ["scheduleId", "grantId"], "Job Schedule service reference");
|
|
279
|
+
requireText(job.serviceAuthorization.scheduleId, "Schedule id");
|
|
280
|
+
requireText(job.serviceAuthorization.grantId, "Schedule grant id");
|
|
281
|
+
}
|
|
258
282
|
if (job.projectId !== undefined)
|
|
259
283
|
requireIdentity(job.projectId, "JobRun Project id");
|
|
260
284
|
}
|
|
261
|
-
|
|
285
|
+
if (!job.delegation)
|
|
286
|
+
validateJobExecutionConfiguration(job);
|
|
262
287
|
requireText(job.idempotencyKey, "DurableJob idempotency key");
|
|
263
288
|
if (!/^[a-f0-9]{64}$/u.test(job.idempotencyKey)) {
|
|
264
289
|
throw new Error("DurableJob idempotency key is invalid.");
|
|
@@ -302,6 +327,11 @@ export function validateDurableJob(job) {
|
|
|
302
327
|
if (job.result !== undefined) {
|
|
303
328
|
normalizeDurableJobResult(job.result);
|
|
304
329
|
}
|
|
330
|
+
if (job.businessResult !== undefined) {
|
|
331
|
+
validateBusinessResult(job.businessResult, runReference(job));
|
|
332
|
+
if (job.status !== job.businessResult.outcome)
|
|
333
|
+
throw new Error("Job status must match its business outcome.");
|
|
334
|
+
}
|
|
305
335
|
if (job.retryOf !== undefined) {
|
|
306
336
|
if ("taskId" in job)
|
|
307
337
|
validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
|
|
@@ -329,7 +359,10 @@ export function validateDurableJob(job) {
|
|
|
329
359
|
return job;
|
|
330
360
|
}
|
|
331
361
|
export function validDurableJobTransition(before, after) {
|
|
332
|
-
if (before.
|
|
362
|
+
if (!isDeepStrictEqual(before.delegation, after.delegation)
|
|
363
|
+
||
|
|
364
|
+
!artifactsAppendOnly(before, after)
|
|
365
|
+
|| before.id !== after.id
|
|
333
366
|
|| before.definitionId !== after.definitionId
|
|
334
367
|
|| before.definitionRevision !== after.definitionRevision
|
|
335
368
|
|| !isDeepStrictEqual(before.trigger, after.trigger)
|
|
@@ -341,7 +374,7 @@ export function validDurableJobTransition(before, after) {
|
|
|
341
374
|
|| before.idempotencyKey !== after.idempotencyKey
|
|
342
375
|
|| before.artifactsLocator !== after.artifactsLocator
|
|
343
376
|
|| before.createdAt !== after.createdAt
|
|
344
|
-
|| !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput], "owner" in after ? after.owner : [after.action, after.actionInput])
|
|
377
|
+
|| !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput, before.serviceAuthorization], "owner" in after ? after.owner : [after.action, after.actionInput, after.serviceAuthorization])
|
|
345
378
|
|| !isDeepStrictEqual(before.env, after.env)
|
|
346
379
|
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
347
380
|
|| before.operation.requestId !== after.operation.requestId
|
|
@@ -354,7 +387,9 @@ export function validDurableJobTransition(before, after) {
|
|
|
354
387
|
|| !isDeepStrictEqual(recordOperationEvidence(before.operation, after.operation), after.operation))
|
|
355
388
|
return false;
|
|
356
389
|
if (isDurableJobTerminal(before.status)) {
|
|
357
|
-
// A terminal job is immutable
|
|
390
|
+
// A terminal job's outcome is immutable. Artifact references can only append,
|
|
391
|
+
// permitting exact post-execution collection without rerunning business work.
|
|
392
|
+
// The two existing one-way flags remain:
|
|
358
393
|
// wakeupNotified (set by the supervisor after enqueuing the Leader
|
|
359
394
|
// wakeup) and acknowledgedAt (set by the Leader on an unknown job so
|
|
360
395
|
// Task lifecycle gates can proceed). Without these exceptions the
|
|
@@ -363,8 +398,8 @@ export function validDurableJobTransition(before, after) {
|
|
|
363
398
|
const wakeupFlip = before.wakeupNotified !== true && after.wakeupNotified === true;
|
|
364
399
|
const acknowledgeFlip = before.acknowledgedAt === undefined
|
|
365
400
|
&& after.acknowledgedAt !== undefined;
|
|
366
|
-
const { wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
|
|
367
|
-
const { wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
|
|
401
|
+
const { artifacts: _beforeArtifacts, wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
|
|
402
|
+
const { artifacts: _afterArtifacts, wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
|
|
368
403
|
return isDeepStrictEqual(beforeFacts, afterFacts)
|
|
369
404
|
&& before.status === after.status
|
|
370
405
|
&& isDeepStrictEqual(before.result, after.result)
|
|
@@ -13,6 +13,7 @@ export function assertJobAssignmentScope(store, target, caller) {
|
|
|
13
13
|
if (run?.status !== "active" || run.reviewRoundId !== undefined
|
|
14
14
|
|| target.owner.kind !== "work-item" || target.owner.workItemId !== run.workItemId
|
|
15
15
|
|| item?.status !== "open" || item.assignee !== caller.role
|
|
16
|
+
|| target.projectId === undefined || target.workspace === undefined
|
|
16
17
|
|| !item.writeProjectIds.includes(target.projectId)
|
|
17
18
|
|| !run.effective.writeProjectIds.includes(target.projectId)
|
|
18
19
|
|| workspace === undefined || resolve(workspace.root) !== resolve(target.workspace)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { requireKnownFields, requireText } from "../domain/validation.js";
|
|
3
|
+
export function runReference(run) {
|
|
4
|
+
if ("executor" in run)
|
|
5
|
+
return { scope: run.scope, runId: run.id };
|
|
6
|
+
return { scope: "taskId" in run ? { kind: "task", taskId: run.taskId } : { kind: "global" }, runId: run.id };
|
|
7
|
+
}
|
|
8
|
+
export function resultDigest(value) {
|
|
9
|
+
return createHash("sha256").update(value).digest("hex");
|
|
10
|
+
}
|
|
11
|
+
export function validateBusinessResult(result, run) {
|
|
12
|
+
requireKnownFields(result, ["schemaVersion", "sourceRun", "outcome", "summary", "data", "evidence"], "Job business result");
|
|
13
|
+
const matches = (ref) => {
|
|
14
|
+
requireKnownFields(ref, ["scope", "runId"], "Job result Run reference");
|
|
15
|
+
requireKnownFields(ref.scope, ref.scope?.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job result scope");
|
|
16
|
+
return ref.runId === run.runId && ref.scope.kind === run.scope.kind
|
|
17
|
+
&& (run.scope.kind !== "task" || (ref.scope.kind === "task" && ref.scope.taskId === run.scope.taskId));
|
|
18
|
+
};
|
|
19
|
+
if (result.schemaVersion !== 1 || !matches(result.sourceRun))
|
|
20
|
+
throw new Error("Job result source Run mismatch.");
|
|
21
|
+
if (!["succeeded", "failed", "cancelled", "timed-out", "unknown-needs-attention"].includes(result.outcome)) {
|
|
22
|
+
throw new Error("Invalid Job business outcome.");
|
|
23
|
+
}
|
|
24
|
+
requireText(result.summary, "Job result summary");
|
|
25
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 256 * 1024)
|
|
26
|
+
throw new Error("Job business result exceeds 256 KiB.");
|
|
27
|
+
if (!Array.isArray(result.evidence))
|
|
28
|
+
throw new Error("Job result evidence must be an array.");
|
|
29
|
+
for (const ref of result.evidence) {
|
|
30
|
+
requireKnownFields(ref, ["kind", "owner", "sourceRun", "version", "locator"], "Job result reference");
|
|
31
|
+
const taskEvidence = ref.kind === "task-result" || ref.kind === "work-item-result";
|
|
32
|
+
const ownerMatches = taskEvidence
|
|
33
|
+
? (requireKnownFields(ref.owner, ["kind", "taskId"], "Task result owner"),
|
|
34
|
+
ref.owner.kind === "task" && /^task-\d+$/u.test(ref.owner.taskId))
|
|
35
|
+
: matches(ref.owner);
|
|
36
|
+
if (!["run-record", "execution-evidence", "task-result", "work-item-result"].includes(ref.kind) || !ownerMatches || !matches(ref.sourceRun)
|
|
37
|
+
|| !/^[a-f0-9]{64}$/u.test(ref.version))
|
|
38
|
+
throw new Error("Invalid Job result evidence identity/version.");
|
|
39
|
+
requireText(ref.locator, "Job result evidence locator");
|
|
40
|
+
if (ref.locator.startsWith("/") || ref.locator.split("/").includes(".."))
|
|
41
|
+
throw new Error("Invalid Job evidence locator.");
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/** Shell and historical results describe only what the saved process evidence proves. */
|
|
46
|
+
export function processBusinessResult(run, result) {
|
|
47
|
+
const sourceRun = runReference(run);
|
|
48
|
+
const summary = result.unknownReason ?? `Execution ${result.outcome}${result.failedStep ? ` at ${result.failedStep}` : ""}.`;
|
|
49
|
+
return {
|
|
50
|
+
schemaVersion: 1, sourceRun, outcome: result.outcome,
|
|
51
|
+
summary: summary.length <= 4096 ? summary : `${summary.slice(0, 4096)}… (see original execution evidence)`,
|
|
52
|
+
evidence: [{
|
|
53
|
+
kind: "run-record", owner: sourceRun, sourceRun,
|
|
54
|
+
version: resultDigest(JSON.stringify(result)),
|
|
55
|
+
locator: `job-runs/${"taskId" in run ? run.taskId : "global"}/${run.id}/result`
|
|
56
|
+
}]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Derived only from the admitted Run; never read a mutable definition here. */
|
|
60
|
+
export function jobRunSnapshot(run) {
|
|
61
|
+
return {
|
|
62
|
+
...runReference(run), definitionId: run.definitionId, definitionRevision: run.definitionRevision,
|
|
63
|
+
owner: "owner" in run ? run.owner : null,
|
|
64
|
+
trigger: run.trigger, retryOf: run.retryOf ?? null,
|
|
65
|
+
executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" } : {
|
|
66
|
+
kind: "builtin", action: run.action, input: run.actionInput
|
|
67
|
+
},
|
|
68
|
+
target: run.delegation ? { taskId: run.delegation.taskId, workItemId: run.delegation.workItemId ?? null }
|
|
69
|
+
: { projectId: run.projectId ?? null, workspace: run.workspace, head: run.head },
|
|
70
|
+
execution: run.delegation ? null : { env: run.env, steps: run.steps, implementation: run.operation.implementation }
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -12,6 +12,10 @@ export function jobCleanupBlocker(job, home, phase = "before-removal") {
|
|
|
12
12
|
if (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined) {
|
|
13
13
|
return "Unknown Job result requires explicit acknowledgement.";
|
|
14
14
|
}
|
|
15
|
+
// A settled delegation owns no runner/workspace. Its target Task retains
|
|
16
|
+
// independent resource ownership and must pass its own cleanup inspection.
|
|
17
|
+
if (job.delegation)
|
|
18
|
+
return undefined;
|
|
15
19
|
if (job.status !== "unknown-needs-attention" && job.startedAt === undefined
|
|
16
20
|
&& job.process === undefined && job.operation.effect === "none")
|
|
17
21
|
return undefined;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
2
2
|
import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
|
|
3
|
+
import { validateAgentJobConfiguration } from "./agentJob.js";
|
|
4
|
+
import { validateTaskJobConfiguration } from "./taskDelegation.js";
|
|
3
5
|
export function jobScopeKey(scope) {
|
|
4
6
|
if (scope.kind === "global")
|
|
5
7
|
return "global";
|
|
@@ -9,7 +11,7 @@ export function jobScopeKey(scope) {
|
|
|
9
11
|
return scope.taskId;
|
|
10
12
|
}
|
|
11
13
|
export function validateJobDefinition(job) {
|
|
12
|
-
requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "createdAt", "updatedAt"], "Job");
|
|
14
|
+
requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "builtin", "instantiation", "createdAt", "updatedAt"], "Job");
|
|
13
15
|
if (job.schemaVersion !== 1)
|
|
14
16
|
throw new Error("Job schemaVersion must be 1.");
|
|
15
17
|
requireIdentity(job.id, "Job id");
|
|
@@ -19,12 +21,29 @@ export function validateJobDefinition(job) {
|
|
|
19
21
|
requirePositiveInteger(job.revision, "Job revision");
|
|
20
22
|
requireTimestamp(job.createdAt, "Job createdAt");
|
|
21
23
|
requireTimestamp(job.updatedAt, "Job updatedAt");
|
|
24
|
+
if (job.instantiation !== undefined) {
|
|
25
|
+
requireKnownFields(job.instantiation, ["sourceId", "sourceRevision", "key"], "Job instantiation");
|
|
26
|
+
requireIdentity(job.instantiation.sourceId, "Job source id");
|
|
27
|
+
requirePositiveInteger(job.instantiation.sourceRevision, "Job source revision");
|
|
28
|
+
requireText(job.instantiation.key, "Job instantiation key");
|
|
29
|
+
}
|
|
22
30
|
if (job.scope.kind !== "task" && job.scope.kind !== "global")
|
|
23
31
|
throw new Error("Invalid Job scope.");
|
|
24
32
|
requireKnownFields(job.scope, job.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job scope");
|
|
25
33
|
jobScopeKey(job.scope);
|
|
26
34
|
const config = job.configuration;
|
|
27
|
-
if (job.scope.kind
|
|
35
|
+
if (job.builtin !== undefined && (job.builtin !== "project-sync" || job.scope.kind !== "global"
|
|
36
|
+
|| config.kind !== "builtin" || config.action !== "project.refresh"
|
|
37
|
+
|| job.id !== `project-sync-${config.projectId}` || job.instantiation !== undefined)) {
|
|
38
|
+
throw new Error("Invalid builtin Project sync definition.");
|
|
39
|
+
}
|
|
40
|
+
if (config.kind === "agent") {
|
|
41
|
+
validateAgentJobConfiguration(config);
|
|
42
|
+
}
|
|
43
|
+
else if (config.kind === "task") {
|
|
44
|
+
validateTaskJobConfiguration(config);
|
|
45
|
+
}
|
|
46
|
+
else if (job.scope.kind === "global") {
|
|
28
47
|
if (config.kind !== "builtin")
|
|
29
48
|
throw new Error("Global Jobs require a controlled builtin action.");
|
|
30
49
|
requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
|
|
@@ -51,6 +70,9 @@ export function createJobDefinition(input, now) {
|
|
|
51
70
|
});
|
|
52
71
|
}
|
|
53
72
|
export function reviseJobDefinition(job, input, now) {
|
|
73
|
+
if (job.configuration.kind !== input.configuration.kind) {
|
|
74
|
+
throw new Error("Changing Job executor kind requires a new Job id; preserve the original execution history.");
|
|
75
|
+
}
|
|
54
76
|
return validateJobDefinition({
|
|
55
77
|
...job, title: input.title, configuration: structuredClone(input.configuration),
|
|
56
78
|
revision: job.revision + 1, updatedAt: now.toISOString()
|
|
@@ -58,6 +80,8 @@ export function reviseJobDefinition(job, input, now) {
|
|
|
58
80
|
}
|
|
59
81
|
/** Old execution IDs are preserved one-to-one; command equality never merges history. */
|
|
60
82
|
export function definitionFromTaskRun(run) {
|
|
83
|
+
if (run.delegation)
|
|
84
|
+
throw new Error("Task delegation already requires a Job definition.");
|
|
61
85
|
return createJobDefinition({
|
|
62
86
|
id: run.definitionId, scope: { kind: "task", taskId: run.taskId }, title: run.steps[0].name,
|
|
63
87
|
configuration: {
|
package/dist/job/jobRunner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { jobStepDirectory } from "./stepDirectory.js";
|
|
15
15
|
import { prepareHttpPreview, isHttpPreview } from "./httpPreview.js";
|
|
16
16
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
17
|
-
import {
|
|
17
|
+
import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
18
18
|
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
|
|
19
19
|
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
20
20
|
const SIGKILL_GRACE_MS = 2_000;
|
|
@@ -46,14 +46,15 @@ export async function runDurableJobRunner(specPath) {
|
|
|
46
46
|
// reads this file to adopt a queued job after a Controller crash.
|
|
47
47
|
//
|
|
48
48
|
// rr4/finding-4: The runner writes its own OS startIdentity so the
|
|
49
|
-
// supervisor can adopt without re-reading
|
|
49
|
+
// supervisor can adopt without re-reading the OS (which could return a
|
|
50
50
|
// reused PID's identity after the original runner exited).
|
|
51
51
|
const readyPath = join(artifactDir, "ready.json");
|
|
52
|
+
const startIdentity = currentProcessStartIdentity();
|
|
52
53
|
try {
|
|
53
54
|
const readyFd = openSync(readyPath, "wx", 0o600);
|
|
54
55
|
writeFileSync(readyFd, JSON.stringify({
|
|
55
56
|
pid: process.pid,
|
|
56
|
-
startIdentity
|
|
57
|
+
startIdentity,
|
|
57
58
|
startedAt: new Date().toISOString()
|
|
58
59
|
}, null, 2) + "\n");
|
|
59
60
|
closeSync(readyFd);
|
|
@@ -2,38 +2,74 @@
|
|
|
2
2
|
* Controlled Project domain action invoked by the artifact-only Job runner.
|
|
3
3
|
* Its catalog connection is read-only; only the shared domain service writes Git.
|
|
4
4
|
*/
|
|
5
|
-
import { resolve } from "node:path";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { SqliteTaskStore } from "../storage/sqliteStore.js";
|
|
8
8
|
import { authorizeGlobalJobStart } from "../controller/jobDefinitionControl.js";
|
|
9
9
|
import { refreshRegisteredProject } from "../repository/projectRefresh.js";
|
|
10
10
|
import { projectRefreshDigest } from "./builtinJob.js";
|
|
11
11
|
import { CliError } from "../errors/cliError.js";
|
|
12
|
+
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
13
|
+
import { runReference } from "./jobBusinessResult.js";
|
|
14
|
+
import { requireProjectSyncCheckout } from "./projectSync.js";
|
|
12
15
|
export async function runProjectRefresh(home, runId) {
|
|
13
16
|
const store = new SqliteTaskStore(home, { readonly: true });
|
|
14
17
|
try {
|
|
15
18
|
const run = store.getGlobalJobRun(runId);
|
|
16
|
-
if (!run || run.action !== "project.refresh"
|
|
17
|
-
|| run.operation.effect
|
|
19
|
+
if (!run || run.action !== "project.refresh"
|
|
20
|
+
|| !((run.status === "queued" && run.operation.effect === "possible")
|
|
21
|
+
|| (run.status === "running" && run.operation.effect === "confirmed"))
|
|
22
|
+
|| run.cancelRequestedAt) {
|
|
18
23
|
throw new Error("No active admitted project.refresh Run.");
|
|
19
24
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
let result;
|
|
26
|
+
try {
|
|
27
|
+
// A revoked/stale grant before Git is a known refusal. Persist its
|
|
28
|
+
// business receipt just like a refusal after waiting for maintenance.
|
|
29
|
+
authorizeGlobalJobStart(store, run);
|
|
30
|
+
result = await refreshRegisteredProject(store, run.projectId, {
|
|
31
|
+
automatic: true,
|
|
32
|
+
expectedDigest: projectRefreshDigest({
|
|
33
|
+
kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
|
|
34
|
+
}),
|
|
35
|
+
beforeRefresh: () => {
|
|
36
|
+
const current = store.getGlobalJobRun(runId);
|
|
37
|
+
if (!current || !["queued", "running"].includes(current.status) || current.cancelRequestedAt) {
|
|
38
|
+
throw new Error("Project refresh Run stopped while waiting for maintenance.");
|
|
39
|
+
}
|
|
40
|
+
authorizeGlobalJobStart(store, current);
|
|
41
|
+
const job = store.getJobDefinition({ kind: "global" }, current.definitionId);
|
|
42
|
+
if (job?.builtin)
|
|
43
|
+
requireProjectSyncCheckout(store, job);
|
|
30
44
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const details = error instanceof CliError ? error.details : {};
|
|
49
|
+
const partial = details.refresh;
|
|
50
|
+
const business = {
|
|
51
|
+
schemaVersion: 1, sourceRun: runReference(run),
|
|
52
|
+
outcome: partial?.changed === null ? "unknown-needs-attention" : "failed",
|
|
53
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
54
|
+
data: JSON.parse(JSON.stringify({ action: run.action, projectId: run.projectId,
|
|
55
|
+
target: run.actionInput, observedAt: new Date().toISOString(), details })),
|
|
56
|
+
evidence: []
|
|
57
|
+
};
|
|
58
|
+
writeTextFileAtomically(join(home, run.artifactsLocator, "business-result.json"), JSON.stringify(business) + "\n");
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const data = { action: run.action, projectId: run.projectId,
|
|
35
62
|
trigger: run.trigger, outcome: "succeeded", observedAt: new Date().toISOString(),
|
|
36
|
-
remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result }
|
|
63
|
+
remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result };
|
|
64
|
+
const business = {
|
|
65
|
+
schemaVersion: 1, sourceRun: runReference(run), outcome: "succeeded",
|
|
66
|
+
summary: `Project ${run.projectId} refreshed to ${result.toCommit}.`,
|
|
67
|
+
data: JSON.parse(JSON.stringify(data)), evidence: []
|
|
68
|
+
};
|
|
69
|
+
// Persist separately from stdout. If this write fails after Git effects,
|
|
70
|
+
// the collector reports unknown, never a safely retryable failed action.
|
|
71
|
+
writeTextFileAtomically(join(home, run.artifactsLocator, "business-result.json"), JSON.stringify(business) + "\n");
|
|
72
|
+
process.stdout.write(JSON.stringify(data) + "\n");
|
|
37
73
|
}
|
|
38
74
|
finally {
|
|
39
75
|
store.close();
|