@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.
Files changed (85) hide show
  1. package/dist/cli/commandCatalog.js +35 -12
  2. package/dist/cli/interactionPolicy.js +2 -2
  3. package/dist/commands/durableJobCommands.js +50 -0
  4. package/dist/commands/projectCommands.js +4 -33
  5. package/dist/commands/scheduleCommands.js +45 -0
  6. package/dist/commands/taskActivationCommands.js +19 -5
  7. package/dist/commands/taskCommands.js +2 -1
  8. package/dist/commands/taskFactCommands.js +24 -12
  9. package/dist/commands/taskPublicationCommands.js +6 -3
  10. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  11. package/dist/context/runContextPack.js +24 -4
  12. package/dist/context/taskContext.js +52 -15
  13. package/dist/context/taskPhase.js +99 -0
  14. package/dist/controlPlaneCli.js +6 -0
  15. package/dist/controller/controller.js +35 -0
  16. package/dist/controller/fileSchedulerStoreAdapter.js +16 -1
  17. package/dist/controller/jobControl.js +42 -12
  18. package/dist/controller/jobDefinitionControl.js +304 -0
  19. package/dist/controller/jobSupervisor.js +47 -32
  20. package/dist/controller/runtime.js +11 -2
  21. package/dist/controller/scheduleControl.js +176 -0
  22. package/dist/coordination/workMailbox.js +3 -3
  23. package/dist/external/githubPublicationVerifier.js +14 -5
  24. package/dist/external/gitlabPublicationVerifier.js +2 -1
  25. package/dist/interaction/notificationPresentation.js +3 -3
  26. package/dist/interaction/operatorPresentation.js +5 -0
  27. package/dist/job/builtinJob.js +58 -0
  28. package/dist/job/durableJob.js +72 -49
  29. package/dist/job/jobDefinition.js +68 -0
  30. package/dist/job/jobRunner.js +2 -1
  31. package/dist/job/projectRefreshRunner.js +48 -0
  32. package/dist/repository/projectRefresh.js +58 -0
  33. package/dist/repository/taskWorkspacePreparer.js +2 -1
  34. package/dist/schedule/schedule.js +108 -0
  35. package/dist/scheduler/operatorEvent.js +2 -0
  36. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  37. package/dist/storage/contextRecords.js +8 -0
  38. package/dist/storage/jobSchema.js +15 -0
  39. package/dist/storage/maintenanceCaller.js +1 -1
  40. package/dist/storage/recordValidation.js +5 -0
  41. package/dist/storage/scheduleSchema.js +12 -0
  42. package/dist/storage/sqliteSchema.js +74 -24
  43. package/dist/storage/sqliteStore.js +110 -9
  44. package/dist/storage/storageVersions.js +1 -1
  45. package/dist/storage/storeRpc.js +5 -0
  46. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  47. package/dist/task/publicationToolObservation.js +128 -0
  48. package/dist/task/taskActivationService.js +35 -0
  49. package/dist/web/assets/client/app/settings.js +7 -1
  50. package/dist/web/assets/client/i18n/en.js +61 -0
  51. package/dist/web/assets/client/i18n/zh-CN.js +61 -0
  52. package/dist/web/assets/client/index.js +2 -0
  53. package/dist/web/assets/client/views/jobs.js +222 -0
  54. package/dist/web/assets/client/views/task/page.js +11 -1
  55. package/dist/web/assets/shared/manageNav.js +1 -0
  56. package/dist/web/assets/shared/taskTabs.js +2 -2
  57. package/dist/web/assets/shell/settingsContent.js +3 -0
  58. package/dist/web/assets/styles/index.js +2 -0
  59. package/dist/web/assets/styles/views/jobs.js +19 -0
  60. package/dist/web/webJobs.js +52 -0
  61. package/dist/web/webServer.js +31 -0
  62. package/docs/cli-information-contract.md +43 -2
  63. package/docs/cli-information-contract.zh-CN.md +32 -1
  64. package/docs/job-runs.md +224 -0
  65. package/docs/release-workflow.md +43 -0
  66. package/docs/release-workflow.zh-CN.md +33 -0
  67. package/docs/schedules.md +132 -0
  68. package/docs/storage-baseline.md +18 -1
  69. package/docs/storage-baseline.zh-CN.md +15 -1
  70. package/docs/task-delivery.md +68 -2
  71. package/docs/task-delivery.zh-CN.md +33 -1
  72. package/docs/testing/verification-levels.md +6 -2
  73. package/docs/testing/verification-levels.zh-CN.md +6 -3
  74. package/package.json +4 -3
  75. package/skills/yui-leader/SKILL.md +18 -1
  76. package/skills/yui-leader/VERSION +1 -1
  77. package/skills/yui-leader/references/planning.md +9 -1
  78. package/skills/yui-reviewer/SKILL.md +4 -0
  79. package/skills/yui-reviewer/VERSION +1 -1
  80. package/skills/yui-runtime/SKILL.md +9 -3
  81. package/skills/yui-runtime/VERSION +1 -1
  82. package/skills/yui-runtime/references/context-ordering.md +103 -0
  83. package/skills/yui-runtime/references/publication.md +44 -4
  84. package/skills/yui-worker/SKILL.md +5 -0
  85. 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
+ }
@@ -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
- validateTaskRecordReference({
237
- taskId: job.taskId,
238
- localId: job.id
239
- }, "durableJob");
240
- validateDurableJobOwner(job.owner, job.taskId);
241
- requireIdentity(job.projectId, "DurableJob project id");
242
- requireCommit(job.head, "DurableJob head");
243
- requireText(job.workspace, "DurableJob workspace");
244
- if (!job.workspace.startsWith("/")) {
245
- throw new Error("DurableJob workspace must be an absolute path.");
246
- }
247
- validateEnvMap(job.env);
248
- if (!Array.isArray(job.steps) || job.steps.length === 0) {
249
- throw new Error("DurableJob requires at least one step.");
250
- }
251
- const stepNames = new Set();
252
- for (const step of job.steps) {
253
- const name = requireText(step.name, "DurableJob step name");
254
- if (stepNames.has(name)) {
255
- throw new Error(`DurableJob step names must be unique: ${name}.`);
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
- validateTaskRecordReference({ taskId: job.taskId, localId: job.retryOf }, "durableJob");
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.taskId !== after.taskId
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
- return before.status === after.status
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
+ }
@@ -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 (typeof spec.taskId !== "string" || spec.taskId.length === 0) {
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)