@zq-silk/yui 2.17.0 → 2.19.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 (73) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +14 -4
  3. package/dist/commands/durableJobCommands.js +50 -3
  4. package/dist/commands/globalRoleCommands.js +18 -12
  5. package/dist/commands/inboxCommands.js +1 -0
  6. package/dist/commands/operatorCommands.js +10 -2
  7. package/dist/controlPlaneCli.js +21 -4
  8. package/dist/controller/clientRuntime.js +34 -13
  9. package/dist/controller/controller.js +45 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +143 -101
  12. package/dist/controller/globalInputDelivery.js +6 -2
  13. package/dist/controller/globalRuntimeAttention.js +11 -3
  14. package/dist/controller/jobControl.js +1 -1
  15. package/dist/controller/jobDefinitionControl.js +24 -13
  16. package/dist/controller/jobSupervisor.js +4 -0
  17. package/dist/controller/messageHealth.js +354 -0
  18. package/dist/controller/runtime.js +75 -5
  19. package/dist/controller/taskHostTerminalRecovery.js +11 -0
  20. package/dist/controller/workerSchedulerStore.js +24 -0
  21. package/dist/core/boundedRpc.js +4 -0
  22. package/dist/core/controllerServer.js +1 -1
  23. package/dist/core/controllerTelemetry.js +92 -19
  24. package/dist/job/globalShell.js +51 -0
  25. package/dist/job/jobBusinessResult.js +4 -3
  26. package/dist/job/jobDefinition.js +6 -1
  27. package/dist/job/jobRunner.js +9 -0
  28. package/dist/job/scheduleService.js +2 -0
  29. package/dist/message/globalInboxRecovery.js +30 -19
  30. package/dist/message/globalProviderRetry.js +7 -1
  31. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  32. package/dist/runtime/globalSessionHealth.js +39 -0
  33. package/dist/scheduler/roleRunStall.js +10 -10
  34. package/dist/storage/contextRecords.js +16 -0
  35. package/dist/storage/persistenceWorker.js +41 -19
  36. package/dist/storage/sqliteSchema.js +35 -11
  37. package/dist/storage/sqliteStore.js +102 -8
  38. package/dist/storage/storageVersions.js +1 -1
  39. package/dist/storage/storeRpc.js +25 -11
  40. package/dist/storage/taskReadRevisionSchema.js +39 -0
  41. package/dist/web/assets/client/app/catalog.js +0 -29
  42. package/dist/web/assets/client/app/jobs.js +115 -0
  43. package/dist/web/assets/client/app/main.js +17 -6
  44. package/dist/web/assets/client/app/manage.js +1 -0
  45. package/dist/web/assets/client/app/settings.js +1 -5
  46. package/dist/web/assets/client/i18n/en.js +203 -18
  47. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  48. package/dist/web/assets/client/index.js +2 -0
  49. package/dist/web/assets/client/layout/manageShell.js +61 -15
  50. package/dist/web/assets/client/layout/router.js +2 -1
  51. package/dist/web/assets/client/lib/prefs.js +1 -1
  52. package/dist/web/assets/client/views/jobs.js +1042 -160
  53. package/dist/web/assets/client/views/sidebar.js +3 -0
  54. package/dist/web/assets/shared/manageNav.js +3 -1
  55. package/dist/web/assets/shell/appRail.js +2 -1
  56. package/dist/web/assets/shell/manage.js +7 -2
  57. package/dist/web/assets/shell/models.js +4 -3
  58. package/dist/web/assets/shell/settingsContent.js +0 -3
  59. package/dist/web/assets/shell/sidebar.js +0 -1
  60. package/dist/web/assets/styles/views/jobs.js +301 -12
  61. package/dist/web/assets/styles/views/sidebar.js +1 -2
  62. package/dist/web/webServer.js +28 -1
  63. package/docs/global-shell-jobs.md +78 -0
  64. package/docs/job-runs.md +6 -3
  65. package/docs/managed-turn-and-session-runtime.md +170 -0
  66. package/docs/managed-turn-and-session-runtime.zh-CN.md +116 -0
  67. package/docs/release-workflow.md +63 -0
  68. package/docs/release-workflow.zh-CN.md +44 -0
  69. package/docs/storage-baseline.md +14 -1
  70. package/docs/storage-baseline.zh-CN.md +6 -1
  71. package/docs/testing/verification-levels.md +2 -2
  72. package/docs/testing/verification-levels.zh-CN.md +2 -2
  73. package/package.json +1 -1
@@ -33,6 +33,8 @@
33
33
  import { runRpcWorker } from "../core/boundedRpc.js";
34
34
  import { SqliteTaskStore } from "./sqliteStore.js";
35
35
  import { StorageRecordError } from "./taskStore.js";
36
+ import { randomUUID } from "node:crypto";
37
+ import { ControllerWorkObserver, compactWorkObservation } from "../core/controllerTelemetry.js";
36
38
  // -- method invocation -------------------------------------------------------
37
39
  function invokeMethod(target, method, args) {
38
40
  const fn = target[method];
@@ -41,6 +43,13 @@ function invokeMethod(target, method, args) {
41
43
  }
42
44
  return fn.apply(target, args);
43
45
  }
46
+ const workerInstance = randomUUID();
47
+ const work = new ControllerWorkObserver();
48
+ function observation() {
49
+ return { instance: workerInstance, work: compactWorkObservation(work),
50
+ database: state.writer?.storageObservationMetrics() ?? null,
51
+ reads: state.observer?.compactReadObservationMetrics?.() ?? null };
52
+ }
44
53
  /**
45
54
  * Sentinel returned by a handler when the durable outbox already records the
46
55
  * request's effect (§5.4). The host's `result` builder maps it to an
@@ -80,7 +89,10 @@ async function handleInit(request) {
80
89
  state.observer = {
81
90
  invoke(method, args) {
82
91
  return invokeMethod(host, method, args);
83
- }
92
+ },
93
+ ...(host.compactReadObservationMetrics === undefined ? {} : {
94
+ compactReadObservationMetrics: () => host.compactReadObservationMetrics()
95
+ })
84
96
  };
85
97
  }
86
98
  }
@@ -162,21 +174,31 @@ runRpcWorker({
162
174
  },
163
175
  init: (request) => handleInit(request),
164
176
  handle: async (request) => {
165
- switch (request.kind) {
166
- case "call":
167
- return handleCall(request);
168
- case "transaction":
169
- return handleTransaction(request);
170
- case "observer":
171
- return handleObserver(request);
172
- case "telemetry":
173
- if (state.writer === undefined)
174
- throw new Error("Worker not initialized.");
175
- return state.writer.flushTelemetry(request.entries, request.runCap);
176
- case "init":
177
- case "cancel":
178
- case "shutdown":
179
- throw new Error(`Unexpected request kind for handle: ${request.kind}`);
177
+ const finish = work.start(request.kind, "requestId" in request ? request.requestId : undefined);
178
+ try {
179
+ switch (request.kind) {
180
+ case "call":
181
+ return await handleCall(request);
182
+ case "transaction":
183
+ return await handleTransaction(request);
184
+ case "observer":
185
+ return await handleObserver(request);
186
+ case "telemetry":
187
+ if (state.writer === undefined)
188
+ throw new Error("Worker not initialized.");
189
+ return state.writer.flushTelemetry(request.entries, request.runCap);
190
+ case "init":
191
+ case "cancel":
192
+ case "shutdown":
193
+ throw new Error(`Unexpected request kind for handle: ${request.kind}`);
194
+ }
195
+ }
196
+ catch (error) {
197
+ finish(true);
198
+ throw error;
199
+ }
200
+ finally {
201
+ finish();
180
202
  }
181
203
  },
182
204
  cancel: (requestId) => {
@@ -196,7 +218,7 @@ runRpcWorker({
196
218
  },
197
219
  ready: () => ({ kind: "ready" }),
198
220
  result: (requestId, value) => (value === ALREADY_APPLIED
199
- ? { kind: "already-applied", requestId }
200
- : { kind: "result", requestId, result: value }),
201
- error: (requestId, error) => ({ kind: "error", requestId, error })
221
+ ? { kind: "already-applied", requestId, observation: observation() }
222
+ : { kind: "result", requestId, result: value, observation: observation() }),
223
+ error: (requestId, error) => ({ kind: "error", requestId, error, observation: observation() })
202
224
  });
@@ -5,6 +5,7 @@ import { migrateTaskMainWorkspaces } from "./upgrade/taskMainWorkspaceMigration.
5
5
  import { migrateProjectKnowledge } from "./upgrade/projectKnowledgeMigration.js";
6
6
  import { JOB_SCHEMA_SQL } from "./jobSchema.js";
7
7
  import { AGENT_JOB_SCHEMA_SQL } from "./agentJobSchema.js";
8
+ import { TASK_READ_REVISION_SCHEMA_SQL } from "./taskReadRevisionSchema.js";
8
9
  import { SCHEDULE_SCHEMA_SQL } from "./scheduleSchema.js";
9
10
  import { GLOBAL_SESSION_TABLE_SQL, SESSION_HISTORY_UPGRADE_SQL } from "./sessionHistorySchema.js";
10
11
  import { migrateJobDefinitions } from "./upgrade/jobDefinitionMigration.js";
@@ -13,7 +14,7 @@ import { migrateScheduleDispatch } from "./upgrade/scheduleDispatchMigration.js"
13
14
  import { migrateProjectSyncClock } from "./upgrade/projectSyncClockMigration.js";
14
15
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
15
16
  export { BASELINE_SCHEMA_SQL };
16
- export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs"];
17
+ export const SQLITE_SCHEMA_TABLES = [...BASELINE_SCHEMA_TABLES, "job_definitions", "global_job_runs", "schedules", "agent_job_runs", "task_read_revisions"];
17
18
  export const TELEMETRY_KEEP_PER_RUN = 200;
18
19
  export const TELEMETRY_RUN_CAP = 50_000;
19
20
  export const BASELINE_SCHEMA_CHECKSUM = createHash("sha256").update(BASELINE_SCHEMA_SQL).digest("hex");
@@ -23,7 +24,9 @@ export const SCHEDULE_SCHEMA_CHECKSUM = createHash("sha256").update(SCHEDULE_SCH
23
24
  // Fresh Homes create the final layout directly; published baseline SQL stays immutable.
24
25
  const SESSION_SCHEMA_SQL = SCHEDULE_SCHEMA_SQL_COMPLETE.replace(/CREATE TABLE global_role_session_sets \([\s\S]*?\n\);/, GLOBAL_SESSION_TABLE_SQL);
25
26
  const SESSION_SCHEMA_CHECKSUM = createHash("sha256").update(SESSION_SCHEMA_SQL).digest("hex");
26
- export const CURRENT_SCHEMA_SQL = SESSION_SCHEMA_SQL + AGENT_JOB_SCHEMA_SQL;
27
+ export const PRE_READ_REVISION_SCHEMA_SQL = SESSION_SCHEMA_SQL + AGENT_JOB_SCHEMA_SQL;
28
+ const PRE_READ_REVISION_SCHEMA_CHECKSUM = createHash("sha256").update(PRE_READ_REVISION_SCHEMA_SQL).digest("hex");
29
+ export const CURRENT_SCHEMA_SQL = PRE_READ_REVISION_SCHEMA_SQL + TASK_READ_REVISION_SCHEMA_SQL;
27
30
  export const CURRENT_SCHEMA_CHECKSUM = createHash("sha256").update(CURRENT_SCHEMA_SQL).digest("hex");
28
31
  export class SqliteSchemaError extends Error {
29
32
  code;
@@ -230,7 +233,7 @@ const MINOR_UPGRADES = Object.freeze([{
230
233
  name: "job-agent-execution",
231
234
  introducedIn: "next",
232
235
  sourceChecksum: SESSION_SCHEMA_CHECKSUM,
233
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
236
+ targetChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
234
237
  // Existing records and immutable artifacts remain byte-for-byte unchanged.
235
238
  // Declares Agent definitions/Runs and optional Job ownership of a non-Task Role.
236
239
  sql: AGENT_JOB_SCHEMA_SQL
@@ -239,8 +242,8 @@ const MINOR_UPGRADES = Object.freeze([{
239
242
  toVersion: "1.21",
240
243
  name: "project-sync-clock",
241
244
  introducedIn: "next",
242
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
243
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
245
+ sourceChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
246
+ targetChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
244
247
  sql: "",
245
248
  dataMigration: "project-sync-clock"
246
249
  }, {
@@ -248,11 +251,31 @@ const MINOR_UPGRADES = Object.freeze([{
248
251
  toVersion: "1.22",
249
252
  name: "role-profile-availability",
250
253
  introducedIn: "next",
251
- sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
252
- targetChecksum: CURRENT_SCHEMA_CHECKSUM,
254
+ sourceChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
255
+ targetChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
253
256
  // Optional enabled flag; absence means enabled. Preserve all historical
254
257
  // Role/Profile bytes, frozen contexts and Session snapshots.
255
258
  sql: ""
259
+ }, {
260
+ fromVersion: "1.22",
261
+ toVersion: "1.23",
262
+ name: "global-shell-scripts",
263
+ introducedIn: "next",
264
+ sourceChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
265
+ targetChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
266
+ // Additive global Shell definitions with revisioned script text and exact
267
+ // Run snapshots. Historical definitions/Runs stay byte-for-byte unchanged.
268
+ sql: ""
269
+ }, {
270
+ fromVersion: "1.23",
271
+ toVersion: "1.24",
272
+ name: "task-read-revisions",
273
+ introducedIn: "next",
274
+ sourceChecksum: PRE_READ_REVISION_SCHEMA_CHECKSUM,
275
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
276
+ // No payload rewriting or historical revision invention. A newly opened
277
+ // reader starts empty; zero is the initial token until the first mutation.
278
+ sql: TASK_READ_REVISION_SCHEMA_SQL
256
279
  }]);
257
280
  export function storageMinorUpgradePlan(from) {
258
281
  if (from === CURRENT_STORAGE_VERSION)
@@ -282,10 +305,11 @@ export function validateSqliteSchemaObjects(db, version = CURRENT_STORAGE_VERSIO
282
305
  const checksum = version === CURRENT_STORAGE_VERSION
283
306
  ? CURRENT_SCHEMA_CHECKSUM : storageMinorUpgradePlan(version)?.[0]?.sourceChecksum;
284
307
  const sql = checksum === CURRENT_SCHEMA_CHECKSUM ? CURRENT_SCHEMA_SQL
285
- : checksum === SESSION_SCHEMA_CHECKSUM ? SESSION_SCHEMA_SQL
286
- : checksum === SCHEDULE_SCHEMA_CHECKSUM ? SCHEDULE_SCHEMA_SQL_COMPLETE
287
- : checksum === JOB_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL + JOB_SCHEMA_SQL
288
- : checksum === BASELINE_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL : undefined;
308
+ : checksum === PRE_READ_REVISION_SCHEMA_CHECKSUM ? PRE_READ_REVISION_SCHEMA_SQL
309
+ : checksum === SESSION_SCHEMA_CHECKSUM ? SESSION_SCHEMA_SQL
310
+ : checksum === SCHEDULE_SCHEMA_CHECKSUM ? SCHEDULE_SCHEMA_SQL_COMPLETE
311
+ : checksum === JOB_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL + JOB_SCHEMA_SQL
312
+ : checksum === BASELINE_SCHEMA_CHECKSUM ? BASELINE_SCHEMA_SQL : undefined;
289
313
  if (sql === undefined || checksum === undefined)
290
314
  throw new SqliteSchemaError(`unsupported schema definition for storage ${version}.`);
291
315
  let expectedObjects = schemaObjectsByChecksum.get(checksum);
@@ -34,6 +34,7 @@ import { validateAgentJobRun, assertAgentJobRunTransition } from "../job/agentJo
34
34
  * in-process contract or deferred validation phase.
35
35
  */
36
36
  import Database from "../sqlite/driver.js";
37
+ import { ControllerWorkObserver, compactWorkObservation } from "../core/controllerTelemetry.js";
37
38
  import { findNativeSessionSets } from "./nativeSessionLookup.js";
38
39
  import { existsSync, mkdirSync } from "node:fs";
39
40
  import { join } from "node:path";
@@ -141,10 +142,37 @@ function assertRunAdmission(task, run) {
141
142
  }
142
143
  }
143
144
  export class SqliteTaskStore {
145
+ #payloadReads = new ControllerWorkObserver();
146
+ #transactions = {
147
+ "write-lock": new ControllerWorkObserver(),
148
+ "write-body": new ControllerWorkObserver(),
149
+ "transaction-commit": new ControllerWorkObserver()
150
+ };
151
+ storageObservationMetrics() {
152
+ return {
153
+ lock: compactWorkObservation(this.#transactions["write-lock"]),
154
+ body: compactWorkObservation(this.#transactions["write-body"]),
155
+ commit: compactWorkObservation(this.#transactions["transaction-commit"]),
156
+ payloadReads: compactWorkObservation(this.#payloadReads)
157
+ };
158
+ }
159
+ #observeTransaction(phase, action) {
160
+ const finish = this.#transactions[phase].start(phase);
161
+ try {
162
+ const result = action();
163
+ finish();
164
+ return result;
165
+ }
166
+ catch (error) {
167
+ finish(true);
168
+ throw error;
169
+ }
170
+ }
144
171
  #db;
145
172
  #rootDir;
146
173
  #openedSchemaHead;
147
174
  #inTransaction = false;
175
+ #writeTransaction = false;
148
176
  #dirty = false;
149
177
  constructor(rootDir, _options = {}) {
150
178
  this.#rootDir = rootDir;
@@ -261,6 +289,13 @@ export class SqliteTaskStore {
261
289
  listEventsByType(taskId, types) {
262
290
  return this.#sortById(this.#listPayload("events", "task_id = ? AND type IN (SELECT value FROM json_each(?))", [taskId, JSON.stringify(types)]), event => event.id);
263
291
  }
292
+ hasRuntimeObservation(taskId, identity) {
293
+ const byEvent = "eventId" in identity;
294
+ return this.#db.prepare(`SELECT 1 FROM events WHERE task_id = ?
295
+ AND type = 'runtime.observation'
296
+ AND json_extract(payload, '${byEvent ? "$.payload.eventId" : "$.payload.semanticKey"}') = ?
297
+ LIMIT 1`).get(taskId, byEvent ? identity.eventId : identity.semanticKey) !== undefined;
298
+ }
264
299
  readTaskUsageFacts(taskId) {
265
300
  // Bounded source selection, not a scan/deserialization of Task history.
266
301
  // Strip result bodies before they cross the SQLite boundary.
@@ -348,13 +383,15 @@ export class SqliteTaskStore {
348
383
  writeTelemetryBatch(this.#db, entries, runCap);
349
384
  }
350
385
  #begin() {
351
- this.#db.exec("BEGIN IMMEDIATE");
386
+ this.#observeTransaction("write-lock", () => this.#db.exec("BEGIN IMMEDIATE"));
352
387
  this.#inTransaction = true;
388
+ this.#writeTransaction = true;
353
389
  this.#dirty = false;
354
390
  }
355
391
  #commit() {
356
- this.#db.exec("COMMIT");
392
+ this.#observeTransaction("transaction-commit", () => this.#db.exec("COMMIT"));
357
393
  this.#inTransaction = false;
394
+ this.#writeTransaction = false;
358
395
  this.#dirty = false;
359
396
  }
360
397
  #rollback() {
@@ -363,6 +400,7 @@ export class SqliteTaskStore {
363
400
  }
364
401
  catch { /* already closed */ }
365
402
  this.#inTransaction = false;
403
+ this.#writeTransaction = false;
366
404
  this.#dirty = false;
367
405
  }
368
406
  /** Verify the SQLite schema head at the single write boundary. */
@@ -393,7 +431,7 @@ export class SqliteTaskStore {
393
431
  this.#begin();
394
432
  try {
395
433
  this.#prepareWrite();
396
- const result = fn();
434
+ const result = this.#observeTransaction("write-body", fn);
397
435
  this.#bumpRevision();
398
436
  this.#commit();
399
437
  return result;
@@ -413,7 +451,7 @@ export class SqliteTaskStore {
413
451
  // This prevents a long-lived Store from executing SQL after the database
414
452
  // schema head has changed underneath it.
415
453
  this.#prepareWrite();
416
- const result = run(this);
454
+ const result = this.#observeTransaction("write-body", () => run(this));
417
455
  if (this.#dirty) {
418
456
  if (options?.requestId !== undefined) {
419
457
  this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
@@ -438,7 +476,7 @@ export class SqliteTaskStore {
438
476
  this.#begin();
439
477
  try {
440
478
  this.#prepareWrite();
441
- const result = await execute(this);
479
+ const result = await this.#transactions["write-body"].observe("write-body", undefined, () => execute(this));
442
480
  if (this.#dirty)
443
481
  this.#bumpRevision();
444
482
  this.#commit();
@@ -472,7 +510,7 @@ export class SqliteTaskStore {
472
510
  if (current !== expectedRevision) {
473
511
  throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`, current);
474
512
  }
475
- const result = execute(this);
513
+ const result = this.#observeTransaction("write-body", () => execute(this));
476
514
  if (this.#dirty) {
477
515
  this.#prepareWrite();
478
516
  if (options?.requestId !== undefined) {
@@ -496,6 +534,13 @@ export class SqliteTaskStore {
496
534
  return row.revision;
497
535
  }
498
536
  getStateRevision() { return this.getRevision(); }
537
+ getTaskReadRevision(taskId) {
538
+ // Uncommitted writes have no durable cache identity, even if rolled back.
539
+ if (this.#writeTransaction)
540
+ return Number.NaN;
541
+ return this.#db.prepare("SELECT revision FROM task_read_revisions WHERE task_id = ?")
542
+ .get(taskId)?.revision ?? 0;
543
+ }
499
544
  /**
500
545
  * Run an ordered command batch inside one `BEGIN IMMEDIATE … COMMIT`, yielding
501
546
  * to the event loop between commands so a cancellation signal can interrupt
@@ -656,8 +701,19 @@ export class SqliteTaskStore {
656
701
  return row === undefined ? null : this.#validateTaskPayload(table, row.payload);
657
702
  }
658
703
  #listPayload(table, where, params) {
659
- const rows = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).all(...params);
660
- return rows.map((row) => this.#validateTaskPayload(table, row.payload));
704
+ // Table names are fixed internal callers; no SQL, parameters or payload
705
+ // text enters diagnostics. Include parsing/validation, not only SQLite IO.
706
+ const finish = this.#payloadReads.start(`list-${table}`);
707
+ try {
708
+ const rows = this.#db.prepare(`SELECT payload FROM ${table} WHERE ${where}`).all(...params);
709
+ const values = rows.map((row) => this.#validateTaskPayload(table, row.payload));
710
+ finish();
711
+ return values;
712
+ }
713
+ catch (error) {
714
+ finish(true);
715
+ throw error;
716
+ }
661
717
  }
662
718
  #validateTaskPayload(table, payload) {
663
719
  const record = this.#parse(payload);
@@ -1019,8 +1075,35 @@ export class SqliteTaskStore {
1019
1075
  OR json_extract(payload, '$.interruptThen') IS NOT NULL)`);
1020
1076
  if (query.interruptThen)
1021
1077
  where.push("json_extract(payload, '$.interruptThen') IS NOT NULL");
1078
+ if (query.reservedThen !== undefined) {
1079
+ where.push(`json_extract(payload, '$.delivery') IS NULL
1080
+ AND json_extract(payload, '$.notDelivered') IS NULL
1081
+ AND json_extract(payload, '$.interruptThen.targetNativeSessionId') = @thenSession
1082
+ AND json_extract(payload, '$.interruptThen.targetAgentId') = @thenAgent
1083
+ AND json_extract(payload, '$.interruptThen.targetAuthorityEpoch') = @thenEpoch
1084
+ AND json_extract(payload, '$.interruptThen.targetAuthorityHolderId') = @thenHolder`);
1085
+ Object.assign(params, { thenSession: query.reservedThen.nativeSessionId,
1086
+ thenAgent: query.reservedThen.agentId, thenEpoch: query.reservedThen.authorityEpoch,
1087
+ thenHolder: query.reservedThen.holderId });
1088
+ }
1022
1089
  if (query.operatorNotice)
1023
1090
  where.push("json_extract(payload, '$.inputControl.requestId') LIKE 'operator-notice:%'");
1091
+ if (query.awaitingDelivery)
1092
+ where.push(`(json_extract(payload, '$.delivery') IS NULL
1093
+ OR json_extract(payload, '$.delivery.via') = 'transport')
1094
+ AND json_extract(payload, '$.inbox.ack') IS NULL
1095
+ AND json_extract(payload, '$.inbox.retirement') IS NULL
1096
+ AND (json_extract(payload, '$.inputControl.action') = 'queue'
1097
+ OR json_extract(payload, '$.interruptThen') IS NOT NULL
1098
+ OR json_extract(payload, '$.control.outcome') IN ('pending','delivery-unknown'))`);
1099
+ if (query.excludeHealthNotices)
1100
+ where.push("COALESCE(json_extract(payload, '$.inputControl.requestId'), '') NOT LIKE 'operator-notice:message-health:%'");
1101
+ if (query.systemBodyPrefix !== undefined) {
1102
+ where.push(`json_extract(payload, '$.kind') = 'system'
1103
+ AND json_extract(payload, '$.author.type') = 'system'
1104
+ AND substr(json_extract(payload, '$.body'), 1, length(@bodyPrefix)) = @bodyPrefix`);
1105
+ params.bodyPrefix = query.systemBodyPrefix;
1106
+ }
1024
1107
  if (query.afterSequence !== undefined) {
1025
1108
  where.push("seq > @after");
1026
1109
  params.after = query.afterSequence;
@@ -1335,6 +1418,17 @@ export class SqliteTaskStore {
1335
1418
  listJobDefinitions(scope) {
1336
1419
  return this.#listPayload("job_definitions", "scope_key=?", [jobScopeKey(scope)]);
1337
1420
  }
1421
+ listJobScopeSummaries() {
1422
+ const rows = this.#db.prepare(`SELECT scope_key, SUM(definitions) AS definitions, SUM(schedules) AS schedules,
1423
+ SUM(enabled) AS enabled FROM (
1424
+ SELECT scope_key, 1 AS definitions, 0 AS schedules, 0 AS enabled FROM job_definitions
1425
+ UNION ALL SELECT scope_key, 0, 1, CASE WHEN state='enabled' THEN 1 ELSE 0 END FROM schedules)
1426
+ GROUP BY scope_key`).all();
1427
+ return rows.map(row => ({
1428
+ scope: row.scope_key === "global" ? { kind: "global" } : { kind: "task", taskId: row.scope_key },
1429
+ definitions: Number(row.definitions), schedules: Number(row.schedules), enabledSchedules: Number(row.enabled)
1430
+ }));
1431
+ }
1338
1432
  saveGlobalJobRun(run) {
1339
1433
  validateDurableJob(run);
1340
1434
  if ("taskId" in run || run.scope !== "global")
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.22";
1
+ export const CURRENT_STORAGE_VERSION = "1.24";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -20,7 +20,8 @@
20
20
  * SQLite is the only product Store. `YUI_STORE_WORKER` selects the observer
21
21
  * execution boundary; it does not select another authority.
22
22
  */
23
- import { BoundedRpcClient, nextRequestId } from "../core/boundedRpc.js";
23
+ import { BoundedRpcClient, deserializeError as deserializeRpcError, nextRequestId } from "../core/boundedRpc.js";
24
+ import { ControllerWorkObserver } from "../core/controllerTelemetry.js";
24
25
  import { StorageCancelledError, StorageConflictError, StorageRecordError } from "./taskStore.js";
25
26
  // -- Read-only classification ------------------------------------------------
26
27
  /**
@@ -37,6 +38,8 @@ const READ_ONLY_STORE_METHODS = new Set([
37
38
  "getHomeIdentity",
38
39
  "getReviewConfig",
39
40
  "getRevision",
41
+ "getStateRevision",
42
+ "getTaskReadRevision",
40
43
  "listConfiguredAgents",
41
44
  "getConfiguredAgent",
42
45
  "listProjects",
@@ -57,6 +60,7 @@ const READ_ONLY_STORE_METHODS = new Set([
57
60
  "queryTaskSearch",
58
61
  "latestEventSequence",
59
62
  "listEventsByType",
63
+ "hasRuntimeObservation",
60
64
  "readTaskUsageFacts",
61
65
  "listTaskRunWorkspaceBases",
62
66
  "contextInputReferences",
@@ -72,6 +76,7 @@ const READ_ONLY_STORE_METHODS = new Set([
72
76
  "listActiveDurableJobs",
73
77
  "getJobDefinition",
74
78
  "listJobDefinitions",
79
+ "listJobScopeSummaries",
75
80
  "getGlobalJobRun",
76
81
  "getAgentJobRun",
77
82
  "listAgentJobRuns",
@@ -137,12 +142,10 @@ function deserializeError(serialized) {
137
142
  if (name === "StorageCancelledError" || name === "AbortError") {
138
143
  return new StorageCancelledError(message);
139
144
  }
140
- const error = new Error(message);
141
- error.name = name;
142
- return error;
145
+ return deserializeRpcError(serialized);
143
146
  }
144
147
  // -- Storage protocol adapter -------------------------------------------------
145
- function storageProtocol(home, options) {
148
+ function storageProtocol(home, options, observe) {
146
149
  return {
147
150
  initRequest: () => ({
148
151
  kind: "init",
@@ -162,6 +165,8 @@ function storageProtocol(home, options) {
162
165
  return response.requestId;
163
166
  },
164
167
  settle: (response, settlement) => {
168
+ if (response.observation !== undefined)
169
+ observe(response.observation);
165
170
  if (response.kind === "result") {
166
171
  settlement.resolve(response.result);
167
172
  return;
@@ -188,8 +193,17 @@ function storageProtocol(home, options) {
188
193
  */
189
194
  export class AsyncTaskStoreClient {
190
195
  #rpc;
196
+ #waits = new ControllerWorkObserver();
197
+ #worker = null;
198
+ /** Cached observations only: status never waits on a busy persistence worker. */
199
+ observationMetrics() {
200
+ return { roundTrips: this.#waits.snapshot(), worker: this.#worker,
201
+ inFlight: this.inFlight, queueDepth: this.queueDepth };
202
+ }
191
203
  constructor(home, options = {}) {
192
- this.#rpc = new BoundedRpcClient(storageProtocol(home, options), {
204
+ this.#rpc = new BoundedRpcClient(storageProtocol(home, options, observation => {
205
+ this.#worker = observation;
206
+ }), {
193
207
  maxInFlight: options.maxInFlight,
194
208
  maxQueue: options.maxQueue,
195
209
  workerScript: options.workerScript ?? new URL("./persistenceWorker.js", import.meta.url)
@@ -198,13 +212,13 @@ export class AsyncTaskStoreClient {
198
212
  /** Invoke a TaskStore method over the RPC (used by the Proxy). */
199
213
  callStore(method, args, options) {
200
214
  const requestId = options?.requestId ?? nextRequestId();
201
- return this.#rpc.send(requestId, {
215
+ return this.#waits.observe("store-call", requestId, () => this.#rpc.send(requestId, {
202
216
  kind: "call",
203
217
  requestId,
204
218
  method,
205
219
  args,
206
220
  readOnly: isReadOnlyMethod(method)
207
- }, { signal: options?.signal });
221
+ }, { signal: options?.signal }));
208
222
  }
209
223
  /** Run a command batch atomically in the worker (§3.2). */
210
224
  transactionAsync(commands, options) {
@@ -213,12 +227,12 @@ export class AsyncTaskStoreClient {
213
227
  op: command.op,
214
228
  args: command.args
215
229
  }));
216
- return this.#rpc.send(requestId, {
230
+ return this.#waits.observe("store-transaction", requestId, () => this.#rpc.send(requestId, {
217
231
  kind: "transaction",
218
232
  requestId,
219
233
  commands: wireCommands,
220
234
  ...(options?.expectedRevision === undefined ? {} : { expectedRevision: options.expectedRevision })
221
- }, { signal: options?.signal });
235
+ }, { signal: options?.signal }));
222
236
  }
223
237
  /**
224
238
  * Invoke a controller observer method hosted by the worker (the
@@ -227,7 +241,7 @@ export class AsyncTaskStoreClient {
227
241
  */
228
242
  invokeObserver(method, args, options) {
229
243
  const requestId = options?.requestId ?? nextRequestId();
230
- return this.#rpc.send(requestId, { kind: "observer", requestId, method, args }, { signal: options?.signal });
244
+ return this.#waits.observe("store-observer", requestId, () => this.#rpc.send(requestId, { kind: "observer", requestId, method, args }, { signal: options?.signal }));
231
245
  }
232
246
  /** Close the worker and release its connections. */
233
247
  close() {
@@ -0,0 +1,39 @@
1
+ /** Storage 1.24 definition: immutable once published. These are cache tokens,
2
+ * not a second Task state or a CAS/authorization source. Triggers cover every
3
+ * source family, including direct SQL, deletions and rolled-back transactions.
4
+ */
5
+ export const TASK_READ_REVISION_SOURCES = [
6
+ "events", "turns", "work_items", "review_rounds", "change_sets",
7
+ "integration_attempts", "input_requests", "durable_jobs", "messages"
8
+ ];
9
+ export const TASK_READ_REVISION_SCHEMA_SQL = `
10
+ CREATE TABLE task_read_revisions (
11
+ task_id TEXT PRIMARY KEY,
12
+ revision INTEGER NOT NULL
13
+ );
14
+ CREATE INDEX idx_runtime_observation_semantic
15
+ ON events(task_id, json_extract(payload, '$.payload.semanticKey'))
16
+ WHERE type = 'runtime.observation';
17
+ CREATE INDEX idx_runtime_observation_event
18
+ ON events(task_id, json_extract(payload, '$.payload.eventId'))
19
+ WHERE type = 'runtime.observation';
20
+ ${TASK_READ_REVISION_SOURCES.map(table => `
21
+ CREATE TRIGGER ${table}_read_ai AFTER INSERT ON ${table}
22
+ BEGIN
23
+ INSERT INTO task_read_revisions VALUES (NEW.task_id, 1)
24
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
25
+ END;
26
+ CREATE TRIGGER ${table}_read_ad AFTER DELETE ON ${table}
27
+ BEGIN
28
+ INSERT INTO task_read_revisions VALUES (OLD.task_id, 1)
29
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
30
+ END;
31
+ CREATE TRIGGER ${table}_read_au AFTER UPDATE ON ${table}
32
+ BEGIN
33
+ INSERT INTO task_read_revisions VALUES (NEW.task_id, 1)
34
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
35
+ INSERT INTO task_read_revisions
36
+ SELECT OLD.task_id, 1 WHERE OLD.task_id != NEW.task_id
37
+ ON CONFLICT(task_id) DO UPDATE SET revision = revision + 1;
38
+ END;
39
+ `).join("")}`;
@@ -111,10 +111,6 @@ function renderSidebar(deps, progress) {
111
111
  reset(deps, progress);
112
112
  });
113
113
  el.catalogCount.textContent = fill(t("catalog.count"), { shown: state.tasks.length, total: state.catalogTotal });
114
- renderSource(el.source, state, t, function (value) {
115
- state.source = value; state.attentionFilter = null; state.query = ""; el.search.value = "";
116
- syncUrl(state, false); reset(deps, progress);
117
- });
118
114
  el.catalogReset.hidden = !state.catalogCursor;
119
115
  el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
120
116
  el.catalogAttentionReset.hidden = !state.attentionFilter;
@@ -122,27 +118,6 @@ function renderSidebar(deps, progress) {
122
118
  el.lastSync.textContent = state.generatedAt ? formatClock(state.generatedAt, deps.locale()) : "—";
123
119
  }
124
120
 
125
- // Task category: search and attention are cross-category, so they show "all";
126
- // picking a category clears them, as the server filter then applies again.
127
- function renderSource(container, state, t, onPick) {
128
- const current = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
129
- // Bound once; the handler reads the live state, not this render's snapshot.
130
- if (!container.childElementCount) {
131
- ["ordinary", "job", "all"].forEach(function (value) {
132
- container.append(h("button.seg-btn", { type: "button", role: "radio", dataset: { source: value },
133
- onclick: function () {
134
- if (value !== (state.source || "ordinary") || state.query.trim() || state.attentionFilter) onPick(value);
135
- } }));
136
- });
137
- }
138
- container.querySelectorAll("[data-source]").forEach(function (button) {
139
- const value = button.dataset.source;
140
- button.textContent = t("jobs.categoryShort." + value);
141
- button.title = t("jobs.category." + value);
142
- button.setAttribute("aria-checked", String(value === current));
143
- });
144
- }
145
-
146
121
  // Redraw the list in place, keeping its scroll position.
147
122
  function renderTaskList(deps) {
148
123
  const scroll = deps.el.tasks.scrollTop;
@@ -175,9 +150,6 @@ function catalogQuery(state) {
175
150
  if (state.filter !== "all") query.set("status", state.filter);
176
151
  if (state.query.trim()) query.set("search", state.query.trim());
177
152
  if (state.attentionFilter) query.set("attention", state.attentionFilter);
178
- // Search and attention remain cross-category discovery; the server owns filtering.
179
- if (!state.query.trim() && !state.attentionFilter && state.source !== "all")
180
- query.set("source", state.source || "ordinary");
181
153
  if (state.catalogCursor) query.set("cursor", state.catalogCursor);
182
154
  return query.toString();
183
155
  }
@@ -233,7 +205,6 @@ function applyFilters(deps, params) {
233
205
  state.query = params.get("q") || "";
234
206
  state.attentionFilter = ATTENTION_KINDS.includes(params.get("attention")) ? params.get("attention") : null;
235
207
  state.catalogAll = params.get("all") === "true";
236
- state.source = ["ordinary", "job", "all"].includes(params.get("source")) ? params.get("source") : "ordinary";
237
208
  deps.el.search.value = state.query;
238
209
  }
239
210