@zq-silk/yui 2.8.0 → 2.9.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/cli/commandCatalog.js +35 -12
- package/dist/cli/interactionPolicy.js +2 -2
- package/dist/cli/updatePorts.js +20 -0
- package/dist/commands/durableJobCommands.js +50 -0
- package/dist/commands/projectCommands.js +4 -33
- package/dist/commands/scheduleCommands.js +45 -0
- package/dist/commands/taskActivationCommands.js +19 -5
- package/dist/commands/taskCommands.js +2 -1
- package/dist/commands/taskFactCommands.js +24 -12
- package/dist/commands/taskPublicationCommands.js +6 -3
- package/dist/commands/taskPublicationVerifyCommand.js +74 -28
- package/dist/context/runContextPack.js +24 -4
- package/dist/context/taskContext.js +52 -15
- package/dist/context/taskPhase.js +99 -0
- package/dist/controlPlaneCli.js +24 -5
- package/dist/controller/controller.js +35 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +16 -1
- package/dist/controller/jobControl.js +42 -12
- package/dist/controller/jobDefinitionControl.js +304 -0
- package/dist/controller/jobSupervisor.js +47 -32
- package/dist/controller/runtime.js +11 -2
- package/dist/controller/scheduleControl.js +176 -0
- package/dist/coordination/workMailbox.js +3 -3
- package/dist/external/githubPublicationVerifier.js +14 -5
- package/dist/external/gitlabPublicationVerifier.js +2 -1
- package/dist/interaction/notificationPresentation.js +3 -3
- package/dist/interaction/operatorPresentation.js +5 -0
- package/dist/job/builtinJob.js +58 -0
- package/dist/job/durableJob.js +72 -49
- package/dist/job/jobDefinition.js +68 -0
- package/dist/job/jobRunner.js +2 -1
- package/dist/job/projectRefreshRunner.js +48 -0
- package/dist/repository/projectRefresh.js +58 -0
- package/dist/repository/taskWorkspacePreparer.js +2 -1
- package/dist/schedule/schedule.js +108 -0
- package/dist/scheduler/operatorEvent.js +2 -0
- package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
- package/dist/storage/contextRecords.js +8 -0
- package/dist/storage/jobSchema.js +15 -0
- package/dist/storage/maintenanceCaller.js +69 -0
- package/dist/storage/recordValidation.js +5 -0
- package/dist/storage/scheduleSchema.js +12 -0
- package/dist/storage/sqliteSchema.js +75 -25
- package/dist/storage/sqliteStore.js +110 -9
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +5 -0
- package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
- package/dist/task/publicationToolObservation.js +128 -0
- package/dist/task/taskActivationService.js +35 -0
- package/dist/web/assets/client/app/settings.js +7 -1
- package/dist/web/assets/client/i18n/en.js +61 -0
- package/dist/web/assets/client/i18n/zh-CN.js +61 -0
- package/dist/web/assets/client/index.js +2 -0
- package/dist/web/assets/client/views/jobs.js +222 -0
- package/dist/web/assets/client/views/task/page.js +11 -1
- package/dist/web/assets/shared/manageNav.js +1 -0
- package/dist/web/assets/shared/taskTabs.js +2 -2
- package/dist/web/assets/shell/settingsContent.js +3 -0
- package/dist/web/assets/styles/index.js +2 -0
- package/dist/web/assets/styles/views/jobs.js +19 -0
- package/dist/web/webJobs.js +52 -0
- package/dist/web/webServer.js +31 -0
- package/docs/cli-information-contract.md +43 -2
- package/docs/cli-information-contract.zh-CN.md +32 -1
- package/docs/job-runs.md +224 -0
- package/docs/release-workflow.md +65 -0
- package/docs/release-workflow.zh-CN.md +49 -0
- package/docs/schedules.md +132 -0
- package/docs/storage-baseline.md +18 -1
- package/docs/storage-baseline.zh-CN.md +15 -1
- package/docs/task-delivery.md +68 -2
- package/docs/task-delivery.zh-CN.md +33 -1
- package/docs/testing/verification-levels.md +6 -2
- package/docs/testing/verification-levels.zh-CN.md +6 -3
- package/package.json +4 -3
- package/skills/yui-leader/SKILL.md +18 -1
- package/skills/yui-leader/VERSION +1 -1
- package/skills/yui-leader/references/planning.md +9 -1
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-reviewer/VERSION +1 -1
- package/skills/yui-runtime/SKILL.md +9 -3
- package/skills/yui-runtime/VERSION +1 -1
- package/skills/yui-runtime/references/context-ordering.md +103 -0
- package/skills/yui-runtime/references/publication.md +44 -4
- package/skills/yui-worker/SKILL.md +5 -0
- package/skills/yui-worker/VERSION +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* preserves its admitted Job across authorized Session replacement.
|
|
10
10
|
*/
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
|
-
import { createHash } from "node:crypto";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import { resolve, sep } from "node:path";
|
|
14
14
|
import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
|
|
15
15
|
import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
|
|
@@ -21,6 +21,7 @@ import { assertJobAssignmentScope, JobAssignmentScopeError } from "../job/jobAss
|
|
|
21
21
|
import { assertContextRecordReadable } from "../context/taskContext.js";
|
|
22
22
|
import { assertTaskProjectWriteAuthority } from "../task/taskAuthority.js";
|
|
23
23
|
import { validateHttpPreview } from "../job/httpPreview.js";
|
|
24
|
+
import { definitionFromTaskRun } from "../job/jobDefinition.js";
|
|
24
25
|
export function createDurableJobControl(store) {
|
|
25
26
|
const authority = createJobCallAuthority(store);
|
|
26
27
|
return {
|
|
@@ -34,7 +35,7 @@ export function createDurableJobControl(store) {
|
|
|
34
35
|
requireJobAssignment(tx, params, params.caller);
|
|
35
36
|
assertNonSecretJobInput(params);
|
|
36
37
|
validateHttpPreview(params.env, params.steps);
|
|
37
|
-
const
|
|
38
|
+
const specificationKey = durableJobIdempotencyKey({
|
|
38
39
|
owner: params.owner,
|
|
39
40
|
projectId: params.projectId,
|
|
40
41
|
head: params.head,
|
|
@@ -42,6 +43,12 @@ export function createDurableJobControl(store) {
|
|
|
42
43
|
workspace: params.workspace,
|
|
43
44
|
env: params.env
|
|
44
45
|
});
|
|
46
|
+
const definition = params.definitionId === undefined ? null
|
|
47
|
+
: tx.getJobDefinition({ kind: "task", taskId: params.taskId }, params.definitionId);
|
|
48
|
+
if (params.definitionId !== undefined && definition === null)
|
|
49
|
+
throw jobDomainError("Job definition not found.");
|
|
50
|
+
const baseKey = definition === null ? specificationKey : createHash("sha256")
|
|
51
|
+
.update(JSON.stringify([specificationKey, definition.id, definition.revision, params.trigger])).digest("hex");
|
|
45
52
|
const inputDigest = params.retryOf === undefined
|
|
46
53
|
? baseKey
|
|
47
54
|
: retryDurableJobIdempotencyKey(baseKey, params.retryOf);
|
|
@@ -77,9 +84,29 @@ export function createDurableJobControl(store) {
|
|
|
77
84
|
}
|
|
78
85
|
authority.authorize(context, params.taskId);
|
|
79
86
|
validateStartParams(tx, params);
|
|
87
|
+
const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
|
|
88
|
+
if (previous !== null) {
|
|
89
|
+
requireJobAssignment(tx, previous, params.caller);
|
|
90
|
+
if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
|
|
91
|
+
throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const definitionId = definition?.id ?? previous?.definitionId;
|
|
95
|
+
if (definitionId !== undefined) {
|
|
96
|
+
const active = tx.listDurableJobs(params.taskId).find(run => run.definitionId === definitionId
|
|
97
|
+
&& (run.status === "queued" || run.status === "running" || run.status === "unknown-needs-attention"));
|
|
98
|
+
if (active !== undefined)
|
|
99
|
+
throw jobDomainError(`Job is already occupied by Run ${active.id} (${active.status}); no new Run was admitted.`);
|
|
100
|
+
}
|
|
80
101
|
const id = tx.nextDurableJobId(params.taskId);
|
|
81
102
|
const job = createDurableJob({
|
|
82
103
|
id,
|
|
104
|
+
definitionId: definitionId ?? `definition-${randomUUID()}`,
|
|
105
|
+
definitionRevision: definition?.revision ?? previous?.definitionRevision,
|
|
106
|
+
trigger: params.trigger ?? {
|
|
107
|
+
source: params.retryOf !== undefined ? "retry" : params.owner.kind === "integration-attempt" ? "integration" : "manual",
|
|
108
|
+
key: requestId
|
|
109
|
+
},
|
|
83
110
|
taskId: params.taskId,
|
|
84
111
|
owner: params.owner,
|
|
85
112
|
projectId: params.projectId,
|
|
@@ -94,6 +121,9 @@ export function createDurableJobControl(store) {
|
|
|
94
121
|
artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
|
|
95
122
|
...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
|
|
96
123
|
}, now);
|
|
124
|
+
if (tx.getJobDefinition({ kind: "task", taskId: params.taskId }, job.definitionId) === null) {
|
|
125
|
+
tx.saveJobDefinition(definitionFromTaskRun(job));
|
|
126
|
+
}
|
|
97
127
|
tx.saveDurableJob(params.taskId, job);
|
|
98
128
|
return { job, created: true };
|
|
99
129
|
});
|
|
@@ -191,7 +221,7 @@ function assertLeaderJobWriteScope(store, target, caller) {
|
|
|
191
221
|
throw jobControlError("UNAUTHORIZED", error instanceof Error ? error.message : String(error));
|
|
192
222
|
}
|
|
193
223
|
}
|
|
194
|
-
function requireJobAssignment(store, target, caller) {
|
|
224
|
+
export function requireJobAssignment(store, target, caller) {
|
|
195
225
|
try {
|
|
196
226
|
assertJobAssignmentScope(store, target, caller);
|
|
197
227
|
}
|
|
@@ -201,7 +231,7 @@ function requireJobAssignment(store, target, caller) {
|
|
|
201
231
|
throw error;
|
|
202
232
|
}
|
|
203
233
|
}
|
|
204
|
-
function jobAuthorityBinding(store, scope, roleName, taskId) {
|
|
234
|
+
export function jobAuthorityBinding(store, scope, roleName, taskId) {
|
|
205
235
|
// A Host detach/reattach preserves the native Session and its queued work.
|
|
206
236
|
// Authenticate the live launch at ingress, but bind accepted Jobs to the
|
|
207
237
|
// caller's durable Session identity, not its disposable Host generation.
|
|
@@ -232,7 +262,7 @@ function jobAuthorityBinding(store, scope, roleName, taskId) {
|
|
|
232
262
|
role.activeAgentId, session.agentId, session.nativeSessionId
|
|
233
263
|
])).digest("hex");
|
|
234
264
|
}
|
|
235
|
-
function assertNonSecretJobInput(params) {
|
|
265
|
+
export function assertNonSecretJobInput(params) {
|
|
236
266
|
// Existing Jobs persist commands and environments. This boundary accepts
|
|
237
267
|
// non-secret executable specifications only; credential resolution is not a
|
|
238
268
|
// Job feature. Never hash a known secret then call the digest sanitized.
|
|
@@ -263,7 +293,7 @@ function assertNonSecretJobInput(params) {
|
|
|
263
293
|
* workspace was not the stable checkout. Now it binds the job to the exact
|
|
264
294
|
* managed workspace, verifies write access, and requires an active Task.
|
|
265
295
|
*/
|
|
266
|
-
function validateStartParams(store, params) {
|
|
296
|
+
export function validateStartParams(store, params) {
|
|
267
297
|
validateJobTarget(store, params);
|
|
268
298
|
assertCallerAuthorized(store, params.caller, params.taskId);
|
|
269
299
|
assertLeaderJobWriteScope(store, params, params.caller);
|
|
@@ -384,7 +414,7 @@ function validateJobTarget(store, params) {
|
|
|
384
414
|
* identity contract does not defend against another process with the same
|
|
385
415
|
* user's filesystem access. Bare user calls cannot start or cancel Jobs.
|
|
386
416
|
*/
|
|
387
|
-
function assertCallerAuthorized(store, caller, taskId) {
|
|
417
|
+
export function assertCallerAuthorized(store, caller, taskId) {
|
|
388
418
|
if (caller.scope === "user") {
|
|
389
419
|
// rr13: A user-scope caller has no per-Session channel binding. Every
|
|
390
420
|
// durable-state claim it could carry is replayable
|
|
@@ -508,7 +538,7 @@ export function parseDurableJobStartParams(value) {
|
|
|
508
538
|
const retryOf = record.retryOf === undefined
|
|
509
539
|
? undefined
|
|
510
540
|
: requiredId(record.retryOf, "job.start retryOf");
|
|
511
|
-
const caller =
|
|
541
|
+
const caller = parseDurableJobCaller(record.caller);
|
|
512
542
|
return {
|
|
513
543
|
taskId,
|
|
514
544
|
owner,
|
|
@@ -531,7 +561,7 @@ export function parseDurableJobRefParams(value) {
|
|
|
531
561
|
return {
|
|
532
562
|
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
533
563
|
jobId: requiredId(record.jobId, "DurableJob jobId"),
|
|
534
|
-
caller:
|
|
564
|
+
caller: parseDurableJobCaller(record.caller)
|
|
535
565
|
};
|
|
536
566
|
}
|
|
537
567
|
/**
|
|
@@ -548,7 +578,7 @@ export function parseDurableJobCancelParams(value) {
|
|
|
548
578
|
return {
|
|
549
579
|
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
550
580
|
jobId: requiredId(record.jobId, "DurableJob jobId"),
|
|
551
|
-
caller:
|
|
581
|
+
caller: parseDurableJobCaller(record.caller)
|
|
552
582
|
};
|
|
553
583
|
}
|
|
554
584
|
/**
|
|
@@ -564,7 +594,7 @@ export function parseDurableJobAcknowledgeParams(value) {
|
|
|
564
594
|
return {
|
|
565
595
|
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
566
596
|
jobId: requiredId(record.jobId, "DurableJob jobId"),
|
|
567
|
-
caller:
|
|
597
|
+
caller: parseDurableJobCaller(record.caller)
|
|
568
598
|
};
|
|
569
599
|
}
|
|
570
600
|
function parseJobOwner(value) {
|
|
@@ -645,7 +675,7 @@ function parseSteps(value) {
|
|
|
645
675
|
* optional managed Session identity fields. The identity is verified against
|
|
646
676
|
* durable state by `assertCallerAuthorized`; parsing only validates the JSON shape.
|
|
647
677
|
*/
|
|
648
|
-
function
|
|
678
|
+
export function parseDurableJobCaller(value) {
|
|
649
679
|
if (value === undefined) {
|
|
650
680
|
throw jobControlError("INVALID_PARAMS", "job caller is required.");
|
|
651
681
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { openSync, closeSync, fstatSync, readSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { requireKnownFields } from "../domain/validation.js";
|
|
5
|
+
import { createJobDefinition, reviseJobDefinition, jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
|
|
6
|
+
import { acknowledgeUnknownDurableJob, requestDurableJobCancel, validateDurableJob, JOB_RUNNER_IMPLEMENTATION, executionNamespace } from "../job/durableJob.js";
|
|
7
|
+
import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
|
|
8
|
+
import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
|
|
9
|
+
import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
|
|
10
|
+
import { createFileArtifactPort } from "./jobSupervisor.js";
|
|
11
|
+
export function authorizeScope(store, scope, caller) {
|
|
12
|
+
parseDurableJobCaller(caller);
|
|
13
|
+
jobScopeKey(scope);
|
|
14
|
+
// Match Task Context's Operator-only boundary for global callers as well
|
|
15
|
+
// as excluding Task callers from global Jobs. Session identity alone is not
|
|
16
|
+
// permission for a custom GlobalRole to read or manage Task definitions.
|
|
17
|
+
if ((scope.kind === "global" && caller.scope !== "global")
|
|
18
|
+
|| (caller.scope === "global" && caller.role !== "operator")) {
|
|
19
|
+
throw Object.assign(new Error("Only the current Operator Session may access Jobs across global scope."), { name: "CoreApplicationError", code: "UNAUTHORIZED" });
|
|
20
|
+
}
|
|
21
|
+
assertCallerAuthorized(store, caller, scope.kind === "task" ? scope.taskId : "");
|
|
22
|
+
}
|
|
23
|
+
function taskParams(job, caller, requestId) {
|
|
24
|
+
if (job.scope.kind !== "task" || job.configuration.kind !== "shell")
|
|
25
|
+
throw new Error("Not a Task Job.");
|
|
26
|
+
return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
|
|
27
|
+
}
|
|
28
|
+
export function authorizeDefinition(store, job, caller, write) {
|
|
29
|
+
authorizeScope(store, job.scope, caller);
|
|
30
|
+
if (job.scope.kind === "task") {
|
|
31
|
+
const params = taskParams(job, caller, "definition-validation");
|
|
32
|
+
// A definition must not reveal another Assignment's commands/environment.
|
|
33
|
+
requireJobAssignment(store, params, caller);
|
|
34
|
+
if (write)
|
|
35
|
+
validateStartParams(store, params);
|
|
36
|
+
if (write)
|
|
37
|
+
assertNonSecretJobInput(params);
|
|
38
|
+
}
|
|
39
|
+
else if (write) {
|
|
40
|
+
if (job.configuration.kind !== "builtin")
|
|
41
|
+
throw new Error("Global Jobs require builtin actions.");
|
|
42
|
+
prepareBuiltinJob(job.configuration, "/");
|
|
43
|
+
if (job.configuration.action === "project.refresh") {
|
|
44
|
+
const project = requireRefreshProject(store, job.configuration.projectId, true);
|
|
45
|
+
if (projectRefreshDigest(job.configuration) !== projectRefreshTargetDigest(project)) {
|
|
46
|
+
throw new Error("Project refresh target changed; inspect the Project and explicitly update the Job.");
|
|
47
|
+
}
|
|
48
|
+
const duplicate = store.listJobDefinitions({ kind: "global" }).find(other => other.id !== job.id && other.configuration.kind === "builtin"
|
|
49
|
+
&& other.configuration.action === "project.refresh" && other.configuration.projectId === project.id);
|
|
50
|
+
if (duplicate)
|
|
51
|
+
throw new Error(`Project refresh Job already exists: ${duplicate.id}.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function listJobRuns(store, job) {
|
|
56
|
+
return (job.scope.kind === "task" ? store.listDurableJobs(job.scope.taskId) : store.listGlobalJobRuns())
|
|
57
|
+
.filter(run => run.definitionId === job.id);
|
|
58
|
+
}
|
|
59
|
+
/** Read-only presentation; attempts/results remain owned by exact JobRuns. */
|
|
60
|
+
function presentDefinition(store, job) {
|
|
61
|
+
if (job.configuration.kind !== "builtin" || job.configuration.action !== "project.refresh")
|
|
62
|
+
return job;
|
|
63
|
+
const projectId = job.configuration.projectId;
|
|
64
|
+
const targetDigest = projectRefreshDigest(job.configuration);
|
|
65
|
+
const runs = listJobRuns(store, job).filter(run => !("taskId" in run)
|
|
66
|
+
&& run.action === "project.refresh" && run.projectId === projectId
|
|
67
|
+
&& projectRefreshDigest({ kind: "builtin", action: run.action,
|
|
68
|
+
projectId: run.projectId, input: run.actionInput }) === targetDigest)
|
|
69
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
70
|
+
const summarize = (run) => run === undefined ? null : {
|
|
71
|
+
runId: run.id, definitionRevision: run.definitionRevision, trigger: run.trigger,
|
|
72
|
+
status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null
|
|
73
|
+
};
|
|
74
|
+
return { ...job, refresh: {
|
|
75
|
+
projectId, lastAttempt: summarize(runs[0]),
|
|
76
|
+
lastSuccessfulRefresh: summarize(runs.find(run => run.status === "succeeded"))
|
|
77
|
+
} };
|
|
78
|
+
}
|
|
79
|
+
function authorizeRunRead(store, run, caller) {
|
|
80
|
+
// A revised definition's Assignment does not grant access to earlier Runs.
|
|
81
|
+
if ("taskId" in run)
|
|
82
|
+
createDurableJobControl(store).getJob(run.taskId, run.id, caller);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The narrow Schedule seam: call inside the SAME TaskStore transaction as
|
|
86
|
+
* schedule advancement. No external effects occur here; rollback removes the
|
|
87
|
+
* admission as well. The caller must be freshly authenticated on every call.
|
|
88
|
+
*/
|
|
89
|
+
export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf) {
|
|
90
|
+
return store.transaction(tx => {
|
|
91
|
+
const job = tx.getJobDefinition(scope, definitionId);
|
|
92
|
+
if (job === null)
|
|
93
|
+
throw new Error("Job definition not found.");
|
|
94
|
+
authorizeDefinition(tx, job, caller, false);
|
|
95
|
+
if (!trigger.key.trim() || !["manual", "retry", "schedule", "integration"].includes(trigger.source)) {
|
|
96
|
+
throw new Error("Invalid JobRun trigger.");
|
|
97
|
+
}
|
|
98
|
+
const runs = listJobRuns(tx, job);
|
|
99
|
+
const same = runs.find(run => run.trigger.key === trigger.key && run.trigger.source === trigger.source);
|
|
100
|
+
if (same !== undefined) {
|
|
101
|
+
authorizeRunRead(tx, same, caller);
|
|
102
|
+
if (same.definitionRevision !== job.revision || same.retryOf !== retryOf) {
|
|
103
|
+
throw new Error(`JobRun trigger conflicts with its original snapshot: ${same.id}.`);
|
|
104
|
+
}
|
|
105
|
+
return { run: same, created: false };
|
|
106
|
+
}
|
|
107
|
+
authorizeDefinition(tx, job, caller, true);
|
|
108
|
+
const occupied = runs.find(run => run.status === "queued" || run.status === "running"
|
|
109
|
+
|| run.status === "unknown-needs-attention");
|
|
110
|
+
if (occupied !== undefined) {
|
|
111
|
+
authorizeRunRead(tx, occupied, caller);
|
|
112
|
+
return { run: occupied, created: false, reason: "overlap" };
|
|
113
|
+
}
|
|
114
|
+
if (retryOf !== undefined && !runs.some(run => run.id === retryOf
|
|
115
|
+
&& ["failed", "cancelled", "timed-out"].includes(run.status))) {
|
|
116
|
+
throw new Error("Retry requires an exact failed/cancelled/timed-out Run of this Job.");
|
|
117
|
+
}
|
|
118
|
+
const requestId = `job:${job.id}:${trigger.source}:${trigger.key}`;
|
|
119
|
+
if (scope.kind === "task") {
|
|
120
|
+
const result = createDurableJobControl(tx).startJob({
|
|
121
|
+
...taskParams(job, caller, requestId), definitionId, trigger, retryOf
|
|
122
|
+
}, now);
|
|
123
|
+
return { run: result.job, created: result.created };
|
|
124
|
+
}
|
|
125
|
+
if (job.configuration.kind !== "builtin")
|
|
126
|
+
throw new Error("Global Jobs require builtin actions.");
|
|
127
|
+
const id = `job-${randomUUID()}`;
|
|
128
|
+
const locator = `artifacts/jobs/global/${id}`;
|
|
129
|
+
const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
|
|
130
|
+
const actorId = `global:${caller.role}`;
|
|
131
|
+
const run = validateDurableJob({
|
|
132
|
+
schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
|
|
133
|
+
trigger: structuredClone(trigger), action: job.configuration.action,
|
|
134
|
+
actionInput: structuredClone(job.configuration.input),
|
|
135
|
+
...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
|
|
136
|
+
...spec, status: "queued", artifactsLocator: locator,
|
|
137
|
+
idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
|
|
138
|
+
operation: {
|
|
139
|
+
requestId, actorId, authorityRef: jobAuthorityBinding(tx, "global", caller.role, ""),
|
|
140
|
+
targetId: job.id, capability: "job.run", implementation: JOB_RUNNER_IMPLEMENTATION,
|
|
141
|
+
inputDigest: createHash("sha256").update(JSON.stringify([job, trigger, retryOf])).digest("hex"),
|
|
142
|
+
effect: "none", receiptRefs: [], partialResultRefs: []
|
|
143
|
+
},
|
|
144
|
+
...(retryOf === undefined ? {} : { retryOf }),
|
|
145
|
+
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
146
|
+
});
|
|
147
|
+
tx.saveGlobalJobRun(run);
|
|
148
|
+
return { run, created: true };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
export function authorizeGlobalJobStart(store, run) {
|
|
152
|
+
if (run.operation.actorId !== "global:operator"
|
|
153
|
+
|| jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef) {
|
|
154
|
+
throw Object.assign(new Error("Global JobRun caller binding was revoked; no execution was started."), { name: "CoreJobError" });
|
|
155
|
+
}
|
|
156
|
+
// Storage failures are not domain refusals or proof of no execution.
|
|
157
|
+
const projects = run.action === "project.refresh" ? store.listProjects() : [];
|
|
158
|
+
const home = store.rootDirectory();
|
|
159
|
+
try {
|
|
160
|
+
const compiled = prepareBuiltinJob({
|
|
161
|
+
kind: "builtin", action: run.action, input: run.actionInput,
|
|
162
|
+
...(run.projectId === undefined ? {} : { projectId: run.projectId })
|
|
163
|
+
}, run.workspace, { home, runId: run.id });
|
|
164
|
+
if (run.action === "project.refresh") {
|
|
165
|
+
const project = requireRefreshProject({ listProjects: () => projects, rootDirectory: () => home }, run.projectId, true);
|
|
166
|
+
if (projectRefreshTargetDigest(project) !== projectRefreshDigest({
|
|
167
|
+
kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
|
|
168
|
+
}))
|
|
169
|
+
throw new Error("Project refresh target changed before execution.");
|
|
170
|
+
if (run.workspace !== join(home, "artifacts/jobs/global", run.id)) {
|
|
171
|
+
throw new Error("Project refresh Run workspace does not match its artifact directory.");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
|
|
175
|
+
|| JSON.stringify(compiled.env) !== JSON.stringify(run.env) || compiled.head !== run.head) {
|
|
176
|
+
throw new Error("Global JobRun does not match its controlled action.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
throw Object.assign(new Error(error instanceof Error ? error.message : String(error), { cause: error }), { name: "CoreJobError" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** One bounded RPC surface; legacy job.start/get/cancel remain exact-run APIs. */
|
|
184
|
+
export function createJobDefinitionControl(store, home) {
|
|
185
|
+
return (value, now) => {
|
|
186
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
187
|
+
throw new Error("Invalid Job request.");
|
|
188
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId"], "Job request");
|
|
189
|
+
const input = value;
|
|
190
|
+
authorizeScope(store, input.scope, input.caller);
|
|
191
|
+
return store.transaction(tx => {
|
|
192
|
+
if (input.op === "list") {
|
|
193
|
+
return tx.listJobDefinitions(input.scope).filter(job => {
|
|
194
|
+
try {
|
|
195
|
+
authorizeDefinition(tx, job, input.caller, false);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}).map(job => presentDefinition(tx, job));
|
|
202
|
+
}
|
|
203
|
+
if (typeof input.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(input.id))
|
|
204
|
+
throw new Error("An explicit safe Job id is required.");
|
|
205
|
+
const existing = tx.getJobDefinition(input.scope, input.id);
|
|
206
|
+
if (input.op === "define" || input.op === "update") {
|
|
207
|
+
let configuration = input.configuration;
|
|
208
|
+
// Empty input is an explicit request to bind the current catalog target.
|
|
209
|
+
// A saved digest is never silently refreshed on run/retry.
|
|
210
|
+
if (input.scope.kind === "global" && configuration?.kind === "builtin"
|
|
211
|
+
&& configuration.action === "project.refresh" && JSON.stringify(configuration.input) === "{}") {
|
|
212
|
+
const project = requireRefreshProject(tx, configuration.projectId, true);
|
|
213
|
+
configuration = { ...configuration, input: { targetDigest: projectRefreshTargetDigest(project) } };
|
|
214
|
+
}
|
|
215
|
+
const fields = { title: input.title, configuration };
|
|
216
|
+
if (input.op === "define") {
|
|
217
|
+
const next = createJobDefinition({ id: input.id, scope: input.scope, ...fields }, now);
|
|
218
|
+
authorizeDefinition(tx, next, input.caller, true);
|
|
219
|
+
if (existing !== null) {
|
|
220
|
+
if (JSON.stringify(existing.configuration) !== JSON.stringify(next.configuration) || existing.title !== next.title) {
|
|
221
|
+
throw new Error("Job id already exists with different configuration; use update with its revision.");
|
|
222
|
+
}
|
|
223
|
+
return existing;
|
|
224
|
+
}
|
|
225
|
+
tx.saveJobDefinition(next);
|
|
226
|
+
return next;
|
|
227
|
+
}
|
|
228
|
+
if (existing === null || existing.revision !== input.expectedRevision)
|
|
229
|
+
throw new Error("Job revision changed or Job missing.");
|
|
230
|
+
// The old snapshot may no longer be runnable (for example HEAD moved).
|
|
231
|
+
// Check its ownership before validating the replacement for execution.
|
|
232
|
+
authorizeDefinition(tx, existing, input.caller, false);
|
|
233
|
+
const next = reviseJobDefinition(existing, fields, now);
|
|
234
|
+
authorizeDefinition(tx, next, input.caller, true);
|
|
235
|
+
tx.saveJobDefinition(next);
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
if (existing === null)
|
|
239
|
+
throw new Error("Job definition not found.");
|
|
240
|
+
validateJobDefinition(existing);
|
|
241
|
+
authorizeDefinition(tx, existing, input.caller, false);
|
|
242
|
+
if (input.op === "show")
|
|
243
|
+
return presentDefinition(tx, existing);
|
|
244
|
+
if (input.op === "runs")
|
|
245
|
+
return listJobRuns(tx, existing).filter(run => {
|
|
246
|
+
try {
|
|
247
|
+
authorizeRunRead(tx, run, input.caller);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
if (input.op === "run") {
|
|
255
|
+
if (input.trigger === undefined)
|
|
256
|
+
throw new Error("A trigger identity is required.");
|
|
257
|
+
// Schedule is an internal transactional caller, not a manual RPC claim.
|
|
258
|
+
if (!["manual", "retry"].includes(input.trigger.source))
|
|
259
|
+
throw new Error("RPC triggers must be manual or retry.");
|
|
260
|
+
return admitJobRun(tx, home, input.scope, input.id, input.caller, input.trigger, now, input.retryOf);
|
|
261
|
+
}
|
|
262
|
+
const run = listJobRuns(tx, existing).find(candidate => candidate.id === input.runId);
|
|
263
|
+
if (run === undefined)
|
|
264
|
+
throw new Error("Exact JobRun not found.");
|
|
265
|
+
authorizeRunRead(tx, run, input.caller);
|
|
266
|
+
if (input.op === "run-get")
|
|
267
|
+
return run;
|
|
268
|
+
if (input.op === "logs") {
|
|
269
|
+
const steps = run.result?.steps ?? createFileArtifactPort(home)
|
|
270
|
+
.readCheckpoint(executionNamespace(run), run.id)?.completedSteps ?? run.checkpoint?.completedSteps ?? [];
|
|
271
|
+
if (steps.length === 0)
|
|
272
|
+
return [];
|
|
273
|
+
const root = realpathSync(resolve(home, run.artifactsLocator, "logs"));
|
|
274
|
+
return steps.map(step => {
|
|
275
|
+
const path = realpathSync(resolve(root, step.logPath));
|
|
276
|
+
if (!path.startsWith(root + sep))
|
|
277
|
+
throw new Error("JobRun log escaped its artifact directory.");
|
|
278
|
+
const fd = openSync(path, "r");
|
|
279
|
+
try {
|
|
280
|
+
const size = fstatSync(fd).size;
|
|
281
|
+
const bytes = Buffer.alloc(Math.min(size, 65536));
|
|
282
|
+
const count = readSync(fd, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
283
|
+
return { step: step.name, path, text: bytes.subarray(0, count).toString("utf8"), truncated: size > bytes.length };
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
closeSync(fd);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (input.op === "run-cancel" || input.op === "run-acknowledge") {
|
|
291
|
+
if ("taskId" in run) {
|
|
292
|
+
const control = createDurableJobControl(tx);
|
|
293
|
+
return input.op === "run-cancel"
|
|
294
|
+
? control.cancelJob(run.taskId, run.id, now, input.caller)
|
|
295
|
+
: control.acknowledgeJob(run.taskId, run.id, now, input.caller);
|
|
296
|
+
}
|
|
297
|
+
const next = input.op === "run-cancel" ? requestDurableJobCancel(run, now) : acknowledgeUnknownDurableJob(run, now);
|
|
298
|
+
tx.saveGlobalJobRun(next);
|
|
299
|
+
return next;
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`Unknown Job operation: ${input.op}.`);
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
}
|