@zq-silk/yui 2.8.1 → 2.10.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/README.md +5 -3
- package/dist/cli/commandCatalog.js +56 -21
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionPolicy.js +2 -2
- package/dist/commands/durableJobCommands.js +50 -0
- package/dist/commands/globalRoleCommands.js +30 -9
- package/dist/commands/inboxCommands.js +79 -22
- 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 +8 -2
- package/dist/commands/taskFactCommands.js +24 -12
- package/dist/commands/taskProjectDeliveryCommand.js +56 -0
- package/dist/commands/taskPublicationCommands.js +6 -3
- package/dist/commands/taskPublicationVerifyCommand.js +74 -28
- package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
- package/dist/context/runContextPack.js +25 -4
- package/dist/context/runInputContract.js +1 -0
- package/dist/context/taskContext.js +53 -16
- package/dist/context/taskPhase.js +99 -0
- package/dist/controlPlaneCli.js +38 -17
- package/dist/controller/controller.js +35 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +75 -16
- package/dist/controller/globalInputDelivery.js +17 -7
- package/dist/controller/inboxTurnCheck.js +64 -0
- package/dist/controller/jobControl.js +42 -12
- package/dist/controller/jobDefinitionControl.js +304 -0
- package/dist/controller/jobSupervisor.js +47 -32
- package/dist/controller/providerRetryAdmission.js +2 -0
- 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 +12 -29
- package/dist/grant/taskAuthorization.js +7 -2
- package/dist/interaction/notificationPresentation.js +12 -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/message/globalInboxRecovery.js +44 -0
- package/dist/message/globalInterrupt.js +1 -1
- package/dist/message/inboxReceipt.js +14 -6
- package/dist/message/inboxTurn.js +86 -0
- package/dist/message/message.js +11 -0
- package/dist/message/taskInboxOwnership.js +20 -6
- package/dist/repository/projectRefresh.js +58 -0
- package/dist/repository/taskWorkspacePreparer.js +2 -1
- package/dist/repository/workspaceCleanupInspection.js +6 -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 +1 -1
- package/dist/storage/recordValidation.js +5 -0
- package/dist/storage/scheduleSchema.js +12 -0
- package/dist/storage/sqliteSchema.js +95 -24
- package/dist/storage/sqliteStore.js +123 -10
- 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/archiveDiagnostics.js +1 -1
- package/dist/task/archivePreflight.js +1 -1
- package/dist/task/projectDeliveryScope.js +20 -0
- package/dist/task/publicationToolObservation.js +126 -0
- package/dist/task/remoteDelivery.js +19 -8
- package/dist/task/remoteDeliveryService.js +5 -4
- 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 +65 -2
- package/dist/web/assets/client/i18n/zh-CN.js +65 -2
- 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/deliveryResults.js +6 -2
- 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/controllerWeb.js +73 -21
- package/dist/web/taskPreviews.js +21 -13
- package/dist/web/webAccess.js +109 -0
- package/dist/web/webJobs.js +52 -0
- package/dist/web/webServer.js +76 -44
- package/docs/architecture/capabilities-and-resources.md +6 -4
- package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
- package/docs/cli-information-contract.md +57 -4
- package/docs/cli-information-contract.zh-CN.md +41 -2
- package/docs/job-runs.md +224 -0
- package/docs/project-delivery-scope.md +59 -0
- package/docs/release-workflow.md +86 -0
- package/docs/release-workflow.zh-CN.md +68 -0
- package/docs/schedules.md +132 -0
- package/docs/storage-baseline.md +31 -1
- package/docs/storage-baseline.zh-CN.md +25 -1
- package/docs/task-delivery.md +74 -2
- package/docs/task-delivery.zh-CN.md +39 -1
- package/docs/testing/verification-levels.md +6 -2
- package/docs/testing/verification-levels.zh-CN.md +6 -3
- package/i18n/README.zh-CN.md +6 -3
- package/package.json +4 -3
- package/skills/yui-leader/SKILL.md +44 -1
- package/skills/yui-leader/VERSION +1 -1
- package/skills/yui-leader/references/planning.md +9 -1
- package/skills/yui-operator/SKILL.md +31 -0
- package/skills/yui-operator/VERSION +1 -1
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-reviewer/VERSION +1 -1
- package/skills/yui-runtime/SKILL.md +67 -8
- 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-runtime/references/recovery.md +21 -0
- package/skills/yui-worker/SKILL.md +5 -0
- package/skills/yui-worker/VERSION +1 -1
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,7 @@ import { spawn } from "node:child_process";
|
|
|
15
15
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, rejectQueuedDurableJob, startDurableJob, touchDurableJobHeartbeat } from "../job/durableJob.js";
|
|
18
|
+
import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markDurableJobUnknown, markDurableJobWakeupNotified, rejectQueuedDurableJob, startDurableJob, touchDurableJobHeartbeat, executionNamespace } from "../job/durableJob.js";
|
|
19
19
|
import { recordOperationEvidence } from "../kernel/operationFacts.js";
|
|
20
20
|
import { wakeReason } from "../scheduler/wakeReason.js";
|
|
21
21
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
@@ -45,12 +45,23 @@ export class DurableJobSupervisor {
|
|
|
45
45
|
}
|
|
46
46
|
reconcile(now) {
|
|
47
47
|
const jobs = this.#store.listActiveDurableJobs();
|
|
48
|
-
|
|
48
|
+
const refresh = (job) => !("taskId" in job) && job.action === "project.refresh";
|
|
49
|
+
const waiting = (job) => refresh(job) && job.status === "queued"
|
|
50
|
+
&& job.operation.effect === "none" && !job.cancelRequestedAt;
|
|
51
|
+
// Harvest/recover existing attempts first. Network operations remain in
|
|
52
|
+
// detached runners, with at most four outstanding Project refreshes.
|
|
53
|
+
const ordered = [...jobs.filter(job => !waiting(job)), ...jobs.filter(waiting)];
|
|
54
|
+
for (const job of ordered) {
|
|
49
55
|
try {
|
|
56
|
+
if (waiting(job)) {
|
|
57
|
+
const occupied = this.#store.listActiveDurableJobs().filter(candidate => refresh(candidate) && (candidate.status === "running" || candidate.operation.effect !== "none"));
|
|
58
|
+
if (occupied.length >= 4)
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
50
61
|
this.#reconcileJob(job, now);
|
|
51
62
|
}
|
|
52
63
|
catch (error) {
|
|
53
|
-
this.#onError(new Error(`DurableJob supervision failed for ${job
|
|
64
|
+
this.#onError(new Error(`DurableJob supervision failed for ${executionNamespace(job)}/${job.id}: `
|
|
54
65
|
+ `${error instanceof Error ? error.message : String(error)}. `
|
|
55
66
|
+ "Inspect retained process/artifact evidence before retry or cancellation; effects remain unconfirmed.", { cause: error }));
|
|
56
67
|
}
|
|
@@ -68,14 +79,14 @@ export class DurableJobSupervisor {
|
|
|
68
79
|
}
|
|
69
80
|
/** Composite key for the SIGKILL deadline map. */
|
|
70
81
|
#sigkillKey(job) {
|
|
71
|
-
return `${job
|
|
82
|
+
return `${executionNamespace(job)}/${job.id}`;
|
|
72
83
|
}
|
|
73
84
|
/** Adopt observed execution, preserve unknown, or start an unattempted request. */
|
|
74
85
|
#reconcileQueued(job, now) {
|
|
75
|
-
const marker = this.#artifacts.readStartMarker(job
|
|
86
|
+
const marker = this.#artifacts.readStartMarker(executionNamespace(job), job.id);
|
|
76
87
|
const spawned = this.#spawnedProcessFromEvidence(job, marker);
|
|
77
88
|
if (job.cancelRequestedAt !== undefined) {
|
|
78
|
-
this.#artifacts.writeCancelFence(job
|
|
89
|
+
this.#artifacts.writeCancelFence(executionNamespace(job), job.id);
|
|
79
90
|
if (spawned !== null) {
|
|
80
91
|
this.#process.signalIfOwned(spawned.pid, spawned.startIdentity, "SIGTERM");
|
|
81
92
|
}
|
|
@@ -87,13 +98,13 @@ export class DurableJobSupervisor {
|
|
|
87
98
|
if (job.operation.effect !== "none") {
|
|
88
99
|
// A send intent was committed but no acceptance can be proved. Absence
|
|
89
100
|
// of a file is not proof of no external effect. Never respawn unknown.
|
|
90
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
101
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner acceptance is unknown; inspect the original request", current.checkpoint?.completedSteps ?? [], now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
91
102
|
this.#deliverTerminalEvent(terminal);
|
|
92
103
|
return;
|
|
93
104
|
}
|
|
94
105
|
if (job.cancelRequestedAt !== undefined) {
|
|
95
106
|
// No effect attempted. Cancellation and its wake commit atomically.
|
|
96
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
107
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(cancelQueuedDurableJob(current, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
97
108
|
this.#deliverTerminalEvent(terminal);
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
@@ -112,7 +123,7 @@ export class DurableJobSupervisor {
|
|
|
112
123
|
// around the spawn), while ready.json is written by the runner itself
|
|
113
124
|
// before any side effect. A disagreement (PID reuse, spawn retry, stale
|
|
114
125
|
// /proc read) is resolved in favor of ready.json.
|
|
115
|
-
const ready = this.#artifacts.readReadyFile(job
|
|
126
|
+
const ready = this.#artifacts.readReadyFile(executionNamespace(job), job.id);
|
|
116
127
|
if (ready !== null) {
|
|
117
128
|
return { pid: ready.pid, startIdentity: ready.startIdentity };
|
|
118
129
|
}
|
|
@@ -128,9 +139,9 @@ export class DurableJobSupervisor {
|
|
|
128
139
|
* process. A request with unobserved acceptance instead stays unknown.
|
|
129
140
|
*/
|
|
130
141
|
#adoptAndContinue(job, process, now) {
|
|
131
|
-
this.#store.transitionDurableJob(job
|
|
142
|
+
this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => startDurableJob(current, process, now), now);
|
|
132
143
|
// The runner may already have finished while the Controller was down.
|
|
133
|
-
const exit = this.#artifacts.readExitJson(job
|
|
144
|
+
const exit = this.#artifacts.readExitJson(executionNamespace(job), job.id);
|
|
134
145
|
if (exit !== null) {
|
|
135
146
|
this.#harvestExit(job, exit, now);
|
|
136
147
|
return;
|
|
@@ -153,18 +164,18 @@ export class DurableJobSupervisor {
|
|
|
153
164
|
}
|
|
154
165
|
const spec = {
|
|
155
166
|
jobId: job.id,
|
|
156
|
-
taskId: job.taskId,
|
|
167
|
+
...("taskId" in job ? { taskId: job.taskId } : { scope: "global" }),
|
|
157
168
|
workspace: job.workspace,
|
|
158
169
|
env: job.env,
|
|
159
170
|
steps: job.steps,
|
|
160
171
|
defaultStepTimeoutMs: DEFAULT_STEP_TIMEOUT_MS,
|
|
161
|
-
artifactDir: this.#artifacts.artifactDir(job
|
|
172
|
+
artifactDir: this.#artifacts.artifactDir(executionNamespace(job), job.id),
|
|
162
173
|
head: job.head
|
|
163
174
|
};
|
|
164
|
-
const specPath = this.#artifacts.writeSpec(job
|
|
175
|
+
const specPath = this.#artifacts.writeSpec(executionNamespace(job), job.id, spec);
|
|
165
176
|
let attempted;
|
|
166
177
|
try {
|
|
167
|
-
attempted = this.#store.transitionDurableJob(job
|
|
178
|
+
attempted = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
|
|
168
179
|
if (current.status !== "queued" || current.operation.effect !== "none") {
|
|
169
180
|
throw new Error("Job already attempted; inspect the original request.");
|
|
170
181
|
}
|
|
@@ -184,7 +195,7 @@ export class DurableJobSupervisor {
|
|
|
184
195
|
return;
|
|
185
196
|
// Both the request and possible-effect boundary precede spawn. A missing
|
|
186
197
|
// acceptance after this point is unknown, never permission to respawn.
|
|
187
|
-
this.#artifacts.writeStartMarker(job
|
|
198
|
+
this.#artifacts.writeStartMarker(executionNamespace(job), job.id, {
|
|
188
199
|
pid: 0,
|
|
189
200
|
startIdentity: "pending",
|
|
190
201
|
spawnedAt: now.toISOString()
|
|
@@ -194,7 +205,7 @@ export class DurableJobSupervisor {
|
|
|
194
205
|
// the runner's own ready.json is the fallback: the next reconcile reads
|
|
195
206
|
// ready and adopts. The runner checks ready with O_EXCL and exits if it
|
|
196
207
|
// already exists, preventing duplicate execution.
|
|
197
|
-
this.#artifacts.writeStartMarker(job
|
|
208
|
+
this.#artifacts.writeStartMarker(executionNamespace(job), job.id, {
|
|
198
209
|
pid: spawned.pid,
|
|
199
210
|
startIdentity: spawned.startIdentity ?? String(spawned.pid),
|
|
200
211
|
spawnedAt: now.toISOString()
|
|
@@ -206,27 +217,27 @@ export class DurableJobSupervisor {
|
|
|
206
217
|
// terminalized or no longer exists; the recovery interval stays the
|
|
207
218
|
// cross-restart fallback. No new timer is pinned while the Controller is
|
|
208
219
|
// idle — each wake is a single event-driven signal.
|
|
209
|
-
spawned.onExit?.(() => this.#wake(job
|
|
210
|
-
this.#wake(job
|
|
220
|
+
spawned.onExit?.(() => this.#wake(executionNamespace(job)));
|
|
221
|
+
this.#wake(executionNamespace(job));
|
|
211
222
|
}
|
|
212
223
|
#rejectStart(job, error, now) {
|
|
213
224
|
// Only an explicit domain refusal is a known failure. Storage/CAS/runtime
|
|
214
225
|
// errors still propagate; never infer rejection from an unavailable read.
|
|
215
226
|
if (!(error instanceof Error) || error.name !== "CoreJobError")
|
|
216
227
|
throw error;
|
|
217
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
228
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(rejectQueuedDurableJob(current, error.message, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
218
229
|
this.#deliverTerminalEvent(terminal);
|
|
219
230
|
}
|
|
220
231
|
#superviseRunning(job, now) {
|
|
221
232
|
// 1. Harvest exit.json if present.
|
|
222
|
-
const exit = this.#artifacts.readExitJson(job
|
|
233
|
+
const exit = this.#artifacts.readExitJson(executionNamespace(job), job.id);
|
|
223
234
|
if (exit !== null) {
|
|
224
235
|
this.#harvestExit(job, exit, now);
|
|
225
236
|
return;
|
|
226
237
|
}
|
|
227
238
|
// 2. Cancel requested: write fence + SIGTERM.
|
|
228
239
|
if (job.cancelRequestedAt !== undefined && job.process !== undefined) {
|
|
229
|
-
this.#artifacts.writeCancelFence(job
|
|
240
|
+
this.#artifacts.writeCancelFence(executionNamespace(job), job.id);
|
|
230
241
|
this.#process.signalIfOwned(job.process.pid, job.process.startIdentity, "SIGTERM");
|
|
231
242
|
}
|
|
232
243
|
// 3. Process liveness via pid + startIdentity.
|
|
@@ -241,7 +252,7 @@ export class DurableJobSupervisor {
|
|
|
241
252
|
}
|
|
242
253
|
}
|
|
243
254
|
// 4. Heartbeat freshness.
|
|
244
|
-
const heartbeatMtime = this.#artifacts.heartbeatMtime(job
|
|
255
|
+
const heartbeatMtime = this.#artifacts.heartbeatMtime(executionNamespace(job), job.id);
|
|
245
256
|
if (heartbeatMtime !== null) {
|
|
246
257
|
const staleMs = now.getTime() - heartbeatMtime;
|
|
247
258
|
if (staleMs > HEARTBEAT_STALE_MS && job.process !== undefined) {
|
|
@@ -255,7 +266,7 @@ export class DurableJobSupervisor {
|
|
|
255
266
|
? Date.parse(job.heartbeatAt)
|
|
256
267
|
: 0;
|
|
257
268
|
if (heartbeatMtime > recordHeartbeat) {
|
|
258
|
-
this.#store.transitionDurableJob(job
|
|
269
|
+
this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => touchDurableJobHeartbeat(current, new Date(heartbeatMtime)), now);
|
|
259
270
|
}
|
|
260
271
|
}
|
|
261
272
|
// 5. SIGKILL escalation.
|
|
@@ -268,7 +279,7 @@ export class DurableJobSupervisor {
|
|
|
268
279
|
#harvestExit(job, exit, now) {
|
|
269
280
|
// Persist the original receipt locator before interpreting its output.
|
|
270
281
|
// A bad schema cannot erase evidence of an already executed runner.
|
|
271
|
-
this.#store.transitionDurableJob(job
|
|
282
|
+
this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => ({
|
|
272
283
|
...current,
|
|
273
284
|
operation: recordOperationEvidence(current.operation, {
|
|
274
285
|
effect: "confirmed",
|
|
@@ -292,7 +303,7 @@ export class DurableJobSupervisor {
|
|
|
292
303
|
// the adapter enqueues the Leader mailbox entry in the same transaction.
|
|
293
304
|
// A separate #notifyWakeup pass would lose the wakeup if the Controller
|
|
294
305
|
// died between the terminal write and the flag flip.
|
|
295
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
306
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => {
|
|
296
307
|
let completed;
|
|
297
308
|
try {
|
|
298
309
|
completed = completeDurableJob(current, result, now);
|
|
@@ -310,7 +321,7 @@ export class DurableJobSupervisor {
|
|
|
310
321
|
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
311
322
|
}
|
|
312
323
|
#handleDeadProcess(job, now) {
|
|
313
|
-
const checkpoint = this.#artifacts.readCheckpoint(job
|
|
324
|
+
const checkpoint = this.#artifacts.readCheckpoint(executionNamespace(job), job.id);
|
|
314
325
|
const proven = checkpoint === null ? null : proveOutcomeFromCheckpoint(job, checkpoint);
|
|
315
326
|
// rr4/finding-7: Same atomic wakeup composition as #harvestExit — the
|
|
316
327
|
// terminal transition and the Leader wakeup commit together.
|
|
@@ -318,12 +329,12 @@ export class DurableJobSupervisor {
|
|
|
318
329
|
// The runner died without exit.json, but its step checkpoint covered
|
|
319
330
|
// every planned step: the outcome is still provable from durable
|
|
320
331
|
// evidence. No false pass — the checkpoint is the runner's own record.
|
|
321
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
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) });
|
|
322
333
|
this.#deliverTerminalEvent(terminal);
|
|
323
334
|
}
|
|
324
335
|
else {
|
|
325
336
|
const completedSteps = checkpoint?.completedSteps ?? [];
|
|
326
|
-
const terminal = this.#store.transitionDurableJob(job
|
|
337
|
+
const terminal = this.#store.transitionDurableJob(executionNamespace(job), job.id, (current) => markDurableJobWakeupNotified(markDurableJobUnknown(current, "runner process exited without writing exit.json", completedSteps, now), now), now, { reason: wakeReason("job-finished"), refs: wakeupRefs(job) });
|
|
327
338
|
this.#deliverTerminalEvent(terminal);
|
|
328
339
|
}
|
|
329
340
|
this.#sigkillAt.delete(this.#sigkillKey(job));
|
|
@@ -339,8 +350,10 @@ export class DurableJobSupervisor {
|
|
|
339
350
|
#deliverTerminalEvent(job) {
|
|
340
351
|
if (job === null || !isDurableJobTerminal(job.status))
|
|
341
352
|
return;
|
|
353
|
+
if (!("taskId" in job))
|
|
354
|
+
return; // The durable Operator mailbox owns global notices.
|
|
342
355
|
this.#terminalEvents?.deliverTerminalEvent({
|
|
343
|
-
taskId: job
|
|
356
|
+
taskId: executionNamespace(job),
|
|
344
357
|
jobId: job.id,
|
|
345
358
|
status: job.status,
|
|
346
359
|
outcome: job.result?.outcome ?? job.status
|
|
@@ -348,9 +361,11 @@ export class DurableJobSupervisor {
|
|
|
348
361
|
}
|
|
349
362
|
}
|
|
350
363
|
function wakeupRefs(job) {
|
|
351
|
-
|
|
364
|
+
if (!("taskId" in job))
|
|
365
|
+
return [];
|
|
366
|
+
const refs = [{ type: "task", id: executionNamespace(job) }];
|
|
352
367
|
if (job.owner.kind === "work-item") {
|
|
353
|
-
refs.push({ type: "work-item", taskId: job
|
|
368
|
+
refs.push({ type: "work-item", taskId: executionNamespace(job), id: job.owner.workItemId });
|
|
354
369
|
}
|
|
355
370
|
return refs;
|
|
356
371
|
}
|
|
@@ -2,6 +2,7 @@ import { admitProviderRetry, cancelProviderRetry, providerRetryAttemptId, provid
|
|
|
2
2
|
import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
|
|
3
3
|
import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { isHandlingReminder } from "../message/inboxTurn.js";
|
|
5
6
|
export function ownedProviderInput(input) {
|
|
6
7
|
if (input.runId !== undefined && input.taskId !== undefined) {
|
|
7
8
|
return { kind: "run", taskId: input.taskId, runId: input.runId };
|
|
@@ -26,6 +27,7 @@ export function retryIntentBlocker(store, input, retry) {
|
|
|
26
27
|
return "role-configuration-changed";
|
|
27
28
|
if (store.listGlobalRoleMessages(input.roleName).some(message => (message.kind === "user" || message.kind === "operator"
|
|
28
29
|
|| message.inputControl !== undefined || message.interruptThen !== undefined)
|
|
30
|
+
&& !isHandlingReminder(message)
|
|
29
31
|
&& Date.parse(message.createdAt) > Date.parse(retry.intentAt)
|
|
30
32
|
&& message.delivery === undefined && message.notDelivered === undefined))
|
|
31
33
|
return "new-user-input";
|
|
@@ -45,6 +45,7 @@ import { archiveOrdinaryTask } from "../task/ordinaryArchive.js";
|
|
|
45
45
|
import { createWebSettings } from "../web/webSettings.js";
|
|
46
46
|
import { createModelManagement } from "../models/modelManagement.js";
|
|
47
47
|
import { createWebSkills } from "../web/webSkills.js";
|
|
48
|
+
import { createWebJobs } from "../web/webJobs.js";
|
|
48
49
|
import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
|
|
49
50
|
import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
|
|
50
51
|
import { createCapabilityDispatcher } from "./capabilityBridge.js";
|
|
@@ -55,6 +56,8 @@ import { createEphemeralResourceReaper } from "./ephemeralResourceReaper.js";
|
|
|
55
56
|
import { AgentHostProviderSessionBusyError, AgentHostProviderTurnFenceError, FileSchedulerStoreAdapter } from "./fileSchedulerStoreAdapter.js";
|
|
56
57
|
import { deliverGlobalInputs } from "./globalInputDelivery.js";
|
|
57
58
|
import { authorizeJobStart } from "./jobControl.js";
|
|
59
|
+
import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
|
|
60
|
+
import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
|
|
58
61
|
import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
|
|
59
62
|
import { createProviderRetryHooks } from "./providerRetryDelivery.js";
|
|
60
63
|
import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
|
|
@@ -412,6 +415,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
412
415
|
const web = createControllerWeb(store, {
|
|
413
416
|
previews: () => createTaskPreviews(store, home),
|
|
414
417
|
skills: createWebSkills(store),
|
|
418
|
+
jobs: createWebJobs(store, home),
|
|
415
419
|
models: createModelManagement(store),
|
|
416
420
|
settings: createWebSettings(store, {
|
|
417
421
|
environment: options.environment ?? process.env, catalogs,
|
|
@@ -483,7 +487,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
483
487
|
store: schedulerStore,
|
|
484
488
|
process: kernel.runner,
|
|
485
489
|
artifacts: createFileArtifactPort(home),
|
|
486
|
-
authorizeStart: (job) => authorizeJobStart(store, job),
|
|
490
|
+
authorizeStart: (job) => "taskId" in job ? authorizeJobStart(store, job) : authorizeGlobalJobStart(store, job),
|
|
487
491
|
// rr6/f1: Bounded supervision wake. The supervisor signals the Controller
|
|
488
492
|
// after spawning a runner (queued→running adoption) and when a runner
|
|
489
493
|
// exits (terminal harvest), so a quick job converges without waiting for
|
|
@@ -492,7 +496,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
492
496
|
// no-op. The recovery interval stays the cross-restart fallback.
|
|
493
497
|
wake: (taskId) => {
|
|
494
498
|
try {
|
|
495
|
-
runningRuntime?.signal(`task:${taskId}`);
|
|
499
|
+
runningRuntime?.signal(taskId === "global" ? "operator" : `task:${taskId}`);
|
|
496
500
|
}
|
|
497
501
|
catch {
|
|
498
502
|
// Controller stopped; the recovery interval remains the fallback.
|
|
@@ -568,6 +572,11 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
568
572
|
await lifecycleDispatcher("runtime.ensure-role-session", { scope: "global", roleName });
|
|
569
573
|
}, options.onError ?? (() => undefined), deliveryOptions),
|
|
570
574
|
jobControl,
|
|
575
|
+
jobDefinitions: createJobDefinitionControl(store, home),
|
|
576
|
+
schedules: {
|
|
577
|
+
control: createScheduleControl(store, home),
|
|
578
|
+
reconcile: now => reconcileSchedules(store, home, now, options.onError)
|
|
579
|
+
},
|
|
571
580
|
capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
|
|
572
581
|
...(continuationReconciler === undefined ? {} : { continuationReconciler }),
|
|
573
582
|
...(resourceReaper === undefined ? {} : { resourceReaper }),
|