@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,111 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { requireKnownFields } from "../domain/validation.js";
|
|
3
|
+
import { runReference } from "../job/jobBusinessResult.js";
|
|
4
|
+
import { createJobDefinitionControl } from "./jobDefinitionControl.js";
|
|
5
|
+
import { assertCallerAuthorized } from "./jobControl.js";
|
|
6
|
+
import { artifactInputBytes, presentArtifactContent } from "../artifacts/artifactCapability.js";
|
|
7
|
+
import { previewArtifactContent } from "../web/webMaterials.js";
|
|
8
|
+
import { boundedJobArtifactPayload, MAX_JOB_ARTIFACT_RESPONSE_BYTES } from "../artifacts/jobArtifactTransport.js";
|
|
9
|
+
import { jobArtifactOwner, openJobArtifactRepository, readJobArtifactReference, validateJobArtifactReference } from "../artifacts/jobArtifact.js";
|
|
10
|
+
/** Git commits precede database publication. A failed publication retains the exact
|
|
11
|
+
* commit and returns its reference for explicit registration, never business replay.
|
|
12
|
+
* Never hold a synchronous SQLite transaction across awaited Git operations. */
|
|
13
|
+
export async function controlJobArtifact(store, home, value, now) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
throw new Error("Invalid Job artifact request.");
|
|
16
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "runId", "artifact", "reference", "offset"], "Job artifact request");
|
|
17
|
+
const input = value;
|
|
18
|
+
const current = () => createJobDefinitionControl(store, home)({
|
|
19
|
+
op: "run-get", scope: input.scope, caller: input.caller, id: input.id, runId: input.runId
|
|
20
|
+
}, now);
|
|
21
|
+
const run = current(); // Both definition and historical Assignment authorization.
|
|
22
|
+
const repo = openJobArtifactRepository(home, jobArtifactOwner(run), runReference(run));
|
|
23
|
+
if (input.op === "artifact-list") {
|
|
24
|
+
const head = await repo.head();
|
|
25
|
+
const files = head === null ? [] : await repo.list(head);
|
|
26
|
+
if (files.length > 256)
|
|
27
|
+
throw new Error("Artifact repository exceeds the bounded 256-file listing.");
|
|
28
|
+
const saved = [];
|
|
29
|
+
if (head !== null)
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
// One bounded file at a time: a valid 256-file repository must not spawn
|
|
32
|
+
// hundreds of Git children or materialize all 8 MiB blobs concurrently.
|
|
33
|
+
const content = await repo.read(file.relativePath, head);
|
|
34
|
+
saved.push({ kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
|
|
35
|
+
version: head, relativePath: file.relativePath, digest: content.digest });
|
|
36
|
+
}
|
|
37
|
+
current();
|
|
38
|
+
return { sourceRun: runReference(run), artifacts: run.artifacts ?? [],
|
|
39
|
+
repository: { head, saved, workingChanges: await repo.workingChanges() } };
|
|
40
|
+
}
|
|
41
|
+
if (input.op === "artifact-read" || input.op === "artifact-preview") {
|
|
42
|
+
const ref = validateJobArtifactReference(input.reference, run);
|
|
43
|
+
// This grants only a deliberately selected, published object, never HEAD or
|
|
44
|
+
// all material in its Task. An arbitrary caller-supplied Task ref is not a grant.
|
|
45
|
+
if (!(run.artifacts ?? []).some(item => isDeepStrictEqual(item, ref))) {
|
|
46
|
+
throw new Error("Artifact reference is not registered for this exact Run.");
|
|
47
|
+
}
|
|
48
|
+
const content = await readJobArtifactReference(home, ref);
|
|
49
|
+
current(); // A replaced Session during I/O must not receive the material.
|
|
50
|
+
return boundedJobArtifactPayload({ reference: ref, ...(input.op === "artifact-preview"
|
|
51
|
+
? previewArtifactContent(content, input.offset) : presentArtifactContent(content)) }, "Artifact response", MAX_JOB_ARTIFACT_RESPONSE_BYTES);
|
|
52
|
+
}
|
|
53
|
+
if (!["artifact-save", "artifact-register"].includes(input.op))
|
|
54
|
+
throw new Error("Unknown Job artifact operation.");
|
|
55
|
+
let ref;
|
|
56
|
+
if (input.op === "artifact-save") {
|
|
57
|
+
const artifact = input.artifact;
|
|
58
|
+
requireKnownFields(artifact, ["relativePath", "content", "encoding", "message", "expectedHead"], "Job artifact save");
|
|
59
|
+
boundedJobArtifactPayload(artifact, "Artifact save");
|
|
60
|
+
if (typeof artifact.relativePath !== "string" || artifact.relativePath.length > 512)
|
|
61
|
+
throw new Error("Artifact path exceeds 512 characters.");
|
|
62
|
+
if ((run.artifacts?.length ?? 0) >= 256)
|
|
63
|
+
throw new Error("Run artifacts exceed the bounded reference limit.");
|
|
64
|
+
const bytes = artifactInputBytes(artifact);
|
|
65
|
+
const saved = await repo.save({ files: [{ relativePath: artifact.relativePath, bytes }],
|
|
66
|
+
message: artifact.message ?? `Save ${artifact.relativePath}`, expectedHead: artifact.expectedHead });
|
|
67
|
+
const content = await repo.read(artifact.relativePath, saved.commit);
|
|
68
|
+
ref = { kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
|
|
69
|
+
version: saved.commit, relativePath: content.relativePath, digest: content.digest };
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ref = validateJobArtifactReference(input.reference, run);
|
|
73
|
+
if (ref.owner.kind === "task") {
|
|
74
|
+
// Only a Task Leader or Operator with current access may select Task material.
|
|
75
|
+
// Assignment readers cannot manufacture a cross-entity grant.
|
|
76
|
+
if (!(input.caller.scope === "global" && input.caller.role === "operator")
|
|
77
|
+
&& !(input.caller.scope === "task" && input.caller.role === "leader")) {
|
|
78
|
+
throw new Error("Selecting Task artifacts requires its authorized Leader or Operator.");
|
|
79
|
+
}
|
|
80
|
+
assertCallerAuthorized(store, input.caller, ref.owner.taskId);
|
|
81
|
+
}
|
|
82
|
+
await readJobArtifactReference(home, ref);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return store.transaction(tx => {
|
|
86
|
+
const fresh = current();
|
|
87
|
+
if (ref.owner.kind === "task")
|
|
88
|
+
assertCallerAuthorized(tx, input.caller, ref.owner.taskId);
|
|
89
|
+
const previous = fresh.artifacts ?? [];
|
|
90
|
+
if (previous.some(item => isDeepStrictEqual(item, ref)))
|
|
91
|
+
return { status: "registered", reference: ref };
|
|
92
|
+
const next = { ...fresh, artifacts: [...previous, ref],
|
|
93
|
+
updatedAt: new Date(Math.max(now.getTime(), Date.parse(fresh.updatedAt))).toISOString() };
|
|
94
|
+
if ("executor" in next)
|
|
95
|
+
tx.saveAgentJobRun(next);
|
|
96
|
+
else if ("taskId" in next)
|
|
97
|
+
tx.saveDurableJob(next.taskId, next);
|
|
98
|
+
else
|
|
99
|
+
tx.saveGlobalJobRun(next);
|
|
100
|
+
return { status: "registered", reference: ref };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
// An explicit partial receipt survives the RPC's bounded exception text.
|
|
105
|
+
// It is NOT a published result. Never replay the business action or silently
|
|
106
|
+
// retry the database write to conceal this boundary.
|
|
107
|
+
return { status: "saved-unregistered", reference: ref,
|
|
108
|
+
error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 4096),
|
|
109
|
+
recovery: "Inspect artifact-list and register this exact reference; do not rerun business work." };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -86,6 +86,8 @@ export function createDurableJobControl(store) {
|
|
|
86
86
|
validateStartParams(tx, params);
|
|
87
87
|
const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
|
|
88
88
|
if (previous !== null) {
|
|
89
|
+
if (previous.delegation)
|
|
90
|
+
throw jobDomainError("Use Job run retry for a Task delegation, not a process specification.");
|
|
89
91
|
requireJobAssignment(tx, previous, params.caller);
|
|
90
92
|
if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
|
|
91
93
|
throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
|
|
@@ -190,6 +192,8 @@ export function createJobCallAuthority(store) {
|
|
|
190
192
|
* Job even when its management binding is revoked. Only a new spawn checks it.
|
|
191
193
|
*/
|
|
192
194
|
export function authorizeJobStart(store, job) {
|
|
195
|
+
if (job.delegation)
|
|
196
|
+
throw jobDomainError("Task delegation cannot start a process.");
|
|
193
197
|
const taskPrefix = `task:${job.taskId}/role:`;
|
|
194
198
|
const actor = job.operation.actorId;
|
|
195
199
|
const scope = actor.startsWith(taskPrefix) ? "task" : actor.startsWith("global:") ? "global" : undefined;
|
|
@@ -8,9 +8,26 @@ import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
|
|
|
8
8
|
import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
|
|
9
9
|
import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
|
|
10
10
|
import { createFileArtifactPort } from "./jobSupervisor.js";
|
|
11
|
-
|
|
11
|
+
import { jobRunSnapshot, runReference } from "../job/jobBusinessResult.js";
|
|
12
|
+
import { controlJobArtifact } from "./jobArtifactControl.js";
|
|
13
|
+
import { ArtifactHeadConflictError, ArtifactManagedConfigViolationError, ArtifactRemoteViolationError } from "../artifacts/taskArtifactRepository.js";
|
|
14
|
+
import { ArtifactCommitLockedError } from "../artifacts/artifactCommitLock.js";
|
|
15
|
+
import { ManagedGitError } from "../artifacts/managedGit.js";
|
|
16
|
+
import { authorizeServiceJob, requireScheduleService, serviceRunBinding } from "../job/scheduleService.js";
|
|
17
|
+
import { authorizeAgentDefinition, controlAgentJob, summarizeAgentRuns } from "./agentJobControl.js";
|
|
18
|
+
import { admitTaskJob, authorizeTaskJob, reconcileTaskDelegation, taskDelegationView } from "./jobTaskDelegationControl.js";
|
|
19
|
+
import { requireProjectSyncTarget } from "../job/projectSync.js";
|
|
20
|
+
export function authorizeScope(store, scope, caller, allowPublic = false) {
|
|
12
21
|
parseDurableJobCaller(caller);
|
|
13
22
|
jobScopeKey(scope);
|
|
23
|
+
// Public local ingress is not a fabricated Operator. It grants only immediate
|
|
24
|
+
// controlled global management/actions. Schedule grants are explicit and
|
|
25
|
+
// Task-shell authority is unchanged.
|
|
26
|
+
if (allowPublic && scope.kind === "global" && caller.scope === "user") {
|
|
27
|
+
if (Object.keys(caller).some(key => key !== "scope"))
|
|
28
|
+
throw new Error("Public user Job caller must not claim a managed identity.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
14
31
|
// Match Task Context's Operator-only boundary for global callers as well
|
|
15
32
|
// as excluding Task callers from global Jobs. Session identity alone is not
|
|
16
33
|
// permission for a custom GlobalRole to read or manage Task definitions.
|
|
@@ -26,7 +43,13 @@ function taskParams(job, caller, requestId) {
|
|
|
26
43
|
return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
|
|
27
44
|
}
|
|
28
45
|
export function authorizeDefinition(store, job, caller, write) {
|
|
29
|
-
|
|
46
|
+
if (job.configuration.kind === "agent")
|
|
47
|
+
return authorizeAgentDefinition(store, job, caller, write);
|
|
48
|
+
authorizeScope(store, job.scope, caller, true);
|
|
49
|
+
if (job.configuration.kind === "task") {
|
|
50
|
+
authorizeTaskJob(store, job, caller, write);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
30
53
|
if (job.scope.kind === "task") {
|
|
31
54
|
const params = taskParams(job, caller, "definition-validation");
|
|
32
55
|
// A definition must not reveal another Assignment's commands/environment.
|
|
@@ -41,11 +64,13 @@ export function authorizeDefinition(store, job, caller, write) {
|
|
|
41
64
|
throw new Error("Global Jobs require builtin actions.");
|
|
42
65
|
prepareBuiltinJob(job.configuration, "/");
|
|
43
66
|
if (job.configuration.action === "project.refresh") {
|
|
67
|
+
if (job.builtin)
|
|
68
|
+
requireProjectSyncTarget(store, job);
|
|
44
69
|
const project = requireRefreshProject(store, job.configuration.projectId, true);
|
|
45
70
|
if (projectRefreshDigest(job.configuration) !== projectRefreshTargetDigest(project)) {
|
|
46
71
|
throw new Error("Project refresh target changed; inspect the Project and explicitly update the Job.");
|
|
47
72
|
}
|
|
48
|
-
const duplicate = store.listJobDefinitions({ kind: "global" }).find(other => other.id !== job.id && other.configuration.kind === "builtin"
|
|
73
|
+
const duplicate = !job.instantiation && store.listJobDefinitions({ kind: "global" }).find(other => !other.instantiation && !other.builtin && other.id !== job.id && other.configuration.kind === "builtin"
|
|
49
74
|
&& other.configuration.action === "project.refresh" && other.configuration.projectId === project.id);
|
|
50
75
|
if (duplicate)
|
|
51
76
|
throw new Error(`Project refresh Job already exists: ${duplicate.id}.`);
|
|
@@ -57,7 +82,29 @@ export function listJobRuns(store, job) {
|
|
|
57
82
|
.filter(run => run.definitionId === job.id);
|
|
58
83
|
}
|
|
59
84
|
/** Read-only presentation; attempts/results remain owned by exact JobRuns. */
|
|
60
|
-
function presentDefinition(store,
|
|
85
|
+
function presentDefinition(store, definition, caller) {
|
|
86
|
+
if (definition.configuration.kind === "agent")
|
|
87
|
+
return {
|
|
88
|
+
...definition, results: summarizeAgentRuns(store.listAgentJobRuns(definition.scope, definition.id))
|
|
89
|
+
};
|
|
90
|
+
const history = listJobRuns(store, definition).filter(run => {
|
|
91
|
+
try {
|
|
92
|
+
authorizeRunRead(store, run, caller);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}).sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
99
|
+
const result = (run) => run ? {
|
|
100
|
+
runId: run.id, sourceRun: runReference(run), status: run.status,
|
|
101
|
+
outcome: run.businessResult?.outcome ?? null,
|
|
102
|
+
summary: run.businessResult?.summary.slice(0, 512) ?? null,
|
|
103
|
+
artifactCount: run.artifacts?.length ?? 0, delegation: taskDelegationView(store, run)
|
|
104
|
+
} : null;
|
|
105
|
+
const job = { ...definition, results: {
|
|
106
|
+
latest: result(history[0]), latestSuccessful: result(history.find(run => run.businessResult?.outcome === "succeeded"))
|
|
107
|
+
} };
|
|
61
108
|
if (job.configuration.kind !== "builtin" || job.configuration.action !== "project.refresh")
|
|
62
109
|
return job;
|
|
63
110
|
const projectId = job.configuration.projectId;
|
|
@@ -69,14 +116,21 @@ function presentDefinition(store, job) {
|
|
|
69
116
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
70
117
|
const summarize = (run) => run === undefined ? null : {
|
|
71
118
|
runId: run.id, definitionRevision: run.definitionRevision, trigger: run.trigger,
|
|
72
|
-
status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null
|
|
119
|
+
status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null,
|
|
120
|
+
summary: run.businessResult?.summary.slice(0, 512) ?? null
|
|
73
121
|
};
|
|
74
122
|
return { ...job, refresh: {
|
|
75
123
|
projectId, lastAttempt: summarize(runs[0]),
|
|
76
124
|
lastSuccessfulRefresh: summarize(runs.find(run => run.status === "succeeded"))
|
|
77
125
|
} };
|
|
78
126
|
}
|
|
79
|
-
function authorizeRunRead(store, run, caller) {
|
|
127
|
+
export function authorizeRunRead(store, run, caller) {
|
|
128
|
+
if (run.delegation) {
|
|
129
|
+
authorizeScope(store, runReference(run).scope, caller, true);
|
|
130
|
+
if (caller.scope === "task" && caller.role !== "leader")
|
|
131
|
+
throw new Error("Task delegation is not an Assignment grant.");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
80
134
|
// A revised definition's Assignment does not grant access to earlier Runs.
|
|
81
135
|
if ("taskId" in run)
|
|
82
136
|
createDurableJobControl(store).getJob(run.taskId, run.id, caller);
|
|
@@ -86,12 +140,21 @@ function authorizeRunRead(store, run, caller) {
|
|
|
86
140
|
* schedule advancement. No external effects occur here; rollback removes the
|
|
87
141
|
* admission as well. The caller must be freshly authenticated on every call.
|
|
88
142
|
*/
|
|
89
|
-
export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf) {
|
|
143
|
+
export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf, service) {
|
|
90
144
|
return store.transaction(tx => {
|
|
91
145
|
const job = tx.getJobDefinition(scope, definitionId);
|
|
92
146
|
if (job === null)
|
|
93
147
|
throw new Error("Job definition not found.");
|
|
148
|
+
if (job.configuration.kind === "agent")
|
|
149
|
+
throw new Error("Agent Jobs use their explicit Role message admission; Schedule service does not grant Agent execution.");
|
|
94
150
|
authorizeDefinition(tx, job, caller, false);
|
|
151
|
+
const serviceSchedule = service === undefined ? undefined : authorizeServiceJob(tx, job, service, trigger.key);
|
|
152
|
+
if (service && (scope.kind !== "global" || trigger.source !== "schedule" || retryOf !== undefined)) {
|
|
153
|
+
throw new Error("Invalid Schedule service admission.");
|
|
154
|
+
}
|
|
155
|
+
if (!service && caller.scope === "user" && !["manual", "retry"].includes(trigger.source)) {
|
|
156
|
+
throw new Error("Public Job admission only authorizes an immediate manual execution or explicit retry.");
|
|
157
|
+
}
|
|
95
158
|
if (!trigger.key.trim() || !["manual", "retry", "schedule", "integration"].includes(trigger.source)) {
|
|
96
159
|
throw new Error("Invalid JobRun trigger.");
|
|
97
160
|
}
|
|
@@ -116,6 +179,11 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
|
|
|
116
179
|
throw new Error("Retry requires an exact failed/cancelled/timed-out Run of this Job.");
|
|
117
180
|
}
|
|
118
181
|
const requestId = `job:${job.id}:${trigger.source}:${trigger.key}`;
|
|
182
|
+
if (job.configuration.kind === "task") {
|
|
183
|
+
if (service)
|
|
184
|
+
throw new Error("Schedule service does not authorize Task delegation.");
|
|
185
|
+
return { run: admitTaskJob(tx, { ...job, configuration: job.configuration }, caller, trigger, requestId, now, retryOf), created: true };
|
|
186
|
+
}
|
|
119
187
|
if (scope.kind === "task") {
|
|
120
188
|
const result = createDurableJobControl(tx).startJob({
|
|
121
189
|
...taskParams(job, caller, requestId), definitionId, trigger, retryOf
|
|
@@ -127,30 +195,85 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
|
|
|
127
195
|
const id = `job-${randomUUID()}`;
|
|
128
196
|
const locator = `artifacts/jobs/global/${id}`;
|
|
129
197
|
const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
|
|
130
|
-
const actorId = `global:${caller.role}`;
|
|
131
|
-
|
|
198
|
+
const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
|
|
199
|
+
let run = {
|
|
132
200
|
schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
|
|
133
201
|
trigger: structuredClone(trigger), action: job.configuration.action,
|
|
202
|
+
...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
|
|
134
203
|
actionInput: structuredClone(job.configuration.input),
|
|
135
204
|
...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
|
|
136
205
|
...spec, status: "queued", artifactsLocator: locator,
|
|
137
206
|
idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
|
|
138
207
|
operation: {
|
|
139
|
-
requestId, actorId, authorityRef: jobAuthorityBinding(tx, "global", caller.role, ""),
|
|
208
|
+
requestId, actorId, authorityRef: service || caller.scope === "user" ? "pending" : jobAuthorityBinding(tx, "global", caller.role, ""),
|
|
140
209
|
targetId: job.id, capability: "job.run", implementation: JOB_RUNNER_IMPLEMENTATION,
|
|
141
210
|
inputDigest: createHash("sha256").update(JSON.stringify([job, trigger, retryOf])).digest("hex"),
|
|
142
211
|
effect: "none", receiptRefs: [], partialResultRefs: []
|
|
143
212
|
},
|
|
144
213
|
...(retryOf === undefined ? {} : { retryOf }),
|
|
145
214
|
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
146
|
-
}
|
|
215
|
+
};
|
|
216
|
+
if (serviceSchedule)
|
|
217
|
+
run = { ...run, operation: { ...run.operation, authorityRef: serviceRunBinding(tx, run, serviceSchedule) } };
|
|
218
|
+
else if (caller.scope === "user")
|
|
219
|
+
run = { ...run, operation: { ...run.operation, authorityRef: publicJobBinding(tx, run) } };
|
|
220
|
+
validateDurableJob(run);
|
|
147
221
|
tx.saveGlobalJobRun(run);
|
|
148
222
|
return { run, created: true };
|
|
149
223
|
});
|
|
150
224
|
}
|
|
225
|
+
/** Job owns instantiation; Schedule supplies only the exact version and occurrence. */
|
|
226
|
+
export function instantiateJobRun(store, home, scope, definitionId, revision, caller, trigger, now, service) {
|
|
227
|
+
return store.transaction(tx => {
|
|
228
|
+
const source = tx.getJobDefinition(scope, definitionId);
|
|
229
|
+
if (!source || source.revision !== revision)
|
|
230
|
+
throw new Error("Source Job revision changed; explicitly select an available version.");
|
|
231
|
+
authorizeDefinition(tx, source, caller, true);
|
|
232
|
+
const id = `instance-${createHash("sha256").update(JSON.stringify([source.scope, source.id, trigger])).digest("hex")}`;
|
|
233
|
+
const instantiation = { sourceId: source.id, sourceRevision: revision, key: trigger.key };
|
|
234
|
+
let job = tx.getJobDefinition(source.scope, id);
|
|
235
|
+
if (job) {
|
|
236
|
+
if (JSON.stringify(job.instantiation) !== JSON.stringify(instantiation)
|
|
237
|
+
|| JSON.stringify(job.configuration) !== JSON.stringify(source.configuration)) {
|
|
238
|
+
throw new Error("Job instance identity conflicts with its original source.");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
job = validateJobDefinition({ ...createJobDefinition({ id, scope: source.scope,
|
|
243
|
+
title: source.title, configuration: source.configuration }, now), instantiation });
|
|
244
|
+
tx.saveJobDefinition(job);
|
|
245
|
+
}
|
|
246
|
+
return admitJobRun(tx, home, source.scope, job.id, caller, trigger, now, undefined, service);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function publicJobBinding(store, run) {
|
|
250
|
+
return createHash("sha256").update(JSON.stringify([
|
|
251
|
+
"public-immediate-job/v1", store.getHomeIdentity().homeId, run.id, run.definitionId, run.definitionRevision,
|
|
252
|
+
run.action, run.projectId, run.actionInput, run.trigger, run.operation.requestId
|
|
253
|
+
])).digest("hex");
|
|
254
|
+
}
|
|
151
255
|
export function authorizeGlobalJobStart(store, run) {
|
|
152
|
-
if (run.
|
|
153
|
-
|
|
256
|
+
if (run.delegation)
|
|
257
|
+
throw new Error("Task delegation cannot start a builtin process.");
|
|
258
|
+
let serviceAdmission = false;
|
|
259
|
+
if (run.operation.actorId === "service:schedule" && run.serviceAuthorization && run.trigger.source === "schedule") {
|
|
260
|
+
try {
|
|
261
|
+
const schedule = requireScheduleService(store, run.serviceAuthorization);
|
|
262
|
+
const job = store.getJobDefinition({ kind: "global" }, run.definitionId);
|
|
263
|
+
if (!job || job.revision !== run.definitionRevision)
|
|
264
|
+
throw new Error("Schedule Run Job revision changed.");
|
|
265
|
+
authorizeServiceJob(store, job, run.serviceAuthorization, run.trigger.key);
|
|
266
|
+
serviceAdmission = serviceRunBinding(store, run, schedule) === run.operation.authorityRef;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
throw Object.assign(new Error(error instanceof Error ? error.message : String(error), { cause: error }), { name: "CoreJobError" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const publicAdmission = run.operation.actorId === "user"
|
|
273
|
+
&& ["manual", "retry"].includes(run.trigger.source)
|
|
274
|
+
&& publicJobBinding(store, run) === run.operation.authorityRef;
|
|
275
|
+
if (!serviceAdmission && !publicAdmission && (run.operation.actorId !== "global:operator"
|
|
276
|
+
|| jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef)) {
|
|
154
277
|
throw Object.assign(new Error("Global JobRun caller binding was revoked; no execution was started."), { name: "CoreJobError" });
|
|
155
278
|
}
|
|
156
279
|
// Storage failures are not domain refusals or proof of no execution.
|
|
@@ -162,6 +285,9 @@ export function authorizeGlobalJobStart(store, run) {
|
|
|
162
285
|
...(run.projectId === undefined ? {} : { projectId: run.projectId })
|
|
163
286
|
}, run.workspace, { home, runId: run.id });
|
|
164
287
|
if (run.action === "project.refresh") {
|
|
288
|
+
const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
|
|
289
|
+
if (definition?.builtin)
|
|
290
|
+
requireProjectSyncTarget(store, definition);
|
|
165
291
|
const project = requireRefreshProject({ listProjects: () => projects, rootDirectory: () => home }, run.projectId, true);
|
|
166
292
|
if (projectRefreshTargetDigest(project) !== projectRefreshDigest({
|
|
167
293
|
kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
|
|
@@ -185,11 +311,28 @@ export function createJobDefinitionControl(store, home) {
|
|
|
185
311
|
return (value, now) => {
|
|
186
312
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
187
313
|
throw new Error("Invalid Job request.");
|
|
188
|
-
|
|
314
|
+
const op = value.op;
|
|
315
|
+
if (typeof op === "string" && op.startsWith("artifact-")) {
|
|
316
|
+
return controlJobArtifact(store, home, value, now).catch(error => {
|
|
317
|
+
// Preserve bounded domain diagnostics through RPC; authentication errors
|
|
318
|
+
// retain their existing CoreApplicationError identity and code.
|
|
319
|
+
if (error instanceof ArtifactHeadConflictError || error instanceof ArtifactManagedConfigViolationError
|
|
320
|
+
|| error instanceof ArtifactRemoteViolationError || error instanceof ArtifactCommitLockedError
|
|
321
|
+
|| error instanceof ManagedGitError)
|
|
322
|
+
error.name = "CoreJobError";
|
|
323
|
+
throw error;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId", "result", "expectedSessionId", "expectedAttemptId"], "Job request");
|
|
189
327
|
const input = value;
|
|
190
|
-
|
|
328
|
+
// A Job execution reader is authorized against its exact definition below,
|
|
329
|
+
// not promoted to Operator or to a Task manager.
|
|
330
|
+
if (!(input.caller.scope === "global" && input.caller.role !== "operator")
|
|
331
|
+
&& !(input.caller.scope === "task" && input.scope.kind === "global"))
|
|
332
|
+
authorizeScope(store, input.scope, input.caller, true);
|
|
191
333
|
return store.transaction(tx => {
|
|
192
334
|
if (input.op === "list") {
|
|
335
|
+
authorizeScope(tx, input.scope, input.caller, true);
|
|
193
336
|
return tx.listJobDefinitions(input.scope).filter(job => {
|
|
194
337
|
try {
|
|
195
338
|
authorizeDefinition(tx, job, input.caller, false);
|
|
@@ -198,12 +341,15 @@ export function createJobDefinitionControl(store, home) {
|
|
|
198
341
|
catch {
|
|
199
342
|
return false;
|
|
200
343
|
}
|
|
201
|
-
}).map(job => presentDefinition(tx, job));
|
|
344
|
+
}).map(job => presentDefinition(tx, job, input.caller));
|
|
202
345
|
}
|
|
203
346
|
if (typeof input.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(input.id))
|
|
204
347
|
throw new Error("An explicit safe Job id is required.");
|
|
205
348
|
const existing = tx.getJobDefinition(input.scope, input.id);
|
|
206
349
|
if (input.op === "define" || input.op === "update") {
|
|
350
|
+
if (existing?.builtin || (!existing && input.scope.kind === "global" && input.id.startsWith("project-sync-"))) {
|
|
351
|
+
throw new Error("Builtin Project sync definitions are installer-owned; edit their Schedule.");
|
|
352
|
+
}
|
|
207
353
|
let configuration = input.configuration;
|
|
208
354
|
// Empty input is an explicit request to bind the current catalog target.
|
|
209
355
|
// A saved digest is never silently refreshed on run/retry.
|
|
@@ -238,9 +384,11 @@ export function createJobDefinitionControl(store, home) {
|
|
|
238
384
|
if (existing === null)
|
|
239
385
|
throw new Error("Job definition not found.");
|
|
240
386
|
validateJobDefinition(existing);
|
|
387
|
+
if (existing.configuration.kind === "agent")
|
|
388
|
+
return controlAgentJob(tx, home, existing, input, now);
|
|
241
389
|
authorizeDefinition(tx, existing, input.caller, false);
|
|
242
390
|
if (input.op === "show")
|
|
243
|
-
return presentDefinition(tx, existing);
|
|
391
|
+
return presentDefinition(tx, existing, input.caller);
|
|
244
392
|
if (input.op === "runs")
|
|
245
393
|
return listJobRuns(tx, existing).filter(run => {
|
|
246
394
|
try {
|
|
@@ -263,8 +411,24 @@ export function createJobDefinitionControl(store, home) {
|
|
|
263
411
|
if (run === undefined)
|
|
264
412
|
throw new Error("Exact JobRun not found.");
|
|
265
413
|
authorizeRunRead(tx, run, input.caller);
|
|
266
|
-
if (input.op === "run-
|
|
267
|
-
return run;
|
|
414
|
+
if (input.op === "run-delegation")
|
|
415
|
+
return taskDelegationView(tx, run);
|
|
416
|
+
if (input.op === "run-get" || input.op === "run-confirm")
|
|
417
|
+
return input.op === "run-confirm" && run.delegation
|
|
418
|
+
? reconcileTaskDelegation(tx, run, now) : run;
|
|
419
|
+
if (input.op === "run-result")
|
|
420
|
+
return {
|
|
421
|
+
snapshot: jobRunSnapshot(run),
|
|
422
|
+
acceptedAt: run.createdAt, startedAt: run.startedAt ?? null,
|
|
423
|
+
confirmedAt: run.businessResult === undefined ? null : run.terminalAt ?? null,
|
|
424
|
+
status: run.status, result: run.businessResult ?? null, artifacts: run.artifacts ?? []
|
|
425
|
+
};
|
|
426
|
+
if (input.op === "run-evidence")
|
|
427
|
+
return {
|
|
428
|
+
sourceRun: jobRunSnapshot(run), operation: run.operation,
|
|
429
|
+
process: run.process ?? null, checkpoint: run.checkpoint ?? null,
|
|
430
|
+
processResult: run.result ?? null, artifactsLocator: run.artifactsLocator
|
|
431
|
+
};
|
|
268
432
|
if (input.op === "logs") {
|
|
269
433
|
const steps = run.result?.steps ?? createFileArtifactPort(home)
|
|
270
434
|
.readCheckpoint(executionNamespace(run), run.id)?.completedSteps ?? run.checkpoint?.completedSteps ?? [];
|
|
@@ -288,6 +452,14 @@ export function createJobDefinitionControl(store, home) {
|
|
|
288
452
|
});
|
|
289
453
|
}
|
|
290
454
|
if (input.op === "run-cancel" || input.op === "run-acknowledge") {
|
|
455
|
+
if (run.delegation) {
|
|
456
|
+
const next = input.op === "run-cancel" ? requestDurableJobCancel(run, now) : acknowledgeUnknownDurableJob(run, now);
|
|
457
|
+
if ("taskId" in next)
|
|
458
|
+
tx.saveDurableJob(next.taskId, next);
|
|
459
|
+
else
|
|
460
|
+
tx.saveGlobalJobRun(next);
|
|
461
|
+
return next;
|
|
462
|
+
}
|
|
291
463
|
if ("taskId" in run) {
|
|
292
464
|
const control = createDurableJobControl(tx);
|
|
293
465
|
return input.op === "run-cancel"
|
|
@@ -20,6 +20,7 @@ import { recordOperationEvidence } from "../kernel/operationFacts.js";
|
|
|
20
20
|
import { wakeReason } from "../scheduler/wakeReason.js";
|
|
21
21
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
22
22
|
import { readProcessStartIdentity } from "../core/fileLockOwner.js";
|
|
23
|
+
import { processBusinessResult, resultDigest, runReference, validateBusinessResult } from "../job/jobBusinessResult.js";
|
|
23
24
|
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
|
|
24
25
|
const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
25
26
|
const SIGKILL_GRACE_MS = 30_000;
|
|
@@ -31,6 +32,7 @@ export class DurableJobSupervisor {
|
|
|
31
32
|
#wake;
|
|
32
33
|
#onError;
|
|
33
34
|
#authorizeStart;
|
|
35
|
+
#reconcileDelegation;
|
|
34
36
|
// f5: Composite key (taskId/jobId) because job IDs are Task-local — every
|
|
35
37
|
// Task has a job-1, so a Task-local key would cross-kill healthy runners.
|
|
36
38
|
#sigkillAt = new Map();
|
|
@@ -42,6 +44,7 @@ export class DurableJobSupervisor {
|
|
|
42
44
|
this.#wake = options.wake ?? (() => undefined);
|
|
43
45
|
this.#onError = options.onError ?? (() => undefined);
|
|
44
46
|
this.#authorizeStart = options.authorizeStart;
|
|
47
|
+
this.#reconcileDelegation = options.reconcileDelegation;
|
|
45
48
|
}
|
|
46
49
|
reconcile(now) {
|
|
47
50
|
const jobs = this.#store.listActiveDurableJobs();
|
|
@@ -67,7 +70,38 @@ export class DurableJobSupervisor {
|
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
}
|
|
73
|
+
/** Explicit collection only. Never admits, spawns, cancels or retries work. */
|
|
74
|
+
confirmResult(namespace, runId, now) {
|
|
75
|
+
const run = this.#store.listActiveDurableJobs()
|
|
76
|
+
.find(run => executionNamespace(run) === namespace && run.id === runId);
|
|
77
|
+
if (!run)
|
|
78
|
+
return;
|
|
79
|
+
if (run.delegation) {
|
|
80
|
+
this.#reconcileDelegation?.(run, now);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (run.status === "queued") {
|
|
84
|
+
const spawned = this.#spawnedProcessFromEvidence(run, this.#artifacts.readStartMarker(namespace, runId));
|
|
85
|
+
if (spawned)
|
|
86
|
+
this.#adoptAndContinue(run, spawned, now);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const exit = this.#artifacts.readExitJson(namespace, runId);
|
|
90
|
+
if (exit)
|
|
91
|
+
this.#harvestExit(run, exit, now);
|
|
92
|
+
else if (run.process) {
|
|
93
|
+
const identity = this.#process.processStartIdentity(run.process.pid);
|
|
94
|
+
if (identity === undefined || identity !== run.process.startIdentity
|
|
95
|
+
|| !this.#process.isProcessAlive(run.process.pid))
|
|
96
|
+
this.#handleDeadProcess(run, now);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
70
100
|
#reconcileJob(job, now) {
|
|
101
|
+
if (job.delegation) {
|
|
102
|
+
this.#reconcileDelegation?.(job, now);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
71
105
|
if (job.status === "queued") {
|
|
72
106
|
this.#reconcileQueued(job, now);
|
|
73
107
|
return;
|
|
@@ -155,6 +189,8 @@ export class DurableJobSupervisor {
|
|
|
155
189
|
}
|
|
156
190
|
}
|
|
157
191
|
#startJob(job, now) {
|
|
192
|
+
if (job.delegation)
|
|
193
|
+
throw new Error("Task delegation cannot start a process.");
|
|
158
194
|
try {
|
|
159
195
|
this.#authorizeStart(job);
|
|
160
196
|
}
|
|
@@ -298,6 +334,7 @@ export class DurableJobSupervisor {
|
|
|
298
334
|
evidenceSource: "exit-artifact",
|
|
299
335
|
steps: exit.steps
|
|
300
336
|
};
|
|
337
|
+
const business = this.#businessResult(job, result);
|
|
301
338
|
// rr4/finding-7: The terminal transition and the Leader wakeup must be
|
|
302
339
|
// atomic. Compose complete + wakeupNotified and pass the wakeup param so
|
|
303
340
|
// the adapter enqueues the Leader mailbox entry in the same transaction.
|
|
@@ -306,11 +343,11 @@ export class DurableJobSupervisor {
|
|
|
306
343
|
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
|
|
307
344
|
let completed;
|
|
308
345
|
try {
|
|
309
|
-
completed = completeDurableJob(current, result, now);
|
|
346
|
+
completed = completeDurableJob(current, result, now, business);
|
|
310
347
|
}
|
|
311
348
|
catch {
|
|
312
349
|
completed = completeDurableJob(current, {
|
|
313
|
-
outcome: "
|
|
350
|
+
outcome: "unknown-needs-attention", exitCode: null, signal: null,
|
|
314
351
|
unknownReason: "runner output is invalid; original receipt is retained",
|
|
315
352
|
steps: []
|
|
316
353
|
}, now);
|
|
@@ -329,7 +366,7 @@ export class DurableJobSupervisor {
|
|
|
329
366
|
// The runner died without exit.json, but its step checkpoint covered
|
|
330
367
|
// every planned step: the outcome is still provable from durable
|
|
331
368
|
// evidence. No false pass — the checkpoint is the runner's own record.
|
|
332
|
-
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
369
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(completeDurableJob(current, { ...proven, evidenceSource: "checkpoint" }, now, this.#businessResult(current, { ...proven, evidenceSource: "checkpoint" })), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
333
370
|
this.#deliverTerminalEvent(terminal);
|
|
334
371
|
}
|
|
335
372
|
else {
|
|
@@ -339,6 +376,34 @@ export class DurableJobSupervisor {
|
|
|
339
376
|
}
|
|
340
377
|
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
341
378
|
}
|
|
379
|
+
#businessResult(job, result) {
|
|
380
|
+
const fallback = processBusinessResult(job, result);
|
|
381
|
+
if ("taskId" in job)
|
|
382
|
+
return fallback;
|
|
383
|
+
// The runner records this only when its cancel fence is seen before the
|
|
384
|
+
// first step. No domain action ran, so no business receipt is expected.
|
|
385
|
+
if (result.outcome === "cancelled" && result.steps.length === 0)
|
|
386
|
+
return fallback;
|
|
387
|
+
if (job.action === "system.noop")
|
|
388
|
+
return { ...fallback, data: { action: "system.noop" } };
|
|
389
|
+
const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
|
|
390
|
+
if (raw !== null && raw !== undefined) {
|
|
391
|
+
try {
|
|
392
|
+
const business = validateBusinessResult(JSON.parse(raw), runReference(job));
|
|
393
|
+
const sourceRun = runReference(job);
|
|
394
|
+
return { ...business, evidence: [...business.evidence, {
|
|
395
|
+
kind: "execution-evidence", owner: sourceRun, sourceRun,
|
|
396
|
+
version: resultDigest(raw), locator: `${job.artifactsLocator}/business-result.json`
|
|
397
|
+
}, ...fallback.evidence] };
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return { ...fallback, outcome: "unknown-needs-attention",
|
|
401
|
+
summary: "Invalid business result receipt; inspect retained execution evidence. Do not replay the action." };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { ...fallback, outcome: "unknown-needs-attention",
|
|
405
|
+
summary: "Business result receipt is missing; process completion does not prove the business outcome. Do not replay the action." };
|
|
406
|
+
}
|
|
342
407
|
/**
|
|
343
408
|
* f7/rr5: Deliver a terminal notice to the runtime inbox channel when a
|
|
344
409
|
* Job reaches a terminal state. The notice is best-effort: the terminal
|
|
@@ -375,6 +440,8 @@ function wakeupRefs(job) {
|
|
|
375
440
|
* checkpoint covers every planned step exactly, by name.
|
|
376
441
|
*/
|
|
377
442
|
function proveOutcomeFromCheckpoint(job, checkpoint) {
|
|
443
|
+
if (job.delegation)
|
|
444
|
+
return null;
|
|
378
445
|
const byName = new Map(checkpoint.completedSteps.map((step) => [step.name, step]));
|
|
379
446
|
if (byName.size !== checkpoint.completedSteps.length)
|
|
380
447
|
return null;
|
|
@@ -501,6 +568,19 @@ export function createFileArtifactPort(home) {
|
|
|
501
568
|
},
|
|
502
569
|
readReadyFile(taskId, jobId) {
|
|
503
570
|
return readJsonFile(join(home, "artifacts", "jobs", taskId, jobId, "ready.json"));
|
|
571
|
+
},
|
|
572
|
+
readBusinessResult(taskId, jobId) {
|
|
573
|
+
const path = join(home, "artifacts", "jobs", taskId, jobId, "business-result.json");
|
|
574
|
+
try {
|
|
575
|
+
if (statSync(path).size > 256 * 1024)
|
|
576
|
+
return "oversized business result";
|
|
577
|
+
return readFileSync(path, "utf8");
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (error.code === "ENOENT")
|
|
581
|
+
return null;
|
|
582
|
+
throw error; // A transient collection failure is not absence or permission to rerun.
|
|
583
|
+
}
|
|
504
584
|
}
|
|
505
585
|
};
|
|
506
586
|
}
|