@zq-silk/yui 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/cli/commandCatalog.js +35 -12
  2. package/dist/cli/interactionPolicy.js +2 -2
  3. package/dist/cli/updatePorts.js +20 -0
  4. package/dist/commands/durableJobCommands.js +50 -0
  5. package/dist/commands/projectCommands.js +4 -33
  6. package/dist/commands/scheduleCommands.js +45 -0
  7. package/dist/commands/taskActivationCommands.js +19 -5
  8. package/dist/commands/taskCommands.js +2 -1
  9. package/dist/commands/taskFactCommands.js +24 -12
  10. package/dist/commands/taskPublicationCommands.js +6 -3
  11. package/dist/commands/taskPublicationVerifyCommand.js +74 -28
  12. package/dist/context/runContextPack.js +24 -4
  13. package/dist/context/taskContext.js +52 -15
  14. package/dist/context/taskPhase.js +99 -0
  15. package/dist/controlPlaneCli.js +24 -5
  16. package/dist/controller/controller.js +35 -0
  17. package/dist/controller/fileSchedulerStoreAdapter.js +16 -1
  18. package/dist/controller/jobControl.js +42 -12
  19. package/dist/controller/jobDefinitionControl.js +304 -0
  20. package/dist/controller/jobSupervisor.js +47 -32
  21. package/dist/controller/runtime.js +11 -2
  22. package/dist/controller/scheduleControl.js +176 -0
  23. package/dist/coordination/workMailbox.js +3 -3
  24. package/dist/external/githubPublicationVerifier.js +14 -5
  25. package/dist/external/gitlabPublicationVerifier.js +2 -1
  26. package/dist/interaction/notificationPresentation.js +3 -3
  27. package/dist/interaction/operatorPresentation.js +5 -0
  28. package/dist/job/builtinJob.js +58 -0
  29. package/dist/job/durableJob.js +72 -49
  30. package/dist/job/jobDefinition.js +68 -0
  31. package/dist/job/jobRunner.js +2 -1
  32. package/dist/job/projectRefreshRunner.js +48 -0
  33. package/dist/repository/projectRefresh.js +58 -0
  34. package/dist/repository/taskWorkspacePreparer.js +2 -1
  35. package/dist/schedule/schedule.js +108 -0
  36. package/dist/scheduler/operatorEvent.js +2 -0
  37. package/dist/scheduler/operatorInputNotificationProcessor.js +8 -0
  38. package/dist/storage/contextRecords.js +8 -0
  39. package/dist/storage/jobSchema.js +15 -0
  40. package/dist/storage/maintenanceCaller.js +69 -0
  41. package/dist/storage/recordValidation.js +5 -0
  42. package/dist/storage/scheduleSchema.js +12 -0
  43. package/dist/storage/sqliteSchema.js +75 -25
  44. package/dist/storage/sqliteStore.js +110 -9
  45. package/dist/storage/storageVersions.js +1 -1
  46. package/dist/storage/storeRpc.js +5 -0
  47. package/dist/storage/upgrade/jobDefinitionMigration.js +22 -0
  48. package/dist/task/publicationToolObservation.js +128 -0
  49. package/dist/task/taskActivationService.js +35 -0
  50. package/dist/web/assets/client/app/settings.js +7 -1
  51. package/dist/web/assets/client/i18n/en.js +61 -0
  52. package/dist/web/assets/client/i18n/zh-CN.js +61 -0
  53. package/dist/web/assets/client/index.js +2 -0
  54. package/dist/web/assets/client/views/jobs.js +222 -0
  55. package/dist/web/assets/client/views/task/page.js +11 -1
  56. package/dist/web/assets/shared/manageNav.js +1 -0
  57. package/dist/web/assets/shared/taskTabs.js +2 -2
  58. package/dist/web/assets/shell/settingsContent.js +3 -0
  59. package/dist/web/assets/styles/index.js +2 -0
  60. package/dist/web/assets/styles/views/jobs.js +19 -0
  61. package/dist/web/webJobs.js +52 -0
  62. package/dist/web/webServer.js +31 -0
  63. package/docs/cli-information-contract.md +43 -2
  64. package/docs/cli-information-contract.zh-CN.md +32 -1
  65. package/docs/job-runs.md +224 -0
  66. package/docs/release-workflow.md +65 -0
  67. package/docs/release-workflow.zh-CN.md +49 -0
  68. package/docs/schedules.md +132 -0
  69. package/docs/storage-baseline.md +18 -1
  70. package/docs/storage-baseline.zh-CN.md +15 -1
  71. package/docs/task-delivery.md +68 -2
  72. package/docs/task-delivery.zh-CN.md +33 -1
  73. package/docs/testing/verification-levels.md +6 -2
  74. package/docs/testing/verification-levels.zh-CN.md +6 -3
  75. package/package.json +4 -3
  76. package/skills/yui-leader/SKILL.md +18 -1
  77. package/skills/yui-leader/VERSION +1 -1
  78. package/skills/yui-leader/references/planning.md +9 -1
  79. package/skills/yui-reviewer/SKILL.md +4 -0
  80. package/skills/yui-reviewer/VERSION +1 -1
  81. package/skills/yui-runtime/SKILL.md +9 -3
  82. package/skills/yui-runtime/VERSION +1 -1
  83. package/skills/yui-runtime/references/context-ordering.md +103 -0
  84. package/skills/yui-runtime/references/publication.md +44 -4
  85. package/skills/yui-worker/SKILL.md +5 -0
  86. package/skills/yui-worker/VERSION +1 -1
@@ -0,0 +1,69 @@
1
+ import { join } from "node:path";
2
+ import Database from "../sqlite/driver.js";
3
+ import { hasManagedIdentity } from "../runtime/managedIdentity.js";
4
+ import { ManagedRuntimeDriftError, requireManagedGlobalCaller } from "../runtime/managedCaller.js";
5
+ import { sessionCallerEnvironment } from "../runtime/sessionCaller.js";
6
+ import { validateStoredRecord } from "./recordValidation.js";
7
+ import { inspectSqliteSchema, storageMinorUpgradePlan, validateSqliteSchemaObjects } from "./sqliteSchema.js";
8
+ import { CURRENT_DATABASE_FILENAME } from "./storageSchema.js";
9
+ /**
10
+ * Maintenance must authenticate before migration, without opening a current-only
11
+ * business Store. The declared 1.x migrations preserve these Role/Session
12
+ * envelopes and physical tables. Read only those records in one SQLite snapshot;
13
+ * this grants no old-format business reads, writes or implicit migration.
14
+ */
15
+ export function maintenanceCallerEnvironment(home, env, operatorOnly = true) {
16
+ if (!hasManagedIdentity(env))
17
+ return env;
18
+ const db = new Database(join(home, CURRENT_DATABASE_FILENAME), { readonly: true, fileMustExist: true });
19
+ try {
20
+ return db.transaction(() => {
21
+ const schema = inspectSqliteSchema(db);
22
+ if (storageMinorUpgradePlan(schema.currentVersion) === null) {
23
+ throw new ManagedRuntimeDriftError("Maintenance cannot authenticate an unsupported Home storage version.");
24
+ }
25
+ validateSqliteSchemaObjects(db, schema.currentVersion);
26
+ const read = (table, where, params) => {
27
+ const row = db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).get(...params);
28
+ if (!row)
29
+ return null;
30
+ const value = JSON.parse(row.payload);
31
+ validateStoredRecord(table, value);
32
+ return value;
33
+ };
34
+ const registry = {
35
+ getGlobalRole: (name) => read("global_roles", "name=?", [name]),
36
+ getGlobalRoleSessionSet: (name) => read("global_role_session_sets", "name=?", [name]),
37
+ getTaskRoleSessionSet: (task, role) => read("role_session_sets", "task_id=? AND role_name=?", [task, role]),
38
+ listRuntimeSessionCandidates: () => {
39
+ // Read canonical sets, not a derived index that could be stale. Only
40
+ // active bindings participate, exactly as in normal caller discovery.
41
+ const rows = db.prepare(`
42
+ SELECT 'global_role_session_sets' AS source, payload FROM global_role_session_sets
43
+ UNION ALL SELECT 'role_session_sets' AS source, payload FROM role_session_sets
44
+ `).all();
45
+ return rows.flatMap(row => {
46
+ const set = JSON.parse(row.payload);
47
+ validateStoredRecord(row.source, set);
48
+ const session = set.sessions[set.activeAgentId];
49
+ if (!session || session.status !== "active")
50
+ return [];
51
+ return [{ owner: set.owner, agentId: session.agentId, adapterId: session.adapterId,
52
+ nativeSessionId: session.nativeSessionId, sessionUpdatedAt: set.updatedAt }];
53
+ });
54
+ }
55
+ };
56
+ const current = sessionCallerEnvironment(registry, env);
57
+ if (current.YUI_SESSION_SCOPE !== "global" || current.YUI_ROLE !== "operator") {
58
+ if (!operatorOnly)
59
+ return current; // Doctor keeps normal scoped authorization below.
60
+ throw new ManagedRuntimeDriftError("Home maintenance requires the user or current Operator, not a Task or custom Role.");
61
+ }
62
+ requireManagedGlobalCaller(registry, current);
63
+ return current;
64
+ })();
65
+ }
66
+ finally {
67
+ db.close();
68
+ }
69
+ }
@@ -8,6 +8,8 @@ import { validateInputRequest } from "../input/inputRequest.js";
8
8
  import { validateChangeSet } from "../integration/changeSet.js";
9
9
  import { validateIntegrationAttempt } from "../integration/integrationAttempt.js";
10
10
  import { validateDurableJob } from "../job/durableJob.js";
11
+ import { validateJobDefinition } from "../job/jobDefinition.js";
12
+ import { validateSchedule } from "../schedule/schedule.js";
11
13
  import { validateGlobalRoleMessage, validateTaskMessage } from "../message/message.js";
12
14
  import { validateMilestone } from "../milestone/milestone.js";
13
15
  import { validatePluginIntent } from "../plugins/pluginIntent.js";
@@ -54,6 +56,9 @@ const validators = {
54
56
  change_sets: check(validateChangeSet),
55
57
  integration_attempts: check(validateIntegrationAttempt),
56
58
  durable_jobs: check(validateDurableJob),
59
+ job_definitions: check(validateJobDefinition),
60
+ schedules: check(validateSchedule),
61
+ global_job_runs: check(validateDurableJob),
57
62
  context_snapshots: check(validateContextSnapshot),
58
63
  managed_workspaces: check(validateManagedWorkspace),
59
64
  input_requests: check(validateInputRequest),
@@ -0,0 +1,12 @@
1
+ /** Additive storage 1.9 -> 1.10. No old records are rewritten. */
2
+ export const SCHEDULE_SCHEMA_SQL = `
3
+ CREATE TABLE schedules (
4
+ scope_key TEXT NOT NULL,
5
+ schedule_id TEXT NOT NULL,
6
+ payload TEXT NOT NULL,
7
+ state TEXT NOT NULL,
8
+ next_due TEXT,
9
+ PRIMARY KEY(scope_key, schedule_id)
10
+ );
11
+ CREATE INDEX idx_schedules_due ON schedules(state, next_due);
12
+ `;
@@ -1,13 +1,20 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import Database from "../sqlite/driver.js";
3
- import { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES } from "./baselineSchema.js";
3
+ import { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES as BASELINE_SCHEMA_TABLES } from "./baselineSchema.js";
4
4
  import { migrateTaskMainWorkspaces } from "./upgrade/taskMainWorkspaceMigration.js";
5
5
  import { migrateProjectKnowledge } from "./upgrade/projectKnowledgeMigration.js";
6
+ import { JOB_SCHEMA_SQL } from "./jobSchema.js";
7
+ import { SCHEDULE_SCHEMA_SQL } from "./scheduleSchema.js";
8
+ import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
6
9
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
7
- export { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES };
10
+ export { BASELINE_SCHEMA_SQL };
11
+ export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules"];
8
12
  export const TELEMETRY_KEEP_PER_RUN = 200;
9
13
  export const TELEMETRY_RUN_CAP = 50_000;
10
- export const CURRENT_SCHEMA_CHECKSUM = createHash("sha256").update(BASELINE_SCHEMA_SQL).digest("hex");
14
+ export const BASELINE_SCHEMA_CHECKSUM = createHash("sha256").update(BASELINE_SCHEMA_SQL).digest("hex");
15
+ export const JOB_SCHEMA_CHECKSUM = createHash("sha256").update(BASELINE_SCHEMA_SQL + JOB_SCHEMA_SQL).digest("hex");
16
+ export const CURRENT_SCHEMA_SQL = BASELINE_SCHEMA_SQL + JOB_SCHEMA_SQL + SCHEDULE_SCHEMA_SQL;
17
+ export const CURRENT_SCHEMA_CHECKSUM = createHash("sha256").update(CURRENT_SCHEMA_SQL).digest("hex");
11
18
  export class SqliteSchemaError extends Error {
12
19
  code;
13
20
  constructor(detail, code = "STORAGE_SCHEMA_INVALID") {
@@ -21,8 +28,8 @@ const MINOR_UPGRADES = Object.freeze([{
21
28
  toVersion: "1.1",
22
29
  name: "task-main-workspace",
23
30
  introducedIn: "1.1.0",
24
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
25
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
31
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
32
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
26
33
  sql: "",
27
34
  dataMigration: "task-main-workspace"
28
35
  }, {
@@ -30,8 +37,8 @@ const MINOR_UPGRADES = Object.freeze([{
30
37
  toVersion: "1.2",
31
38
  name: "task-authorization-source",
32
39
  introducedIn: "next",
33
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
34
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
40
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
41
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
35
42
  // New grants can carry source evidence. Valid historical Operator grants
36
43
  // remain unchanged; migration must not invent authorization provenance.
37
44
  sql: ""
@@ -40,8 +47,8 @@ const MINOR_UPGRADES = Object.freeze([{
40
47
  toVersion: "1.3",
41
48
  name: "selected-session-input",
42
49
  introducedIn: "next",
43
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
44
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
50
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
51
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
45
52
  // Optional explicit Session fences apply only to new inputs. Preserve old
46
53
  // Role-addressed messages; never infer a historical user's Session choice.
47
54
  sql: ""
@@ -50,8 +57,8 @@ const MINOR_UPGRADES = Object.freeze([{
50
57
  toVersion: "1.4",
51
58
  name: "project-knowledge-applicability",
52
59
  introducedIn: "next",
53
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
54
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
60
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
61
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
55
62
  sql: "",
56
63
  dataMigration: "project-knowledge"
57
64
  }, {
@@ -59,8 +66,8 @@ const MINOR_UPGRADES = Object.freeze([{
59
66
  toVersion: "1.5",
60
67
  name: "frozen-skill-packages",
61
68
  introducedIn: "next",
62
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
63
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
69
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
70
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
64
71
  // Additive effective-launch/manifest package evidence. Historical envelopes
65
72
  // and their digests remain unchanged: absence explicitly means entrypoint-only
66
73
  // provenance, not an invitation to invent packages from today's source files.
@@ -72,8 +79,8 @@ const MINOR_UPGRADES = Object.freeze([{
72
79
  toVersion: "1.6",
73
80
  name: "runtime-cost-evidence",
74
81
  introducedIn: "next",
75
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
76
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
82
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
83
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
77
84
  // Optional sourced monetary observations; historical missing costs stay unknown.
78
85
  sql: ""
79
86
  }, {
@@ -81,8 +88,8 @@ const MINOR_UPGRADES = Object.freeze([{
81
88
  toVersion: "1.7",
82
89
  name: "agent-inbox-consumption",
83
90
  introducedIn: "next",
84
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
85
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
91
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
92
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
86
93
  // Additive handling receipts on canonical Messages and wakes. Never invent
87
94
  // historical processing or Provider acceptance; existing records stay intact.
88
95
  sql: ""
@@ -91,11 +98,41 @@ const MINOR_UPGRADES = Object.freeze([{
91
98
  toVersion: "1.8",
92
99
  name: "model-management-connection",
93
100
  introducedIn: "next",
94
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
95
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
101
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
102
+ targetChecksum: BASELINE_SCHEMA_CHECKSUM,
96
103
  // Optional non-secret connection references. Existing Homes remain unconfigured;
97
104
  // never infer local credentials or adopt an existing external service.
98
105
  sql: ""
106
+ }, {
107
+ fromVersion: "1.8",
108
+ toVersion: "1.9",
109
+ name: "job-definitions-and-runs",
110
+ introducedIn: "next",
111
+ sourceChecksum: BASELINE_SCHEMA_CHECKSUM,
112
+ targetChecksum: JOB_SCHEMA_CHECKSUM,
113
+ sql: JOB_SCHEMA_SQL,
114
+ dataMigration: "job-definitions"
115
+ }, {
116
+ fromVersion: "1.9",
117
+ toVersion: "1.10",
118
+ name: "persistent-schedules",
119
+ introducedIn: "next",
120
+ sourceChecksum: JOB_SCHEMA_CHECKSUM,
121
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
122
+ sql: SCHEDULE_SCHEMA_SQL
123
+ }, {
124
+ fromVersion: "1.10",
125
+ toVersion: "1.11",
126
+ name: "activation-input-provenance",
127
+ introducedIn: "next",
128
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
129
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
130
+ // Activation event payloads may now name an exact input or contain attributed
131
+ // native input text. Existing events remain immutable; missing old provenance
132
+ // stays explicitly missing. No historical source is guessed from timestamps.
133
+ // New Task Job semantic writes also append job.state-changed events; no old
134
+ // Job history is backfilled and the SQL layout remains unchanged.
135
+ sql: ""
99
136
  }]);
100
137
  export function storageMinorUpgradePlan(from) {
101
138
  if (from === CURRENT_STORAGE_VERSION)
@@ -118,13 +155,24 @@ function objects(db) {
118
155
  return db.prepare(`SELECT type,name,sql FROM sqlite_master
119
156
  WHERE name NOT GLOB 'sqlite_*' ORDER BY type,name`).all();
120
157
  }
121
- let expectedObjects;
122
- function validateSchemaObjects(db) {
158
+ const schemaObjectsByChecksum = new Map();
159
+ export function validateSqliteSchemaObjects(db, version = CURRENT_STORAGE_VERSION) {
160
+ // Maintenance authenticates before migration. Validate the declared source
161
+ // layout, not current-only tables that the migration has yet to create.
162
+ const checksum = version === CURRENT_STORAGE_VERSION
163
+ ? CURRENT_SCHEMA_CHECKSUM : storageMinorUpgradePlan(version)?.[0]?.sourceChecksum;
164
+ const sql = checksum === CURRENT_SCHEMA_CHECKSUM ? CURRENT_SCHEMA_SQL
165
+ : checksum === JOB_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL + JOB_SCHEMA_SQL
166
+ : checksum === BASELINE_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL : undefined;
167
+ if (sql === undefined || checksum === undefined)
168
+ throw new SqliteSchemaError(`unsupported schema definition for storage ${version}.`);
169
+ let expectedObjects = schemaObjectsByChecksum.get(checksum);
123
170
  if (expectedObjects === undefined) {
124
171
  const reference = new Database(":memory:");
125
172
  try {
126
- reference.exec(BASELINE_SCHEMA_SQL);
173
+ reference.exec(sql);
127
174
  expectedObjects = objects(reference);
175
+ schemaObjectsByChecksum.set(checksum, expectedObjects);
128
176
  }
129
177
  finally {
130
178
  reference.close();
@@ -134,7 +182,7 @@ function validateSchemaObjects(db) {
134
182
  for (const expected of expectedObjects) {
135
183
  const found = actual.find(object => object.type === expected.type && object.name === expected.name);
136
184
  if (found?.sql !== expected.sql)
137
- throw new SqliteSchemaError(`required ${expected.type} '${expected.name}' differs from the current definition.`);
185
+ throw new SqliteSchemaError(`required ${expected.type} '${expected.name}' differs from the storage ${version} definition.`);
138
186
  }
139
187
  if (actual.length !== expectedObjects.length)
140
188
  throw new SqliteSchemaError("unexpected schema objects; preserve the database for diagnosis.");
@@ -161,7 +209,7 @@ export function inspectSqliteSchema(db) {
161
209
  throw new SqliteSchemaError("baseline checksum does not match its version.");
162
210
  }
163
211
  if (version === CURRENT_STORAGE_VERSION)
164
- validateSchemaObjects(db);
212
+ validateSqliteSchemaObjects(db);
165
213
  return {
166
214
  currentVersion: version, currentChecksum: row.checksum,
167
215
  targetVersion: CURRENT_STORAGE_VERSION, targetChecksum: CURRENT_SCHEMA_CHECKSUM,
@@ -183,7 +231,7 @@ export function initializeSqliteSchema(db) {
183
231
  }
184
232
  const { major, minor } = storageVersionParts(CURRENT_STORAGE_VERSION);
185
233
  return db.transaction(() => {
186
- db.exec(BASELINE_SCHEMA_SQL);
234
+ db.exec(CURRENT_SCHEMA_SQL);
187
235
  db.prepare("INSERT INTO storage_schema VALUES (1,?,?,?,?,?)")
188
236
  .run(STORAGE_FORMAT, major, minor, CURRENT_SCHEMA_CHECKSUM, new Date().toISOString());
189
237
  return validateSqliteSchema(db);
@@ -207,6 +255,8 @@ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
207
255
  }
208
256
  if (step.dataMigration === "project-knowledge")
209
257
  migrateProjectKnowledge(db);
258
+ if (step.dataMigration === "job-definitions")
259
+ migrateJobDefinitions(db);
210
260
  const { major, minor } = storageVersionParts(step.toVersion);
211
261
  db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
212
262
  .run(major, minor, step.targetChecksum);
@@ -40,6 +40,7 @@ import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
40
40
  import { validateTaskBrief } from "../brief/taskBrief.js";
41
41
  import { validateContextSnapshot } from "../context/contextSnapshot.js";
42
42
  import { consumePendingBatch, mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
43
+ import { createTaskEvent } from "../event/taskEvent.js";
43
44
  import { enqueueDurableOperatorFact } from "../scheduler/operatorEvent.js";
44
45
  import { validateDurableJob, validDurableJobTransition } from "../job/durableJob.js";
45
46
  import { validateGlobalRoleMessage } from "../message/message.js";
@@ -60,6 +61,8 @@ import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
60
61
  import { operationalTaskRecords, TASK_RECORD_RETIRED_EVENT } from "../task/taskRecordRetirement.js";
61
62
  import { writeTelemetryBatch } from "../telemetry/sqliteTelemetryBatch.js";
62
63
  import { STORED_RECORD_TABLES, validateStoredRecord } from "./recordValidation.js";
64
+ import { jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
65
+ import { validateSchedule } from "../schedule/schedule.js";
63
66
  import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
64
67
  import { validateWorkItem } from "../workItem/workItem.js";
65
68
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
@@ -1056,6 +1059,25 @@ export class SqliteTaskStore {
1056
1059
  throw new StorageRecordError("Task id is required.");
1057
1060
  }
1058
1061
  this.#mutate(() => {
1062
+ const schedules = this.listSchedules({ kind: "task", taskId: task.id });
1063
+ if (task.status === "completed" && schedules.some(s => s.rule.kind === "once"
1064
+ && (s.state === "enabled" || s.state === "needs-attention"))) {
1065
+ throw new StorageRecordError("Task has unfulfilled once Schedules; inspect and explicitly disable them before completion.");
1066
+ }
1067
+ if (["completed", "cancelled", "archived"].includes(task.status)) {
1068
+ for (const schedule of schedules.filter(s => s.state === "enabled" || s.state === "needs-attention")) {
1069
+ this.saveSchedule({ ...schedule, state: "retired", revision: schedule.revision + 1,
1070
+ reason: `Task ${task.status}; no future admission. Pending intent and Run history retained.`,
1071
+ updatedAt: task.updatedAt });
1072
+ }
1073
+ }
1074
+ else if (task.executionGate.state === "stopped") {
1075
+ for (const schedule of schedules.filter(s => s.state === "enabled")) {
1076
+ this.saveSchedule({ ...schedule, state: "needs-attention", revision: schedule.revision + 1,
1077
+ reason: "Task execution stopped; inspect and explicitly enable after execution resumes.",
1078
+ updatedAt: task.updatedAt });
1079
+ }
1080
+ }
1059
1081
  for (const binding of task.projectBindings) {
1060
1082
  const found = this.#db.prepare("SELECT 1 FROM projects WHERE id = ?").get(binding.projectId);
1061
1083
  if (found === undefined)
@@ -1234,6 +1256,71 @@ export class SqliteTaskStore {
1234
1256
  return this.#getPayload("integration_attempts", "task_id = ? AND integration_id = ?", [taskId, integrationId]);
1235
1257
  }
1236
1258
  // -- durable jobs -----------------------------------------------------------
1259
+ saveSchedule(schedule) {
1260
+ validateSchedule(schedule);
1261
+ this.#mutate(() => {
1262
+ this.#db.prepare(`INSERT INTO schedules VALUES(?,?,?,?,?)
1263
+ ON CONFLICT(scope_key,schedule_id) DO UPDATE SET payload=excluded.payload,state=excluded.state,next_due=excluded.next_due`)
1264
+ .run(jobScopeKey(schedule.scope), schedule.id, this.#recordJson("schedules", schedule), schedule.state, schedule.nextDue);
1265
+ });
1266
+ }
1267
+ getSchedule(scope, id) {
1268
+ return this.#getPayload("schedules", "scope_key=? AND schedule_id=?", [jobScopeKey(scope), id]);
1269
+ }
1270
+ listSchedules(scope) {
1271
+ return this.#listPayload("schedules", "scope_key=?", [jobScopeKey(scope)]);
1272
+ }
1273
+ listDueSchedules(now, limit) {
1274
+ const rows = this.#db.prepare(`SELECT payload FROM schedules
1275
+ WHERE state='enabled' AND next_due<=?
1276
+ ORDER BY COALESCE(json_extract(payload,'$.lastAttempt.at'),''),next_due,scope_key,schedule_id LIMIT ?`).all(now, limit);
1277
+ return rows.map(row => validateSchedule(JSON.parse(row.payload)));
1278
+ }
1279
+ saveJobDefinition(job) {
1280
+ validateJobDefinition(job);
1281
+ if (job.scope.kind === "task")
1282
+ this.#requireTask(job.scope.taskId);
1283
+ const previous = this.getJobDefinition(job.scope, job.id);
1284
+ if (previous !== null && (job.revision !== previous.revision + 1
1285
+ || job.createdAt !== previous.createdAt || Date.parse(job.updatedAt) < Date.parse(previous.updatedAt))) {
1286
+ throw new StorageRecordError("Job definition revision changed; reload before editing.");
1287
+ }
1288
+ this.#mutate(() => {
1289
+ this.#db.prepare(`INSERT INTO job_definitions VALUES(?,?,?)
1290
+ ON CONFLICT(scope_key,definition_id) DO UPDATE SET payload=excluded.payload`)
1291
+ .run(jobScopeKey(job.scope), job.id, this.#recordJson("job_definitions", job));
1292
+ });
1293
+ }
1294
+ getJobDefinition(scope, id) {
1295
+ return this.#getPayload("job_definitions", "scope_key=? AND definition_id=?", [jobScopeKey(scope), id]);
1296
+ }
1297
+ listJobDefinitions(scope) {
1298
+ return this.#listPayload("job_definitions", "scope_key=?", [jobScopeKey(scope)]);
1299
+ }
1300
+ saveGlobalJobRun(run) {
1301
+ validateDurableJob(run);
1302
+ if ("taskId" in run || run.scope !== "global")
1303
+ throw new StorageRecordError("Global Run cannot have a Task.");
1304
+ const previous = this.getGlobalJobRun(run.id);
1305
+ if (previous !== null && (!validDurableJobTransition(previous, run)
1306
+ || Date.parse(run.updatedAt) < Date.parse(previous.updatedAt))) {
1307
+ throw new StorageRecordError("Invalid global JobRun transition.");
1308
+ }
1309
+ this.#mutate(() => {
1310
+ this.#db.prepare(`INSERT INTO global_job_runs VALUES(?,?,?)
1311
+ ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload,status=excluded.status`)
1312
+ .run(run.id, this.#recordJson("global_job_runs", run), run.status);
1313
+ });
1314
+ }
1315
+ getGlobalJobRun(id) {
1316
+ return this.#getPayload("global_job_runs", "run_id=?", [id]);
1317
+ }
1318
+ listGlobalJobRuns() {
1319
+ return this.#listPayload("global_job_runs", "1=1", []);
1320
+ }
1321
+ listActiveGlobalJobRuns() {
1322
+ return this.#listPayload("global_job_runs", "status IN ('queued','running')", []);
1323
+ }
1237
1324
  nextDurableJobId(taskId) {
1238
1325
  return this.#nextTaskRecordId(taskId, "durableJob");
1239
1326
  }
@@ -1243,21 +1330,35 @@ export class SqliteTaskStore {
1243
1330
  }
1244
1331
  validateDurableJob(job);
1245
1332
  this.#requireTask(taskId);
1246
- const existing = this.getDurableJob(taskId, job.id);
1247
- if (existing !== null) {
1248
- if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
1249
- throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
1250
- }
1251
- if (!validDurableJobTransition(existing, job)) {
1252
- throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
1253
- }
1254
- }
1255
1333
  this.#mutate(() => {
1334
+ const existing = this.getDurableJob(taskId, job.id);
1335
+ if (existing !== null) {
1336
+ if (Date.parse(job.updatedAt) < Date.parse(existing.updatedAt)) {
1337
+ throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${job.id}`);
1338
+ }
1339
+ if (!validDurableJobTransition(existing, job)) {
1340
+ throw new StorageRecordError(`DurableJob transition is invalid: ${job.id}`);
1341
+ }
1342
+ }
1256
1343
  this.#db.prepare(`INSERT INTO durable_jobs (job_id, task_id, idempotency_key, status, payload, created_at, updated_at)
1257
1344
  VALUES (?, ?, ?, ?, ?, ?, ?)
1258
1345
  ON CONFLICT(task_id, job_id) DO UPDATE SET idempotency_key = excluded.idempotency_key,
1259
1346
  status = excluded.status,
1260
1347
  payload = excluded.payload, updated_at = excluded.updated_at`).run(job.id, taskId, job.idempotencyKey ?? null, job.status, this.#recordJson("durable_jobs", job), job.createdAt, job.updatedAt);
1348
+ // All Task Job writers share this boundary (including cancellation and
1349
+ // unknown-result acknowledgement). Publish semantic changes atomically;
1350
+ // heartbeat/checkpoint/notification bookkeeping is deliberately excluded.
1351
+ if (existing === null || existing.status !== job.status
1352
+ || !isDeepStrictEqual(existing.result, job.result)
1353
+ || existing.cancelRequestedAt !== job.cancelRequestedAt
1354
+ || existing.acknowledgedAt !== job.acknowledgedAt) {
1355
+ this.saveEvent(taskId, createTaskEvent(this.nextEventId(taskId), taskId, "job.state-changed", {
1356
+ jobId: job.id, status: job.status,
1357
+ ...(existing === null ? {} : { previousStatus: existing.status }),
1358
+ ...(job.cancelRequestedAt === undefined ? {} : { cancelRequestedAt: job.cancelRequestedAt }),
1359
+ ...(job.acknowledgedAt === undefined ? {} : { acknowledgedAt: job.acknowledgedAt })
1360
+ }, new Date(job.updatedAt)));
1361
+ }
1261
1362
  });
1262
1363
  }
1263
1364
  listDurableJobs(taskId) {
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.8";
1
+ export const CURRENT_STORAGE_VERSION = "1.11";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -70,6 +70,11 @@ const READ_ONLY_STORE_METHODS = new Set([
70
70
  "listIntegrationAttempts",
71
71
  "getIntegrationAttempt",
72
72
  "listActiveDurableJobs",
73
+ "getJobDefinition",
74
+ "listJobDefinitions",
75
+ "getGlobalJobRun",
76
+ "listGlobalJobRuns",
77
+ "listActiveGlobalJobRuns",
73
78
  "listRoles",
74
79
  "getRole",
75
80
  "listManagedWorkspaces",
@@ -0,0 +1,22 @@
1
+ import { validateDurableJob } from "../../job/durableJob.js";
2
+ import { definitionFromTaskRun } from "../../job/jobDefinition.js";
3
+ export function migrateJobDefinitions(db) {
4
+ const rows = db.prepare("SELECT task_id, job_id, payload FROM durable_jobs").all();
5
+ for (const row of rows) {
6
+ const original = JSON.parse(row.payload);
7
+ if (original.taskId !== row.task_id || original.id !== row.job_id
8
+ || original.definitionId !== undefined || original.definitionRevision !== undefined || original.trigger !== undefined) {
9
+ throw new Error(`Invalid 1.8 Job identity: ${row.task_id}/${row.job_id}.`);
10
+ }
11
+ const run = validateDurableJob({
12
+ ...original, definitionId: original.id, definitionRevision: 1,
13
+ trigger: { source: "migration", key: original.operation.requestId }
14
+ });
15
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
16
+ .run(9, "durable_jobs", `${row.task_id}/${row.job_id}`, row.payload);
17
+ db.prepare("INSERT INTO job_definitions VALUES(?,?,?)")
18
+ .run(row.task_id, run.definitionId, JSON.stringify(definitionFromTaskRun(run)));
19
+ db.prepare("UPDATE durable_jobs SET payload=? WHERE task_id=? AND job_id=?")
20
+ .run(JSON.stringify(run), row.task_id, row.job_id);
21
+ }
22
+ }
@@ -0,0 +1,128 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ const REQUIRED = [
3
+ "taskId", "publicationId", "projectId", "localCommit", "provider", "repository",
4
+ "externalKind", "externalId", "externalUrl", "targetBranch", "state",
5
+ "headCommit", "observedAt", "tool", "command", "output"
6
+ ];
7
+ const OPTIONAL = ["remoteCommit", "mergedAt"];
8
+ const SHA = /^[0-9a-f]{40}$/u;
9
+ const MAX_AGE_MS = 24 * 60 * 60 * 1_000;
10
+ export function parsePublicationToolObservation(text) {
11
+ if (Buffer.byteLength(text, "utf8") > 64 * 1024) {
12
+ throw usageError("Tool observation must not exceed 64 KiB; supply a sanitized relevant output excerpt.");
13
+ }
14
+ let value;
15
+ try {
16
+ value = JSON.parse(text);
17
+ }
18
+ catch {
19
+ throw usageError("--observation requires a JSON object.");
20
+ }
21
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
22
+ throw usageError("--observation requires a JSON object.");
23
+ }
24
+ const record = value;
25
+ const allowed = new Set([...REQUIRED, ...OPTIONAL]);
26
+ for (const key of Object.keys(record)) {
27
+ if (!allowed.has(key))
28
+ throw usageError(`Unknown tool observation field: ${key}.`);
29
+ }
30
+ for (const key of [...REQUIRED, ...OPTIONAL]) {
31
+ if (OPTIONAL.includes(key) && record[key] === undefined)
32
+ continue;
33
+ if (typeof record[key] !== "string" || record[key].trim().length === 0) {
34
+ throw usageError(`Tool observation requires nonempty ${key}.`);
35
+ }
36
+ }
37
+ for (const key of ["localCommit", "headCommit", "remoteCommit"]) {
38
+ if (record[key] !== undefined && !SHA.test(record[key])) {
39
+ throw usageError(`Tool observation ${key} requires an exact lowercase Git SHA.`);
40
+ }
41
+ }
42
+ if (!((record.provider === "github" && record.externalKind === "pull-request")
43
+ || (record.provider === "gitlab" && record.externalKind === "merge-request"))) {
44
+ throw usageError("Tool observation requires a matching GitHub PR or GitLab MR kind.");
45
+ }
46
+ if (!["open", "merged", "closed"].includes(record.state)
47
+ || (record.state === "merged" && record.remoteCommit === undefined)) {
48
+ throw usageError("Tool observation requires a known state and an integrated commit for a merge.");
49
+ }
50
+ for (const key of ["observedAt", "mergedAt"]) {
51
+ if (record[key] !== undefined
52
+ && (!/^\d{4}-\d\d-\d\dT/u.test(record[key])
53
+ || !Number.isFinite(Date.parse(record[key])))) {
54
+ throw usageError(`Tool observation ${key} requires an ISO timestamp.`);
55
+ }
56
+ }
57
+ // Canonical key ordering makes retries independent of JSON whitespace/order.
58
+ const evidence = JSON.stringify(Object.fromEntries(Object.keys(record).sort().map(key => [key, record[key]])));
59
+ return { ...record, evidence };
60
+ }
61
+ export function assertPublicationToolObservation(publication, observation, now, observationNotBefore) {
62
+ for (const field of ["taskId", "projectId", "provider", "repository",
63
+ "externalKind", "externalId", "localCommit", "targetBranch"]) {
64
+ if (observation[field] !== publication[field]) {
65
+ throw usageError(`Tool observation ${field} does not match the Publication candidate.`);
66
+ }
67
+ }
68
+ if (observation.publicationId !== publication.id) {
69
+ throw usageError("Tool observation publicationId does not match the selected Publication.");
70
+ }
71
+ if (publication.externalUrl === undefined
72
+ || canonicalPublicationUrl(observation.externalUrl, publication.provider)
73
+ !== canonicalPublicationUrl(publication.externalUrl, publication.provider)) {
74
+ throw usageError("Tool observation externalUrl must match the recorded Publication service and object.");
75
+ }
76
+ const url = new URL(canonicalPublicationUrl(observation.externalUrl, publication.provider));
77
+ const path = publication.provider === "gitlab"
78
+ ? `/${publication.repository}/-/merge_requests/${publication.externalId}`
79
+ : `/${publication.repository.toLowerCase()}/pull/${publication.externalId}`;
80
+ // The registered URL comparison above pins the host AND any service prefix.
81
+ // GitLab may be served below a relative root such as /gitlab.
82
+ if (publication.provider === "gitlab" ? !url.pathname.endsWith(path) : url.pathname !== path) {
83
+ throw usageError("Tool observation URL does not match the repository and PR/MR identity.");
84
+ }
85
+ const observed = Date.parse(observation.observedAt);
86
+ if ((observationNotBefore !== undefined && observed < observationNotBefore)
87
+ || observed > now.getTime() || now.getTime() - observed > MAX_AGE_MS) {
88
+ throw usageError("Tool observation is stale or future-dated; observe the current Publication within 24 hours of recording.");
89
+ }
90
+ if (observation.mergedAt !== undefined && Date.parse(observation.mergedAt) > observed) {
91
+ throw usageError("Tool observation merge time cannot follow its observation time.");
92
+ }
93
+ }
94
+ /** Derive the latest material change from immutable history, not title edits or
95
+ * initial bookkeeping. A tool read may legitimately precede its first upsert.
96
+ * Changed-then-restored candidates still have a change boundary. */
97
+ export function publicationObservationNotBefore(publication, previous) {
98
+ const fields = [
99
+ "projectId", "provider", "repository", "externalKind", "externalId",
100
+ "externalUrl", "localCommit", "targetBranch", "state", "headCommit", "remoteCommit", "mergedAt"
101
+ ];
102
+ let current = publication;
103
+ while (current.supersedes !== undefined) {
104
+ const prior = previous(current.supersedes);
105
+ if (prior === null)
106
+ throw usageError("Publication history is incomplete; cannot validate observation freshness.");
107
+ if (fields.some(field => current[field] !== prior[field])) {
108
+ return Date.parse(current.createdAt);
109
+ }
110
+ current = prior;
111
+ }
112
+ return undefined;
113
+ }
114
+ export function canonicalPublicationUrl(value, provider) {
115
+ let url;
116
+ try {
117
+ url = new URL(value);
118
+ }
119
+ catch {
120
+ throw usageError("Publication URL must be an absolute HTTP(S) URL.");
121
+ }
122
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password
123
+ || url.search || url.hash) {
124
+ throw usageError("Publication URL must be HTTP(S), without credentials, query or fragment.");
125
+ }
126
+ const path = url.pathname.replace(/\/$/u, "");
127
+ return `${url.origin}${provider === "github" ? path.toLowerCase() : path}`;
128
+ }