@zq-silk/yui 2.8.1 → 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/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 +6 -0
- 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 +1 -1
- package/dist/storage/recordValidation.js +5 -0
- package/dist/storage/scheduleSchema.js +12 -0
- package/dist/storage/sqliteSchema.js +74 -24
- 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 +43 -0
- package/docs/release-workflow.zh-CN.md +33 -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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { requireKnownFields } from "../domain/validation.js";
|
|
3
|
+
/**
|
|
4
|
+
* Controlled action compilation, not a shell/Agent plugin surface.
|
|
5
|
+
* Unknown actions fail closed; executable paths/argv are never caller supplied.
|
|
6
|
+
*/
|
|
7
|
+
export function prepareBuiltinJob(configuration, workspace, execution) {
|
|
8
|
+
if (configuration.action === "project.refresh") {
|
|
9
|
+
projectRefreshDigest(configuration);
|
|
10
|
+
// Runtime-owned Git discovery/authentication context, never caller input
|
|
11
|
+
// or arbitrary inherited credentials. Background Git cannot prompt.
|
|
12
|
+
const env = { GIT_TERMINAL_PROMPT: "0" };
|
|
13
|
+
for (const key of ["PATH", "HOME", "SSH_AUTH_SOCK", "XDG_CONFIG_HOME", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_GLOBAL"]) {
|
|
14
|
+
if (process.env[key] !== undefined)
|
|
15
|
+
env[key] = process.env[key];
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
workspace, env, head: "0".repeat(40),
|
|
19
|
+
steps: [{
|
|
20
|
+
name: "project.refresh", command: `project.refresh ${configuration.projectId}`,
|
|
21
|
+
argv: [process.execPath, fileURLToPath(new URL("./projectRefreshRunner.js", import.meta.url)),
|
|
22
|
+
execution?.home ?? "", execution?.runId ?? ""],
|
|
23
|
+
timeoutMs: 10 * 60_000
|
|
24
|
+
}]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (configuration.action !== "system.noop")
|
|
28
|
+
throw new Error(`Unsupported builtin Job action: ${configuration.action}.`);
|
|
29
|
+
if (configuration.projectId !== undefined || JSON.stringify(configuration.input) !== "{}") {
|
|
30
|
+
throw new Error("system.noop accepts an empty input and no Project target.");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
workspace,
|
|
34
|
+
env: {},
|
|
35
|
+
// No source repository applies to this action. This explicit sentinel is
|
|
36
|
+
// never an Integration candidate or evidence of a Project revision.
|
|
37
|
+
head: "0".repeat(40),
|
|
38
|
+
steps: [{
|
|
39
|
+
name: "system.noop",
|
|
40
|
+
command: "system.noop",
|
|
41
|
+
argv: [process.execPath, "-e", "process.stdout.write('system.noop completed\\n')"],
|
|
42
|
+
timeoutMs: 5000
|
|
43
|
+
}]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function projectRefreshDigest(configuration) {
|
|
47
|
+
if (!/^project-\d+$/u.test(configuration.projectId ?? ""))
|
|
48
|
+
throw new Error("project.refresh requires an exact Project ID.");
|
|
49
|
+
const input = configuration.input;
|
|
50
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
51
|
+
throw new Error("Invalid project.refresh input.");
|
|
52
|
+
requireKnownFields(input, ["targetDigest"], "project.refresh input");
|
|
53
|
+
const digest = input.targetDigest;
|
|
54
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/u.test(digest)) {
|
|
55
|
+
throw new Error("project.refresh requires its current catalog targetDigest.");
|
|
56
|
+
}
|
|
57
|
+
return digest;
|
|
58
|
+
}
|
package/dist/job/durableJob.js
CHANGED
|
@@ -11,6 +11,9 @@ export const DURABLE_JOB_TERMINAL_STATUSES = [
|
|
|
11
11
|
"cancelled",
|
|
12
12
|
"unknown-needs-attention"
|
|
13
13
|
];
|
|
14
|
+
export function executionNamespace(run) {
|
|
15
|
+
return "taskId" in run ? run.taskId : "global";
|
|
16
|
+
}
|
|
14
17
|
export function isDurableJobTerminal(status) {
|
|
15
18
|
return DURABLE_JOB_TERMINAL_STATUSES.includes(status);
|
|
16
19
|
}
|
|
@@ -22,6 +25,9 @@ export function createDurableJob(input, now) {
|
|
|
22
25
|
return validateDurableJob({
|
|
23
26
|
schemaVersion: CURRENT_DURABLE_JOB_SCHEMA_VERSION,
|
|
24
27
|
id: input.id,
|
|
28
|
+
definitionId: input.definitionId ?? input.id,
|
|
29
|
+
definitionRevision: input.definitionRevision ?? 1,
|
|
30
|
+
trigger: input.trigger ?? { source: "manual", key: input.operation.requestId },
|
|
25
31
|
taskId: input.taskId,
|
|
26
32
|
owner: input.owner,
|
|
27
33
|
projectId: input.projectId,
|
|
@@ -233,50 +239,26 @@ export function validateDurableJob(job) {
|
|
|
233
239
|
throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
|
|
234
240
|
}
|
|
235
241
|
validateOperationFacts(job.operation);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
stepNames.add(name);
|
|
258
|
-
requireText(step.command, `DurableJob step command (${name})`);
|
|
259
|
-
if (step.argv !== undefined) {
|
|
260
|
-
if (!Array.isArray(step.argv) || step.argv.length === 0) {
|
|
261
|
-
throw new Error(`DurableJob step argv must be a non-empty array (${name}).`);
|
|
262
|
-
}
|
|
263
|
-
for (const value of step.argv) {
|
|
264
|
-
requireText(value, `DurableJob step argv (${name})`);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
if (step.cwd !== undefined) {
|
|
268
|
-
requireText(step.cwd, `DurableJob step cwd (${name})`);
|
|
269
|
-
if (!step.cwd.startsWith("/")) {
|
|
270
|
-
throw new Error(`DurableJob step cwd must be absolute (${name}).`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (step.env !== undefined) {
|
|
274
|
-
validateEnvMap(step.env);
|
|
275
|
-
}
|
|
276
|
-
if (step.timeoutMs !== undefined) {
|
|
277
|
-
requirePositiveInteger(step.timeoutMs, `DurableJob step timeout (${name})`);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
242
|
+
requireIdentity(job.definitionId, "Job definition id");
|
|
243
|
+
requirePositiveInteger(job.definitionRevision, "Job definition revision");
|
|
244
|
+
requireText(job.trigger?.key, "Job trigger key");
|
|
245
|
+
if (!["manual", "retry", "schedule", "integration", "migration"].includes(job.trigger.source)) {
|
|
246
|
+
throw new Error("Invalid JobRun trigger source.");
|
|
247
|
+
}
|
|
248
|
+
if ("taskId" in job) {
|
|
249
|
+
validateTaskRecordReference({ taskId: job.taskId, localId: job.id }, "durableJob");
|
|
250
|
+
validateDurableJobOwner(job.owner, job.taskId);
|
|
251
|
+
requireIdentity(job.projectId, "DurableJob project id");
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
if (job.scope !== "global")
|
|
255
|
+
throw new Error("Invalid global JobRun scope.");
|
|
256
|
+
requireIdentity(job.id, "JobRun id");
|
|
257
|
+
requireText(job.action, "JobRun builtin action");
|
|
258
|
+
if (job.projectId !== undefined)
|
|
259
|
+
requireIdentity(job.projectId, "JobRun Project id");
|
|
260
|
+
}
|
|
261
|
+
validateJobExecutionConfiguration(job);
|
|
280
262
|
requireText(job.idempotencyKey, "DurableJob idempotency key");
|
|
281
263
|
if (!/^[a-f0-9]{64}$/u.test(job.idempotencyKey)) {
|
|
282
264
|
throw new Error("DurableJob idempotency key is invalid.");
|
|
@@ -321,7 +303,10 @@ export function validateDurableJob(job) {
|
|
|
321
303
|
normalizeDurableJobResult(job.result);
|
|
322
304
|
}
|
|
323
305
|
if (job.retryOf !== undefined) {
|
|
324
|
-
|
|
306
|
+
if ("taskId" in job)
|
|
307
|
+
validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
|
|
308
|
+
else
|
|
309
|
+
requireIdentity(job.retryOf, "JobRun retryOf");
|
|
325
310
|
}
|
|
326
311
|
if (job.cancelRequestedAt !== undefined) {
|
|
327
312
|
requireTimestamp(job.cancelRequestedAt, "DurableJob cancelRequestedAt");
|
|
@@ -345,14 +330,18 @@ export function validateDurableJob(job) {
|
|
|
345
330
|
}
|
|
346
331
|
export function validDurableJobTransition(before, after) {
|
|
347
332
|
if (before.id !== after.id
|
|
348
|
-
|| before.
|
|
333
|
+
|| before.definitionId !== after.definitionId
|
|
334
|
+
|| before.definitionRevision !== after.definitionRevision
|
|
335
|
+
|| !isDeepStrictEqual(before.trigger, after.trigger)
|
|
336
|
+
|| before.retryOf !== after.retryOf
|
|
337
|
+
|| executionNamespace(before) !== executionNamespace(after)
|
|
349
338
|
|| before.projectId !== after.projectId
|
|
350
339
|
|| before.head !== after.head
|
|
351
340
|
|| before.workspace !== after.workspace
|
|
352
341
|
|| before.idempotencyKey !== after.idempotencyKey
|
|
353
342
|
|| before.artifactsLocator !== after.artifactsLocator
|
|
354
343
|
|| before.createdAt !== after.createdAt
|
|
355
|
-
|| !isDeepStrictEqual(before.owner, after.owner)
|
|
344
|
+
|| !isDeepStrictEqual("owner" in before ? before.owner : [before.action, before.actionInput], "owner" in after ? after.owner : [after.action, after.actionInput])
|
|
356
345
|
|| !isDeepStrictEqual(before.env, after.env)
|
|
357
346
|
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
358
347
|
|| before.operation.requestId !== after.operation.requestId
|
|
@@ -374,7 +363,10 @@ export function validDurableJobTransition(before, after) {
|
|
|
374
363
|
const wakeupFlip = before.wakeupNotified !== true && after.wakeupNotified === true;
|
|
375
364
|
const acknowledgeFlip = before.acknowledgedAt === undefined
|
|
376
365
|
&& after.acknowledgedAt !== undefined;
|
|
377
|
-
|
|
366
|
+
const { wakeupNotified: _beforeWake, acknowledgedAt: _beforeAck, updatedAt: _beforeTime, ...beforeFacts } = before;
|
|
367
|
+
const { wakeupNotified: _afterWake, acknowledgedAt: _afterAck, updatedAt: _afterTime, ...afterFacts } = after;
|
|
368
|
+
return isDeepStrictEqual(beforeFacts, afterFacts)
|
|
369
|
+
&& before.status === after.status
|
|
378
370
|
&& isDeepStrictEqual(before.result, after.result)
|
|
379
371
|
&& isDeepStrictEqual(before.operation, after.operation)
|
|
380
372
|
&& before.terminalAt === after.terminalAt
|
|
@@ -398,7 +390,7 @@ export function validDurableJobTransition(before, after) {
|
|
|
398
390
|
};
|
|
399
391
|
return (allowed[before.status] ?? []).includes(after.status);
|
|
400
392
|
}
|
|
401
|
-
function validateDurableJobOwner(owner, taskId) {
|
|
393
|
+
export function validateDurableJobOwner(owner, taskId) {
|
|
402
394
|
if (owner.kind === "integration-attempt") {
|
|
403
395
|
validateTaskRecordReference({ taskId, localId: owner.integrationAttemptId }, "integrationAttempt");
|
|
404
396
|
}
|
|
@@ -409,6 +401,37 @@ function validateDurableJobOwner(owner, taskId) {
|
|
|
409
401
|
throw new Error(`DurableJob owner kind is invalid: ${String(owner.kind)}.`);
|
|
410
402
|
}
|
|
411
403
|
}
|
|
404
|
+
export function validateJobExecutionConfiguration(job) {
|
|
405
|
+
requireCommit(job.head, "DurableJob head");
|
|
406
|
+
requireText(job.workspace, "DurableJob workspace");
|
|
407
|
+
if (!job.workspace.startsWith("/"))
|
|
408
|
+
throw new Error("DurableJob workspace must be an absolute path.");
|
|
409
|
+
validateEnvMap(job.env);
|
|
410
|
+
if (!Array.isArray(job.steps) || job.steps.length === 0) {
|
|
411
|
+
throw new Error("DurableJob requires at least one step.");
|
|
412
|
+
}
|
|
413
|
+
const names = new Set();
|
|
414
|
+
for (const step of job.steps) {
|
|
415
|
+
const name = requireText(step.name, "DurableJob step name");
|
|
416
|
+
if (names.has(name))
|
|
417
|
+
throw new Error(`DurableJob step names must be unique: ${name}.`);
|
|
418
|
+
names.add(name);
|
|
419
|
+
requireText(step.command, `DurableJob step command (${name})`);
|
|
420
|
+
if (step.argv !== undefined) {
|
|
421
|
+
if (!Array.isArray(step.argv) || step.argv.length === 0)
|
|
422
|
+
throw new Error("Step argv must be nonempty.");
|
|
423
|
+
for (const value of step.argv)
|
|
424
|
+
requireText(value, `DurableJob step argv (${name})`);
|
|
425
|
+
}
|
|
426
|
+
if (step.cwd !== undefined && !requireText(step.cwd, "Step cwd").startsWith("/")) {
|
|
427
|
+
throw new Error("DurableJob step cwd must be absolute.");
|
|
428
|
+
}
|
|
429
|
+
if (step.env !== undefined)
|
|
430
|
+
validateEnvMap(step.env);
|
|
431
|
+
if (step.timeoutMs !== undefined)
|
|
432
|
+
requirePositiveInteger(step.timeoutMs, "Step timeout");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
412
435
|
function validateEnvMap(env) {
|
|
413
436
|
if (typeof env !== "object" || env === null || Array.isArray(env)) {
|
|
414
437
|
throw new Error("DurableJob env must be a map.");
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { requireIdentity, requireKnownFields, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
2
|
+
import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
|
|
3
|
+
export function jobScopeKey(scope) {
|
|
4
|
+
if (scope.kind === "global")
|
|
5
|
+
return "global";
|
|
6
|
+
requireIdentity(scope.taskId, "Job Task id");
|
|
7
|
+
if (!/^task-\d+$/u.test(scope.taskId))
|
|
8
|
+
throw new Error("Invalid Job Task id.");
|
|
9
|
+
return scope.taskId;
|
|
10
|
+
}
|
|
11
|
+
export function validateJobDefinition(job) {
|
|
12
|
+
requireKnownFields(job, ["schemaVersion", "id", "scope", "title", "revision", "configuration", "createdAt", "updatedAt"], "Job");
|
|
13
|
+
if (job.schemaVersion !== 1)
|
|
14
|
+
throw new Error("Job schemaVersion must be 1.");
|
|
15
|
+
requireIdentity(job.id, "Job id");
|
|
16
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(job.id))
|
|
17
|
+
throw new Error("Invalid Job definition id.");
|
|
18
|
+
requireText(job.title, "Job title");
|
|
19
|
+
requirePositiveInteger(job.revision, "Job revision");
|
|
20
|
+
requireTimestamp(job.createdAt, "Job createdAt");
|
|
21
|
+
requireTimestamp(job.updatedAt, "Job updatedAt");
|
|
22
|
+
if (job.scope.kind !== "task" && job.scope.kind !== "global")
|
|
23
|
+
throw new Error("Invalid Job scope.");
|
|
24
|
+
requireKnownFields(job.scope, job.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Job scope");
|
|
25
|
+
jobScopeKey(job.scope);
|
|
26
|
+
const config = job.configuration;
|
|
27
|
+
if (job.scope.kind === "global") {
|
|
28
|
+
if (config.kind !== "builtin")
|
|
29
|
+
throw new Error("Global Jobs require a controlled builtin action.");
|
|
30
|
+
requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
|
|
31
|
+
requireText(config.action, "Builtin action");
|
|
32
|
+
if (config.projectId !== undefined)
|
|
33
|
+
requireIdentity(config.projectId, "Job Project id");
|
|
34
|
+
if (config.input === undefined)
|
|
35
|
+
throw new Error("Builtin Job input is required.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
if (config.kind !== "shell")
|
|
39
|
+
throw new Error("Task Jobs require a managed shell configuration.");
|
|
40
|
+
requireKnownFields(config, ["kind", "owner", "projectId", "head", "workspace", "env", "steps"], "Task Job");
|
|
41
|
+
requireIdentity(config.projectId, "Job Project id");
|
|
42
|
+
validateDurableJobOwner(config.owner, job.scope.taskId);
|
|
43
|
+
validateJobExecutionConfiguration(config);
|
|
44
|
+
}
|
|
45
|
+
return job;
|
|
46
|
+
}
|
|
47
|
+
export function createJobDefinition(input, now) {
|
|
48
|
+
return validateJobDefinition({
|
|
49
|
+
schemaVersion: 1, ...structuredClone(input), revision: 1,
|
|
50
|
+
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function reviseJobDefinition(job, input, now) {
|
|
54
|
+
return validateJobDefinition({
|
|
55
|
+
...job, title: input.title, configuration: structuredClone(input.configuration),
|
|
56
|
+
revision: job.revision + 1, updatedAt: now.toISOString()
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Old execution IDs are preserved one-to-one; command equality never merges history. */
|
|
60
|
+
export function definitionFromTaskRun(run) {
|
|
61
|
+
return createJobDefinition({
|
|
62
|
+
id: run.definitionId, scope: { kind: "task", taskId: run.taskId }, title: run.steps[0].name,
|
|
63
|
+
configuration: {
|
|
64
|
+
kind: "shell", owner: run.owner, projectId: run.projectId, head: run.head,
|
|
65
|
+
workspace: run.workspace, env: run.env, steps: run.steps
|
|
66
|
+
}
|
|
67
|
+
}, new Date(run.createdAt));
|
|
68
|
+
}
|
package/dist/job/jobRunner.js
CHANGED
|
@@ -267,7 +267,8 @@ function readSpec(specPath) {
|
|
|
267
267
|
if (typeof spec.jobId !== "string" || spec.jobId.length === 0) {
|
|
268
268
|
throw new Error("DurableJob spec is missing jobId.");
|
|
269
269
|
}
|
|
270
|
-
if (
|
|
270
|
+
if (spec.scope === "global" ? spec.taskId !== undefined
|
|
271
|
+
: typeof spec.taskId !== "string" || spec.taskId.length === 0) {
|
|
271
272
|
throw new Error("DurableJob spec is missing taskId.");
|
|
272
273
|
}
|
|
273
274
|
if (typeof spec.workspace !== "string" || !spec.workspace.startsWith("/")) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controlled Project domain action invoked by the artifact-only Job runner.
|
|
3
|
+
* Its catalog connection is read-only; only the shared domain service writes Git.
|
|
4
|
+
*/
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SqliteTaskStore } from "../storage/sqliteStore.js";
|
|
8
|
+
import { authorizeGlobalJobStart } from "../controller/jobDefinitionControl.js";
|
|
9
|
+
import { refreshRegisteredProject } from "../repository/projectRefresh.js";
|
|
10
|
+
import { projectRefreshDigest } from "./builtinJob.js";
|
|
11
|
+
import { CliError } from "../errors/cliError.js";
|
|
12
|
+
export async function runProjectRefresh(home, runId) {
|
|
13
|
+
const store = new SqliteTaskStore(home, { readonly: true });
|
|
14
|
+
try {
|
|
15
|
+
const run = store.getGlobalJobRun(runId);
|
|
16
|
+
if (!run || run.action !== "project.refresh" || !["queued", "running"].includes(run.status)
|
|
17
|
+
|| run.operation.effect !== "possible" || run.cancelRequestedAt) {
|
|
18
|
+
throw new Error("No active admitted project.refresh Run.");
|
|
19
|
+
}
|
|
20
|
+
authorizeGlobalJobStart(store, run);
|
|
21
|
+
const result = await refreshRegisteredProject(store, run.projectId, {
|
|
22
|
+
automatic: true,
|
|
23
|
+
expectedDigest: projectRefreshDigest({
|
|
24
|
+
kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
|
|
25
|
+
}),
|
|
26
|
+
beforeRefresh: () => {
|
|
27
|
+
const current = store.getGlobalJobRun(runId);
|
|
28
|
+
if (!current || !["queued", "running"].includes(current.status) || current.cancelRequestedAt) {
|
|
29
|
+
throw new Error("Project refresh Run stopped while waiting for maintenance.");
|
|
30
|
+
}
|
|
31
|
+
authorizeGlobalJobStart(store, current);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
process.stdout.write(JSON.stringify({ action: run.action, projectId: run.projectId,
|
|
35
|
+
trigger: run.trigger, outcome: "succeeded", observedAt: new Date().toISOString(),
|
|
36
|
+
remoteCheck: { status: "verified", commit: result.toCommit }, refresh: result }) + "\n");
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
store.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
43
|
+
runProjectRefresh(process.argv[2], process.argv[3]).catch(error => {
|
|
44
|
+
process.stderr.write(JSON.stringify({ outcome: "failed", reason: error instanceof Error ? error.message : String(error),
|
|
45
|
+
...(error instanceof CliError ? { details: error.details } : {}) }) + "\n");
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CliError, usageError } from "../errors/cliError.js";
|
|
3
|
+
import { assertProjectActive } from "./project.js";
|
|
4
|
+
import { GitWorkspaceRefreshError, NodeGitWorkspace } from "./gitWorkspace.js";
|
|
5
|
+
import { acquireProjectMaintenanceLock } from "./projectMaintenanceLock.js";
|
|
6
|
+
/** Bind maintenance intent to catalog identity, not a caller-supplied path. */
|
|
7
|
+
export function projectRefreshTargetDigest(project) {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify([
|
|
9
|
+
project.id, project.path, project.remoteUrl, project.stableBranch,
|
|
10
|
+
project.developmentBranch, project.ownership, project.status
|
|
11
|
+
])).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
export function requireRefreshProject(store, id, automatic = false) {
|
|
14
|
+
const project = store.listProjects().find(project => project.id === id);
|
|
15
|
+
if (!project)
|
|
16
|
+
throw usageError(`Project not found: ${id}.`);
|
|
17
|
+
assertProjectActive(project, "refresh");
|
|
18
|
+
if (automatic && project.ownership !== "managed") {
|
|
19
|
+
throw usageError(`Automatic Project refresh requires a managed Project: ${id}.`);
|
|
20
|
+
}
|
|
21
|
+
if (project.remoteUrl === undefined)
|
|
22
|
+
throw usageError(`Project refresh requires a remote URL: ${id}.`);
|
|
23
|
+
if (project.stableBranch !== project.developmentBranch) {
|
|
24
|
+
throw usageError(`Project refresh requires matching stable and development branches: ${id}.`);
|
|
25
|
+
}
|
|
26
|
+
return project;
|
|
27
|
+
}
|
|
28
|
+
/** Shared manual/background operation. Waiting never authorizes a changed target. */
|
|
29
|
+
export async function refreshRegisteredProject(store, id, options = {}) {
|
|
30
|
+
const project = requireRefreshProject(store, id, options.automatic);
|
|
31
|
+
const expected = options.expectedDigest ?? projectRefreshTargetDigest(project);
|
|
32
|
+
const check = () => {
|
|
33
|
+
const current = requireRefreshProject(store, id, options.automatic);
|
|
34
|
+
if (projectRefreshTargetDigest(current) !== expected) {
|
|
35
|
+
throw new Error(`Project changed while waiting to refresh: ${id}; read current state before retrying.`);
|
|
36
|
+
}
|
|
37
|
+
return current;
|
|
38
|
+
};
|
|
39
|
+
check();
|
|
40
|
+
const release = await acquireProjectMaintenanceLock(store.rootDirectory(), id, { signal: options.signal });
|
|
41
|
+
try {
|
|
42
|
+
const current = check();
|
|
43
|
+
options.beforeRefresh?.();
|
|
44
|
+
const refreshed = await (options.git ?? new NodeGitWorkspace()).refresh({
|
|
45
|
+
repositoryPath: current.path, remoteUrl: current.remoteUrl, stableRef: current.stableBranch
|
|
46
|
+
});
|
|
47
|
+
return { project: current, ...refreshed };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof GitWorkspaceRefreshError) {
|
|
51
|
+
throw new CliError("RUNTIME_ERROR", error.message, undefined, { projectId: id, refresh: error.result });
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,6 +2,7 @@ import { lstat, mkdir, readdir, readlink, rmdir, symlink, unlink } from "node:fs
|
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { isDeepStrictEqual } from "node:util";
|
|
4
4
|
import { recordArchiveCleanup } from "../task/archiveDiagnostics.js";
|
|
5
|
+
import { activationWakeRefs } from "../context/taskPhase.js";
|
|
5
6
|
import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
6
7
|
import { createTaskEvent } from "../event/taskEvent.js";
|
|
7
8
|
import { retireTaskRoleSessionsForWorkspace, updateRoleAgentSessionStatus } from "../executor/agentExecutor.js";
|
|
@@ -2282,7 +2283,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
2282
2283
|
function recordTaskActivation(store, previous, active, now, actor) {
|
|
2283
2284
|
// Activation is the explicit request to enter delivery, not an ordinary
|
|
2284
2285
|
// Leader-local edit. Preserve that transition for the replacement Session.
|
|
2285
|
-
enqueueWork(store, { kind: "role", taskId: active.id, roleName: LEADER_ROLE }, "execution-started", now, [{ type: "task", id: active.id }]);
|
|
2286
|
+
enqueueWork(store, { kind: "role", taskId: active.id, roleName: LEADER_ROLE }, "execution-started", now, [{ type: "task", id: active.id }, ...activationWakeRefs(store, active)]);
|
|
2286
2287
|
enqueueWork(store, { kind: "task", taskId: active.id }, "task-activated", now, [{ type: "task", id: active.id }]);
|
|
2287
2288
|
store.saveEvent(active.id, createTaskEvent(store.nextEventId(active.id), active.id, "task.activated", { fromStatus: previous.status, status: active.status, by: actor }, now));
|
|
2288
2289
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CronExpressionParser } from "cron-parser";
|
|
2
|
+
import { requireKnownFields, requirePositiveInteger, requireTimestamp, requireText } from "../domain/validation.js";
|
|
3
|
+
import { jobScopeKey } from "../job/jobDefinition.js";
|
|
4
|
+
export function scheduleId(id) {
|
|
5
|
+
if (typeof id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(id)) {
|
|
6
|
+
throw new Error("An explicit safe Schedule/Job id is required.");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function instant(value) {
|
|
10
|
+
requireTimestamp(value, "Schedule instant");
|
|
11
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/u.test(value))
|
|
12
|
+
throw new Error("Schedule instants require an explicit UTC offset.");
|
|
13
|
+
}
|
|
14
|
+
export function validateScheduleRule(rule) {
|
|
15
|
+
if (rule?.kind === "once") {
|
|
16
|
+
requireKnownFields(rule, ["kind", "at"], "Once rule");
|
|
17
|
+
instant(rule.at);
|
|
18
|
+
}
|
|
19
|
+
else if (rule?.kind === "interval") {
|
|
20
|
+
requireKnownFields(rule, ["kind", "anchor", "everyMs"], "Interval rule");
|
|
21
|
+
instant(rule.anchor);
|
|
22
|
+
if (!Number.isSafeInteger(rule.everyMs) || rule.everyMs < 1000)
|
|
23
|
+
throw new Error("Interval everyMs must be an integer of at least 1000.");
|
|
24
|
+
}
|
|
25
|
+
else if (rule?.kind === "cron") {
|
|
26
|
+
requireKnownFields(rule, ["kind", "expression", "timezone"], "Cron rule");
|
|
27
|
+
if (typeof rule.timezone !== "string" || !rule.timezone.trim())
|
|
28
|
+
throw new Error("An explicit IANA timezone is required.");
|
|
29
|
+
new Intl.DateTimeFormat("en", { timeZone: rule.timezone });
|
|
30
|
+
if (typeof rule.expression !== "string" || rule.expression.trim().split(/\s+/u).length !== 5
|
|
31
|
+
|| /\bH\b/iu.test(rule.expression))
|
|
32
|
+
throw new Error("Cron requires five fields and does not support random H.");
|
|
33
|
+
CronExpressionParser.parse(rule.expression, { tz: rule.timezone, currentDate: "2026-01-01T00:00:00Z" });
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
throw new Error("Unknown Schedule rule.");
|
|
37
|
+
return rule;
|
|
38
|
+
}
|
|
39
|
+
/** Strictly after the fence; once returns null after its single occurrence. */
|
|
40
|
+
export function nextScheduleDue(rule, after) {
|
|
41
|
+
validateScheduleRule(rule);
|
|
42
|
+
const at = after.getTime();
|
|
43
|
+
if (rule.kind === "once")
|
|
44
|
+
return Date.parse(rule.at) > at ? new Date(rule.at).toISOString() : null;
|
|
45
|
+
if (rule.kind === "interval") {
|
|
46
|
+
const anchor = Date.parse(rule.anchor);
|
|
47
|
+
return new Date(anchor + Math.max(0, Math.floor((at - anchor) / rule.everyMs) + 1) * rule.everyMs).toISOString();
|
|
48
|
+
}
|
|
49
|
+
return CronExpressionParser.parse(rule.expression, { currentDate: after, tz: rule.timezone }).next().toISOString();
|
|
50
|
+
}
|
|
51
|
+
export function validateSchedule(value) {
|
|
52
|
+
requireKnownFields(value, ["schemaVersion", "id", "scope", "jobId", "rule", "revision", "generation",
|
|
53
|
+
"state", "nextDue", "processedThrough", "authorization", "lastAttempt", "reason", "createdAt", "updatedAt"], "Schedule");
|
|
54
|
+
if (value.schemaVersion !== 1)
|
|
55
|
+
throw new Error("Invalid Schedule schema.");
|
|
56
|
+
scheduleId(value.id);
|
|
57
|
+
scheduleId(value.jobId);
|
|
58
|
+
if (!["task", "global"].includes(value.scope.kind))
|
|
59
|
+
throw new Error("Invalid Schedule scope.");
|
|
60
|
+
requireKnownFields(value.scope, value.scope.kind === "task" ? ["kind", "taskId"] : ["kind"], "Schedule scope");
|
|
61
|
+
jobScopeKey(value.scope);
|
|
62
|
+
validateScheduleRule(value.rule);
|
|
63
|
+
requirePositiveInteger(value.revision, "Schedule revision");
|
|
64
|
+
requirePositiveInteger(value.generation, "Schedule generation");
|
|
65
|
+
if (!["enabled", "disabled", "completed", "needs-attention", "retired"].includes(value.state))
|
|
66
|
+
throw new Error("Invalid Schedule state.");
|
|
67
|
+
if (value.state === "enabled" && value.nextDue === null)
|
|
68
|
+
throw new Error("Enabled Schedule requires nextDue.");
|
|
69
|
+
if (value.state === "completed" && (value.rule.kind !== "once" || value.nextDue !== null))
|
|
70
|
+
throw new Error("Only an admitted once Schedule is completed.");
|
|
71
|
+
if (value.nextDue !== null)
|
|
72
|
+
instant(value.nextDue);
|
|
73
|
+
if (value.processedThrough !== undefined)
|
|
74
|
+
instant(value.processedThrough);
|
|
75
|
+
instant(value.createdAt);
|
|
76
|
+
instant(value.updatedAt);
|
|
77
|
+
requireKnownFields(value.authorization, ["caller", "binding", "jobRevision"], "Schedule authorization");
|
|
78
|
+
requirePositiveInteger(value.authorization.jobRevision, "Schedule Job revision");
|
|
79
|
+
if (typeof value.authorization.binding !== "string" || !/^[a-f0-9]{64}$/u.test(value.authorization.binding))
|
|
80
|
+
throw new Error("Invalid Schedule authority binding.");
|
|
81
|
+
const caller = value.authorization.caller;
|
|
82
|
+
requireKnownFields(caller, ["scope", "taskId", "role", "agentId", "adapterId", "nativeSessionId", "runId"], "Schedule caller");
|
|
83
|
+
if (typeof caller.role !== "string" || typeof caller.nativeSessionId !== "string")
|
|
84
|
+
throw new Error("Schedule authority requires a Role and Session.");
|
|
85
|
+
requireText(caller.role, "Schedule caller role");
|
|
86
|
+
requireText(caller.nativeSessionId, "Schedule caller Session");
|
|
87
|
+
if (caller.scope === "global") {
|
|
88
|
+
if (caller.role !== "operator")
|
|
89
|
+
throw new Error("Global Schedule authority requires Operator.");
|
|
90
|
+
}
|
|
91
|
+
else if (caller.scope !== "task" || value.scope.kind !== "task" || caller.taskId !== value.scope.taskId) {
|
|
92
|
+
throw new Error("Schedule caller must belong to its scope.");
|
|
93
|
+
}
|
|
94
|
+
if (value.reason !== undefined)
|
|
95
|
+
requireText(value.reason, "Schedule reason");
|
|
96
|
+
if (value.lastAttempt !== undefined) {
|
|
97
|
+
requireKnownFields(value.lastAttempt, ["at", "dueAt", "outcome", "runId", "reason"], "Schedule attempt");
|
|
98
|
+
instant(value.lastAttempt.at);
|
|
99
|
+
instant(value.lastAttempt.dueAt);
|
|
100
|
+
if (!["admitted", "overlap", "blocked"].includes(value.lastAttempt.outcome))
|
|
101
|
+
throw new Error("Invalid Schedule attempt.");
|
|
102
|
+
if (value.lastAttempt.runId !== undefined)
|
|
103
|
+
requireText(value.lastAttempt.runId, "Schedule Run id");
|
|
104
|
+
if (value.lastAttempt.reason !== undefined)
|
|
105
|
+
requireText(value.lastAttempt.reason, "Schedule attempt reason");
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
@@ -16,6 +16,8 @@ export function claimOperatorAttention(mailbox, options) {
|
|
|
16
16
|
|| pending.toSequence - pending.fromSequence + 1 !== pending.requestCount
|
|
17
17
|
|| pending.dedupeKeys.some((key, index) => {
|
|
18
18
|
const ref = pending.refs[index];
|
|
19
|
+
if (ref.type === "global-job-run")
|
|
20
|
+
return key !== `operator-global-job-run:${ref.id}`;
|
|
19
21
|
return (ref.type !== "event" && ref.type !== "input")
|
|
20
22
|
|| key !== `operator-${ref.type}:${ref.taskId}:${ref.id}`
|
|
21
23
|
&& key !== `sequence:${pending.fromSequence + index}`;
|
|
@@ -20,6 +20,10 @@ export async function processOperatorInputNotifications(store, selection, now =
|
|
|
20
20
|
return [];
|
|
21
21
|
const processing = claim.processing;
|
|
22
22
|
const attentions = deduplicateAttention(processing.batch.refs.flatMap((ref) => {
|
|
23
|
+
if (ref.type === "global-job-run") {
|
|
24
|
+
const run = store.getGlobalJobRun?.(ref.id);
|
|
25
|
+
return run == null ? [] : [{ kind: "global-job-run", run }];
|
|
26
|
+
}
|
|
23
27
|
if (ref.type === "input") {
|
|
24
28
|
const request = store.getInputRequest(ref.taskId, ref.id);
|
|
25
29
|
return request !== null && request.status === "open"
|
|
@@ -70,6 +74,8 @@ function skipped(attention, reason) {
|
|
|
70
74
|
};
|
|
71
75
|
}
|
|
72
76
|
function attentionIdentity(attention) {
|
|
77
|
+
if (attention.kind === "global-job-run")
|
|
78
|
+
return { sourceKind: "global-job-run", sourceId: attention.run.id };
|
|
73
79
|
return attention.kind === "input"
|
|
74
80
|
? {
|
|
75
81
|
sourceKind: "input",
|
|
@@ -83,6 +89,8 @@ function attentionIdentity(attention) {
|
|
|
83
89
|
};
|
|
84
90
|
}
|
|
85
91
|
function toPresentationItem(attention) {
|
|
92
|
+
if (attention.kind === "global-job-run")
|
|
93
|
+
return attention;
|
|
86
94
|
return attention.kind === "input"
|
|
87
95
|
? { kind: "input-request", request: attention.request }
|
|
88
96
|
: { kind: "task-event", event: attention.event };
|
|
@@ -98,6 +98,14 @@ export function queryContextRecords(db, taskId, query) {
|
|
|
98
98
|
const sequence = `CAST(substr(r.${key}, length(r.${key}) - length(ltrim(r.${key}, 'abcdefghijklmnopqrstuvwxyz-')) + 1) AS INTEGER)`;
|
|
99
99
|
// Task-local numeric IDs are the stable historical order, not lexical 1,10,2.
|
|
100
100
|
if (query.family === "task-event") {
|
|
101
|
+
if (query.eventRequestId !== undefined) {
|
|
102
|
+
where.push("json_extract(r.payload, '$.payload.requestId') = @requestId");
|
|
103
|
+
params.requestId = query.eventRequestId;
|
|
104
|
+
}
|
|
105
|
+
if (query.eventMessageId !== undefined) {
|
|
106
|
+
where.push("json_extract(r.payload, '$.payload.messageId') = @messageId");
|
|
107
|
+
params.messageId = query.eventMessageId;
|
|
108
|
+
}
|
|
101
109
|
if (query.eventTypes !== undefined) {
|
|
102
110
|
where.push("r.type IN (SELECT value FROM json_each(@types))");
|
|
103
111
|
params.types = JSON.stringify(query.eventTypes);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Additive DDL for storage 1.9; the published baseline is unchanged. */
|
|
2
|
+
export const JOB_SCHEMA_SQL = `
|
|
3
|
+
CREATE TABLE job_definitions (
|
|
4
|
+
scope_key TEXT NOT NULL,
|
|
5
|
+
definition_id TEXT NOT NULL,
|
|
6
|
+
payload TEXT NOT NULL,
|
|
7
|
+
PRIMARY KEY (scope_key, definition_id)
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE global_job_runs (
|
|
10
|
+
run_id TEXT PRIMARY KEY,
|
|
11
|
+
payload TEXT NOT NULL,
|
|
12
|
+
status TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
CREATE INDEX idx_global_job_runs_status ON global_job_runs(status);
|
|
15
|
+
`;
|
|
@@ -22,7 +22,7 @@ export function maintenanceCallerEnvironment(home, env, operatorOnly = true) {
|
|
|
22
22
|
if (storageMinorUpgradePlan(schema.currentVersion) === null) {
|
|
23
23
|
throw new ManagedRuntimeDriftError("Maintenance cannot authenticate an unsupported Home storage version.");
|
|
24
24
|
}
|
|
25
|
-
validateSqliteSchemaObjects(db);
|
|
25
|
+
validateSqliteSchemaObjects(db, schema.currentVersion);
|
|
26
26
|
const read = (table, where, params) => {
|
|
27
27
|
const row = db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).get(...params);
|
|
28
28
|
if (!row)
|