@zq-silk/yui 2.17.0 → 2.18.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 (70) hide show
  1. package/dist/cli/commandCatalog.js +2 -1
  2. package/dist/commands/controllerCommands.js +13 -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 +35 -22
  10. package/dist/controller/diagnosticJournal.js +214 -0
  11. package/dist/controller/fileSchedulerStoreAdapter.js +139 -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/runtime.js +66 -4
  18. package/dist/controller/workerSchedulerStore.js +24 -0
  19. package/dist/core/boundedRpc.js +4 -0
  20. package/dist/core/controllerServer.js +1 -1
  21. package/dist/core/controllerTelemetry.js +92 -19
  22. package/dist/job/globalShell.js +51 -0
  23. package/dist/job/jobBusinessResult.js +4 -3
  24. package/dist/job/jobDefinition.js +6 -1
  25. package/dist/job/jobRunner.js +9 -0
  26. package/dist/job/scheduleService.js +2 -0
  27. package/dist/message/globalInboxRecovery.js +30 -19
  28. package/dist/message/globalProviderRetry.js +7 -1
  29. package/dist/runtime/builtinAgentErrorMappers.js +4 -0
  30. package/dist/runtime/globalSessionHealth.js +39 -0
  31. package/dist/scheduler/roleRunStall.js +10 -10
  32. package/dist/storage/persistenceWorker.js +41 -19
  33. package/dist/storage/sqliteSchema.js +35 -11
  34. package/dist/storage/sqliteStore.js +86 -8
  35. package/dist/storage/storageVersions.js +1 -1
  36. package/dist/storage/storeRpc.js +25 -11
  37. package/dist/storage/taskReadRevisionSchema.js +39 -0
  38. package/dist/web/assets/client/app/catalog.js +0 -29
  39. package/dist/web/assets/client/app/jobs.js +115 -0
  40. package/dist/web/assets/client/app/main.js +17 -6
  41. package/dist/web/assets/client/app/manage.js +1 -0
  42. package/dist/web/assets/client/app/settings.js +1 -5
  43. package/dist/web/assets/client/i18n/en.js +203 -18
  44. package/dist/web/assets/client/i18n/zh-CN.js +202 -17
  45. package/dist/web/assets/client/index.js +2 -0
  46. package/dist/web/assets/client/layout/manageShell.js +61 -15
  47. package/dist/web/assets/client/layout/router.js +2 -1
  48. package/dist/web/assets/client/lib/prefs.js +1 -1
  49. package/dist/web/assets/client/views/jobs.js +1042 -160
  50. package/dist/web/assets/client/views/sidebar.js +3 -0
  51. package/dist/web/assets/shared/manageNav.js +3 -1
  52. package/dist/web/assets/shell/appRail.js +2 -1
  53. package/dist/web/assets/shell/manage.js +7 -2
  54. package/dist/web/assets/shell/models.js +4 -3
  55. package/dist/web/assets/shell/settingsContent.js +0 -3
  56. package/dist/web/assets/shell/sidebar.js +0 -1
  57. package/dist/web/assets/styles/views/jobs.js +301 -12
  58. package/dist/web/assets/styles/views/sidebar.js +1 -2
  59. package/dist/web/webServer.js +28 -1
  60. package/docs/global-shell-jobs.md +78 -0
  61. package/docs/job-runs.md +6 -3
  62. package/docs/managed-turn-and-session-runtime.md +74 -0
  63. package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -0
  64. package/docs/release-workflow.md +43 -0
  65. package/docs/release-workflow.zh-CN.md +29 -0
  66. package/docs/storage-baseline.md +14 -1
  67. package/docs/storage-baseline.zh-CN.md +6 -1
  68. package/docs/testing/verification-levels.md +2 -2
  69. package/docs/testing/verification-levels.zh-CN.md +2 -2
  70. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { requireKnownFields } from "../domain/validation.js";
5
5
  import { createJobDefinition, reviseJobDefinition, jobScopeKey, validateJobDefinition } from "../job/jobDefinition.js";
6
6
  import { acknowledgeUnknownDurableJob, requestDurableJobCancel, validateDurableJob, JOB_RUNNER_IMPLEMENTATION, executionNamespace } from "../job/durableJob.js";
7
7
  import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
8
+ import { prepareGlobalShell } from "../job/globalShell.js";
8
9
  import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
9
10
  import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
10
11
  import { createFileArtifactPort } from "./jobSupervisor.js";
@@ -38,7 +39,7 @@ export function authorizeScope(store, scope, caller, allowPublic = false) {
38
39
  assertCallerAuthorized(store, caller, scope.kind === "task" ? scope.taskId : "");
39
40
  }
40
41
  function taskParams(job, caller, requestId) {
41
- if (job.scope.kind !== "task" || job.configuration.kind !== "shell")
42
+ if (job.scope.kind !== "task" || job.configuration.kind !== "shell" || !("owner" in job.configuration))
42
43
  throw new Error("Not a Task Job.");
43
44
  return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
44
45
  }
@@ -60,8 +61,13 @@ export function authorizeDefinition(store, job, caller, write) {
60
61
  assertNonSecretJobInput(params);
61
62
  }
62
63
  else if (write) {
64
+ if (job.configuration.kind === "shell") {
65
+ prepareGlobalShell(job.configuration, "/job-validation");
66
+ assertNonSecretJobInput({ ...job.configuration, requestId: "global-shell-definition" });
67
+ return;
68
+ }
63
69
  if (job.configuration.kind !== "builtin")
64
- throw new Error("Global Jobs require builtin actions.");
70
+ throw new Error("Not a global process Job.");
65
71
  prepareBuiltinJob(job.configuration, "/");
66
72
  if (job.configuration.action === "project.refresh") {
67
73
  if (job.builtin)
@@ -190,18 +196,21 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
190
196
  }, now);
191
197
  return { run: result.job, created: result.created };
192
198
  }
193
- if (job.configuration.kind !== "builtin")
194
- throw new Error("Global Jobs require builtin actions.");
199
+ if (job.configuration.kind !== "builtin" && job.configuration.kind !== "shell")
200
+ throw new Error("Not a global process Job.");
195
201
  const id = `job-${randomUUID()}`;
196
202
  const locator = `artifacts/jobs/global/${id}`;
197
- const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
203
+ const config = job.configuration;
204
+ const shell = config.kind === "shell";
205
+ const spec = shell ? prepareGlobalShell(config, join(home, locator, "workspace"))
206
+ : prepareBuiltinJob(config, join(home, locator), { home, runId: id });
198
207
  const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
199
208
  let run = {
200
209
  schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
201
- trigger: structuredClone(trigger), action: job.configuration.action,
210
+ trigger: structuredClone(trigger), action: shell ? "shell.exec" : config.action,
202
211
  ...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
203
- actionInput: structuredClone(job.configuration.input),
204
- ...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
212
+ actionInput: structuredClone(shell ? config : config.input),
213
+ ...(!shell && config.projectId !== undefined ? { projectId: config.projectId } : {}),
205
214
  ...spec, status: "queued", artifactsLocator: locator,
206
215
  idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
207
216
  operation: {
@@ -280,10 +289,12 @@ export function authorizeGlobalJobStart(store, run) {
280
289
  const projects = run.action === "project.refresh" ? store.listProjects() : [];
281
290
  const home = store.rootDirectory();
282
291
  try {
283
- const compiled = prepareBuiltinJob({
284
- kind: "builtin", action: run.action, input: run.actionInput,
285
- ...(run.projectId === undefined ? {} : { projectId: run.projectId })
286
- }, run.workspace, { home, runId: run.id });
292
+ const compiled = run.action === "shell.exec"
293
+ ? prepareGlobalShell(run.actionInput, join(home, "artifacts/jobs/global", run.id, "workspace"))
294
+ : prepareBuiltinJob({
295
+ kind: "builtin", action: run.action, input: run.actionInput,
296
+ ...(run.projectId === undefined ? {} : { projectId: run.projectId })
297
+ }, run.workspace, { home, runId: run.id });
287
298
  if (run.action === "project.refresh") {
288
299
  const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
289
300
  if (definition?.builtin)
@@ -297,7 +308,7 @@ export function authorizeGlobalJobStart(store, run) {
297
308
  throw new Error("Project refresh Run workspace does not match its artifact directory.");
298
309
  }
299
310
  }
300
- if (JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
311
+ if (compiled.workspace !== run.workspace || JSON.stringify(compiled.steps) !== JSON.stringify(run.steps)
301
312
  || JSON.stringify(compiled.env) !== JSON.stringify(run.env) || compiled.head !== run.head) {
302
313
  throw new Error("Global JobRun does not match its controlled action.");
303
314
  }
@@ -199,6 +199,8 @@ export class DurableJobSupervisor {
199
199
  return;
200
200
  }
201
201
  const spec = {
202
+ ...(!("taskId" in job) && "action" in job && job.action === "shell.exec"
203
+ ? { files: job.actionInput.files } : {}),
202
204
  jobId: job.id,
203
205
  ...("taskId" in job ? { taskId: job.taskId } : { scope: "global" }),
204
206
  workspace: job.workspace,
@@ -386,6 +388,8 @@ export class DurableJobSupervisor {
386
388
  return fallback;
387
389
  if (job.action === "system.noop")
388
390
  return { ...fallback, data: { action: "system.noop" } };
391
+ if (job.action === "shell.exec")
392
+ return fallback;
389
393
  const raw = this.#artifacts.readBusinessResult?.(executionNamespace(job), job.id);
390
394
  if (raw !== null && raw !== undefined) {
391
395
  try {
@@ -1,4 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { ControllerDiagnosticJournal } from "./diagnosticJournal.js";
3
+ import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
2
4
  import { prepareNativeHostSteer } from "./nativeHostInput.js";
3
5
  import { archiveLeaderTask } from "../task/leaderArchive.js";
4
6
  import { TaskWorkspaceCoordinator } from "../repository/taskWorkspaceCoordinator.js";
@@ -61,7 +63,6 @@ import { authorizeJobStart } from "./jobControl.js";
61
63
  import { authorizeGlobalJobStart, createJobDefinitionControl } from "./jobDefinitionControl.js";
62
64
  import { reconcileTaskDelegation } from "./jobTaskDelegationControl.js";
63
65
  import { createScheduleControl, reconcileSchedules } from "./scheduleControl.js";
64
- import { reconcileProjectSync } from "../job/projectSync.js";
65
66
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
66
67
  import { createProviderRetryHooks } from "./providerRetryDelivery.js";
67
68
  import { createRuntimeResourceActivityTracker } from "./resourceInventory.js";
@@ -72,8 +73,54 @@ import { AsyncRuntimeEventProcessor, createAsyncRuntimeObserver, FileRuntimeEven
72
73
  import { RuntimeLaunchCoordinator } from "./runtimeLaunchCoordinator.js";
73
74
  import { SessionOwnerReconciliation } from "./sessionOwnerReconciliation.js";
74
75
  import { readTaskAgentCapabilities } from "./agentCapabilities.js";
76
+ import { workerSchedulerStore } from "./workerSchedulerStore.js";
77
+ import { deserializeError } from "../core/boundedRpc.js";
75
78
  /** Production composition root for the current SQLite TaskStore + tmux Controller. */
76
79
  export async function startFileTaskControllerRuntime(home, options = {}) {
80
+ const diagnostics = new ControllerDiagnosticJournal(home);
81
+ let timer;
82
+ const closeDiagnostics = async () => {
83
+ if (timer !== undefined)
84
+ clearInterval(timer);
85
+ await diagnostics.close();
86
+ };
87
+ try {
88
+ const running = await createFileTaskControllerRuntime(home, {
89
+ ...options,
90
+ diagnosticMetrics: () => diagnostics.metrics(),
91
+ onError: error => { diagnostics.error(error); options.onError?.(error); }
92
+ });
93
+ let expectedAt = monotonicMilliseconds() + 250;
94
+ timer = setInterval(() => {
95
+ const now = monotonicMilliseconds();
96
+ const lagMs = Math.max(0, now - expectedAt);
97
+ expectedAt = now + 250;
98
+ diagnostics.observe({
99
+ ...running.runtime.runtimeMetrics(),
100
+ commands: {
101
+ routes: running.server.commandObserver.snapshot(),
102
+ eventLoopDelay: running.server.eventLoopDelay.snapshot()
103
+ }
104
+ }, lagMs);
105
+ }, 250);
106
+ timer.unref();
107
+ const closed = running.closed.finally(closeDiagnostics);
108
+ return { ...running, closed, close: async () => {
109
+ try {
110
+ await running.close();
111
+ }
112
+ finally {
113
+ await closeDiagnostics();
114
+ }
115
+ } };
116
+ }
117
+ catch (error) {
118
+ diagnostics.error(error);
119
+ await closeDiagnostics();
120
+ throw error;
121
+ }
122
+ }
123
+ async function createFileTaskControllerRuntime(home, options) {
77
124
  // The current Home always uses SQLite. The persistence worker is enabled by
78
125
  // default; YUI_STORE_WORKER=0/false keeps the same database in-process.
79
126
  const useWorker = resolveStoreWorkerEnabledForHome(home, options.environment ?? process.env);
@@ -111,6 +158,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
111
158
  closeTelemetry = () => telemetry?.sink.close() ?? Promise.resolve();
112
159
  const schedulerStore = options.schedulerStore
113
160
  ?? new FileSchedulerStoreAdapter(store, telemetry);
161
+ // Injected stores/adapters retain their owner and semantics. Only the
162
+ // production SQLite adapter shares the worker's exact Home and behavior.
163
+ const workerMaintenance = asyncStoreClient !== undefined
164
+ && options.store === undefined && options.schedulerStore === undefined;
114
165
  const domainIdentity = options.domainIdentity
115
166
  ?? ephemeralDomainFromEnvironment(options.environment ?? process.env);
116
167
  const planner = options.planner ?? new FileRoleLaunchPlanner(home, store, {
@@ -532,7 +583,9 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
532
583
  const continuationReconciler = options.continuationMetadata === undefined
533
584
  ? undefined
534
585
  : new ProviderContinuationReconciliationService(store, schedulerStore, options.continuationMetadata, options.onError);
535
- const running = await startFileTaskController(home, schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
586
+ const running = await startFileTaskController(home, workerMaintenance
587
+ ? workerSchedulerStore(schedulerStore, asyncStoreClient)
588
+ : schedulerStore, delivery, (method, params) => method === "task.role-capabilities"
536
589
  ? readTaskAgentCapabilities(store, catalogs, params)
537
590
  : method === "web.start" || method === "web.stop" || method === "web.status"
538
591
  ? web.dispatch(method, params) : lifecycleDispatcher(method, params), {
@@ -551,6 +604,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
551
604
  ?? resolveRuntimeHealth(durableConfig.runtimeHealth).diagnosticAfterMs,
552
605
  now: options.now,
553
606
  onError: options.onError,
607
+ diagnosticMetrics: options.diagnosticMetrics,
554
608
  lifecycleHost,
555
609
  providerRetry: createProviderRetryHooks(home, store, {
556
610
  now: options.now,
@@ -581,8 +635,12 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
581
635
  jobDefinitions: createJobDefinitionControl(store, home),
582
636
  schedules: {
583
637
  control: createScheduleControl(store, home),
584
- reconcile: now => {
585
- reconcileProjectSync(store, now, options.onError);
638
+ reconcile: async (now) => {
639
+ const errors = workerMaintenance
640
+ ? await asyncStoreClient.invokeObserver("reconcileProjectSync", [now])
641
+ : schedulerStore.reconcileProjectSync(now);
642
+ for (const error of errors)
643
+ options.onError?.(deserializeError(error));
586
644
  reconcileSchedules(store, home, now, options.onError);
587
645
  }
588
646
  },
@@ -600,6 +658,10 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
600
658
  ?? (useWorker && asyncStoreClient !== undefined
601
659
  ? new AsyncRuntimeEventProcessor(runtimeEventInbox, createAsyncRuntimeObserver((method, args) => asyncStoreClient.invokeObserver(method, args)))
602
660
  : new FileRuntimeEventProcessor(runtimeEventInbox, schedulerStore)),
661
+ persistenceMetrics: () => ({
662
+ worker: asyncStoreClient?.observationMetrics() ?? null,
663
+ database: store instanceof SqliteTaskStore ? store.storageObservationMetrics() : null
664
+ }),
603
665
  runtimeObserver: options.runtimeObserver
604
666
  ?? new AgentRuntimeObserver(store, runtimeEventInbox),
605
667
  domainIdentity,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Keep maintenance read/check/write closures together on the persistence
3
+ * worker. These operations recheck their exact durable condition in one
4
+ * transaction and are idempotent under the worker's existing replay contract.
5
+ * Do not add admission/claim operations here: their result and unknown-effect
6
+ * contracts need a separate audit before moving across this boundary.
7
+ */
8
+ const WORKER_MAINTENANCE = new Set([
9
+ "resolveExpiredInputRecommendations",
10
+ "recordRoleRunProgress",
11
+ "recordRoleRunDiagnostic",
12
+ "recordRoleRunStall"
13
+ ]);
14
+ export function workerSchedulerStore(store, client) {
15
+ return new Proxy(store, {
16
+ get(target, property) {
17
+ if (WORKER_MAINTENANCE.has(property)) {
18
+ return (...args) => client.invokeObserver(String(property), args);
19
+ }
20
+ const value = Reflect.get(target, property);
21
+ return typeof value === "function" ? value.bind(target) : value;
22
+ }
23
+ });
24
+ }
@@ -48,6 +48,10 @@ export function serializeError(error) {
48
48
  export function deserializeError(serialized) {
49
49
  const error = new Error(serialized.message);
50
50
  error.name = serialized.name;
51
+ if (serialized.code !== undefined)
52
+ Object.assign(error, { code: serialized.code });
53
+ if (serialized.stack !== undefined)
54
+ error.stack = serialized.stack;
51
55
  return error;
52
56
  }
53
57
  // -- Bounded slot pool (backpressure) ----------------------------------------
@@ -311,7 +311,7 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
311
311
  // observation completes when the response is handed to the socket, so a
312
312
  // later status snapshot sees prior built-in completions and the dispatcher
313
313
  // service-time metric stays a distinct observation.
314
- const observation = telemetry.commandObserver.start();
314
+ const observation = telemetry.commandObserver.start(request.method, request.id);
315
315
  const send = (response, onFlushed) => {
316
316
  observation.complete(isBuiltinControllerMethod(request.method) ? "builtin" : "dispatched", response.ok ? "success" : "failure");
317
317
  sendResponse(socket, response, onFlushed);
@@ -1,13 +1,69 @@
1
- /**
2
- * Pure-memory, fixed-space command observation for the Controller socket.
3
- *
4
- * The core server owns route/built-in command observation: every authenticated
5
- * request is counted once at the routing layer, and a bounded unrefed scheduler
6
- * samples event-loop delay so an already-written request's pre-dispatch wait is
7
- * observable even when the loop is saturated by scheduler projections. The
8
- * FileTaskController composes these snapshots into its runtime status metric;
9
- * nothing here is persisted and every snapshot is O(1).
10
- */
1
+ import { createHash } from "node:crypto";
2
+ /** Fixed-size retained diagnostics; live bookkeeping follows in-flight work.
3
+ * Not a log or scheduling authority. No params,
4
+ * prompts, paths, failure text or credentials are retained. Target/request
5
+ * hashes correlate an observation without publishing arbitrary input. */
6
+ export class ControllerWorkObserver {
7
+ clock;
8
+ #active = new Map();
9
+ #recent = [];
10
+ #completed = 0;
11
+ #maximumMs = 0;
12
+ #slowest = null;
13
+ constructor(clock = monotonicMilliseconds) {
14
+ this.clock = clock;
15
+ }
16
+ start(phase, target) {
17
+ const token = {};
18
+ const entry = { phase, target: target === undefined ? null
19
+ : createHash("sha256").update(target).digest("hex").slice(0, 16), startedAt: this.clock() };
20
+ this.#active.set(token, entry);
21
+ return (failed = false) => {
22
+ if (!this.#active.delete(token))
23
+ return;
24
+ const elapsedMs = Math.max(0, this.clock() - entry.startedAt);
25
+ this.#completed++;
26
+ this.#maximumMs = Math.max(this.#maximumMs, elapsedMs);
27
+ const completed = { phase, target: entry.target, startedAtMs: entry.startedAt, elapsedMs, failed };
28
+ if (this.#slowest === null || elapsedMs > this.#slowest.elapsedMs)
29
+ this.#slowest = completed;
30
+ this.#recent.push(completed);
31
+ if (this.#recent.length > 16)
32
+ this.#recent.shift();
33
+ };
34
+ }
35
+ async observe(phase, target, action) {
36
+ const finish = this.start(phase, target);
37
+ try {
38
+ const value = await action();
39
+ finish();
40
+ return value;
41
+ }
42
+ catch (error) {
43
+ finish(true);
44
+ throw error;
45
+ }
46
+ }
47
+ snapshot() {
48
+ const active = [];
49
+ for (const entry of this.#active.values()) {
50
+ active.push({ phase: entry.phase, target: entry.target, startedAtMs: entry.startedAt,
51
+ ageMs: Math.max(0, this.clock() - entry.startedAt) });
52
+ if (active.length === 16)
53
+ break;
54
+ }
55
+ return {
56
+ completed: this.#completed, maximumMs: this.#maximumMs, inFlight: this.#active.size,
57
+ active, slowest: this.#slowest,
58
+ recent: [...this.#recent]
59
+ };
60
+ }
61
+ }
62
+ /** Small worker-response observation; no historical arrays on the RPC wire. */
63
+ export function compactWorkObservation(observer) {
64
+ const { completed, maximumMs, slowest, recent } = observer.snapshot();
65
+ return { completed, maximumMs, slowest, latest: recent.at(-1) ?? null };
66
+ }
11
67
  const CONTROLLER_DELAY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
12
68
  const DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS = 50;
13
69
  const BUILTIN_METHODS = new Set([
@@ -19,6 +75,12 @@ const BUILTIN_METHODS = new Set([
19
75
  "controller.rollback-handover",
20
76
  "controller.handover-state"
21
77
  ]);
78
+ const OBSERVED_METHODS = new Set([...BUILTIN_METHODS,
79
+ "scheduler.signal", "scheduler.scan", "scheduler.configure",
80
+ "runtime.ensure-role-session", "runtime.stop-role-session",
81
+ "capability.search", "capability.describe", "capability.call",
82
+ "schedule.control", "job.definition", "job.start", "job.get", "job.cancel", "job.acknowledge"
83
+ ]);
22
84
  export function isBuiltinControllerMethod(method) {
23
85
  return BUILTIN_METHODS.has(method);
24
86
  }
@@ -47,19 +109,23 @@ export class ControllerCommandObserver {
47
109
  #completed = 0;
48
110
  #failed = 0;
49
111
  #inFlight = 0;
50
- /** Monotonic arrival timestamps of in-flight observations (FIFO). */
51
- #inFlightStarted = [];
112
+ /** Insertion order preserves arrival order; completion removes its own token. */
113
+ #inFlightStarted = new Map();
52
114
  #builtinCompleted = 0;
53
115
  #builtinFailed = 0;
54
116
  #dispatchedCompleted = 0;
55
117
  #dispatchedFailed = 0;
118
+ #observations;
56
119
  constructor(clock = monotonicMilliseconds) {
57
120
  this.#clock = clock;
121
+ this.#observations = new ControllerWorkObserver(clock);
58
122
  }
59
- start() {
123
+ start(method = "unknown", requestId) {
60
124
  this.#received += 1;
61
125
  this.#inFlight += 1;
62
- this.#inFlightStarted.push(this.#clock());
126
+ const token = {};
127
+ this.#inFlightStarted.set(token, this.#clock());
128
+ const finish = this.#observations.start(OBSERVED_METHODS.has(method) ? method : "other", requestId);
63
129
  let completed = false;
64
130
  return {
65
131
  complete: (kind, outcome) => {
@@ -67,7 +133,8 @@ export class ControllerCommandObserver {
67
133
  return;
68
134
  completed = true;
69
135
  this.#inFlight = Math.max(0, this.#inFlight - 1);
70
- this.#inFlightStarted.shift();
136
+ this.#inFlightStarted.delete(token);
137
+ finish(outcome === "failure");
71
138
  this.#completed += 1;
72
139
  if (outcome === "failure")
73
140
  this.#failed += 1;
@@ -87,9 +154,9 @@ export class ControllerCommandObserver {
87
154
  };
88
155
  }
89
156
  snapshot() {
90
- const oldest = this.#inFlightStarted.length === 0
157
+ const oldest = this.#inFlightStarted.size === 0
91
158
  ? null
92
- : Math.max(0, this.#clock() - this.#inFlightStarted[0]);
159
+ : Math.max(0, this.#clock() - this.#inFlightStarted.values().next().value);
93
160
  return {
94
161
  received: this.#received,
95
162
  completed: this.#completed,
@@ -103,7 +170,8 @@ export class ControllerCommandObserver {
103
170
  dispatched: {
104
171
  completed: this.#dispatchedCompleted,
105
172
  failed: this.#dispatchedFailed
106
- }
173
+ },
174
+ observations: this.#observations.snapshot()
107
175
  };
108
176
  }
109
177
  }
@@ -122,6 +190,7 @@ export class ControllerEventLoopDelay {
122
190
  #expectedAt = 0;
123
191
  #samples = 0;
124
192
  #maximumLagMs = 0;
193
+ #maximumObservedAtMs = null;
125
194
  #buckets = new Map(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [threshold, 0]));
126
195
  #stopped = false;
127
196
  constructor(clock = monotonicMilliseconds, scheduler = productionTelemetryScheduler, intervalMs = DEFAULT_EVENT_LOOP_DELAY_INTERVAL_MS) {
@@ -142,7 +211,10 @@ export class ControllerEventLoopDelay {
142
211
  this.#expectedAt = this.#clock() + this.#intervalMs;
143
212
  this.#samples += 1;
144
213
  const bounded = Math.ceil(lag);
145
- this.#maximumLagMs = Math.max(this.#maximumLagMs, bounded);
214
+ if (this.#maximumObservedAtMs === null || bounded > this.#maximumLagMs) {
215
+ this.#maximumLagMs = bounded;
216
+ this.#maximumObservedAtMs = this.#clock();
217
+ }
146
218
  for (const threshold of CONTROLLER_DELAY_BUCKETS_MS) {
147
219
  if (bounded <= threshold) {
148
220
  this.#buckets.set(threshold, (this.#buckets.get(threshold) ?? 0) + 1);
@@ -158,6 +230,7 @@ export class ControllerEventLoopDelay {
158
230
  return {
159
231
  samples: this.#samples,
160
232
  maximumLagMs: this.#maximumLagMs,
233
+ maximumObservedAtMs: this.#maximumObservedAtMs,
161
234
  lagBuckets: Object.fromEntries(CONTROLLER_DELAY_BUCKETS_MS.map((threshold) => [
162
235
  `le${threshold}ms`,
163
236
  this.#buckets.get(threshold) ?? 0
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { requireKnownFields } from "../domain/validation.js";
5
+ import { validateJobExecutionConfiguration } from "./durableJob.js";
6
+ export function validateJobScriptFiles(files) {
7
+ if (!Array.isArray(files) || files.length > 64)
8
+ throw new Error("Job supports at most 64 script files.");
9
+ const paths = new Set();
10
+ let bytes = 0;
11
+ for (const file of files) {
12
+ requireKnownFields(file, ["path", "content"], "Job script file");
13
+ if (typeof file.path !== "string" || file.path.length > 240
14
+ || !/^[a-zA-Z0-9_-][a-zA-Z0-9._/-]*$/u.test(file.path)
15
+ || file.path.split("/").some((part) => !part || part === "." || part === "..")) {
16
+ throw new Error("Script paths must be safe relative paths, for example scripts/check.sh.");
17
+ }
18
+ if (paths.has(file.path) || [...paths].some(path => path.startsWith(file.path + "/") || file.path.startsWith(path + "/"))) {
19
+ throw new Error("Script paths must be unique and cannot overlap directories.");
20
+ }
21
+ if (typeof file.content !== "string" || file.content.includes("\0"))
22
+ throw new Error("Scripts must be UTF-8 text without NUL.");
23
+ bytes += Buffer.byteLength(file.content);
24
+ if (bytes > 512 * 1024)
25
+ throw new Error("Job scripts exceed 512 KiB.");
26
+ paths.add(file.path);
27
+ }
28
+ }
29
+ export function prepareGlobalShell(configuration, workspace) {
30
+ requireKnownFields(configuration, ["kind", "env", "steps", "files"], "Global Shell Job");
31
+ if (configuration.kind !== "shell")
32
+ throw new Error("Not a Shell Job.");
33
+ validateJobScriptFiles(configuration.files);
34
+ // No repository applies. This hash identifies executable configuration,
35
+ // never a Project commit or an Integration candidate.
36
+ const head = createHash("sha1").update(JSON.stringify(configuration)).digest("hex");
37
+ const spec = { workspace, env: configuration.env, steps: configuration.steps, head };
38
+ validateJobExecutionConfiguration(spec);
39
+ return spec;
40
+ }
41
+ /** A fresh per-Run directory; never overwrite another run's files or follow
42
+ * a pre-existing workspace. The runner's exclusive ready receipt fences replay. */
43
+ export function materializeJobScripts(workspace, files) {
44
+ validateJobScriptFiles(files);
45
+ mkdirSync(workspace, { mode: 0o700 });
46
+ for (const file of files) {
47
+ const path = join(workspace, file.path);
48
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
49
+ writeFileSync(path, file.content, { flag: "wx", mode: 0o700 });
50
+ }
51
+ }
@@ -62,9 +62,10 @@ export function jobRunSnapshot(run) {
62
62
  ...runReference(run), definitionId: run.definitionId, definitionRevision: run.definitionRevision,
63
63
  owner: "owner" in run ? run.owner : null,
64
64
  trigger: run.trigger, retryOf: run.retryOf ?? null,
65
- executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" } : {
66
- kind: "builtin", action: run.action, input: run.actionInput
67
- },
65
+ executor: run.delegation ? { kind: "task", delegation: run.delegation } : "taskId" in run ? { kind: "shell" }
66
+ : run.action === "shell.exec" ? { kind: "shell", configuration: run.actionInput } : {
67
+ kind: "builtin", action: run.action, input: run.actionInput
68
+ },
68
69
  target: run.delegation ? { taskId: run.delegation.taskId, workItemId: run.delegation.workItemId ?? null }
69
70
  : { projectId: run.projectId ?? null, workspace: run.workspace, head: run.head },
70
71
  execution: run.delegation ? null : { env: run.env, steps: run.steps, implementation: run.operation.implementation }
@@ -2,6 +2,7 @@ import { requireIdentity, requireKnownFields, requirePositiveInteger, requireTex
2
2
  import { validateJobExecutionConfiguration, validateDurableJobOwner } from "./durableJob.js";
3
3
  import { validateAgentJobConfiguration } from "./agentJob.js";
4
4
  import { validateTaskJobConfiguration } from "./taskDelegation.js";
5
+ import { prepareGlobalShell } from "./globalShell.js";
5
6
  export function jobScopeKey(scope) {
6
7
  if (scope.kind === "global")
7
8
  return "global";
@@ -44,6 +45,10 @@ export function validateJobDefinition(job) {
44
45
  validateTaskJobConfiguration(config);
45
46
  }
46
47
  else if (job.scope.kind === "global") {
48
+ if (config.kind === "shell") {
49
+ prepareGlobalShell(config, "/job-validation");
50
+ return job;
51
+ }
47
52
  if (config.kind !== "builtin")
48
53
  throw new Error("Global Jobs require a controlled builtin action.");
49
54
  requireKnownFields(config, ["kind", "action", "projectId", "input"], "Builtin Job");
@@ -54,7 +59,7 @@ export function validateJobDefinition(job) {
54
59
  throw new Error("Builtin Job input is required.");
55
60
  }
56
61
  else {
57
- if (config.kind !== "shell")
62
+ if (config.kind !== "shell" || !("owner" in config))
58
63
  throw new Error("Task Jobs require a managed shell configuration.");
59
64
  requireKnownFields(config, ["kind", "owner", "projectId", "head", "workspace", "env", "steps"], "Task Job");
60
65
  requireIdentity(config.projectId, "Job Project id");
@@ -13,6 +13,7 @@ import { join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { jobStepDirectory } from "./stepDirectory.js";
15
15
  import { prepareHttpPreview, isHttpPreview } from "./httpPreview.js";
16
+ import { materializeJobScripts, validateJobScriptFiles } from "./globalShell.js";
16
17
  import { writeTextFileAtomically } from "../storage/durableFile.js";
17
18
  import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
18
19
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
@@ -112,6 +113,8 @@ export async function runDurableJobRunner(specPath) {
112
113
  let failedStep;
113
114
  let preview;
114
115
  try {
116
+ if (spec.files !== undefined)
117
+ materializeJobScripts(spec.workspace, spec.files);
115
118
  for (let index = 0; index < spec.steps.length; index += 1) {
116
119
  if (cancelled || existsSync(cancelPath)) {
117
120
  cancelled = true;
@@ -278,6 +281,12 @@ function readSpec(specPath) {
278
281
  if (typeof spec.artifactDir !== "string" || !spec.artifactDir.startsWith("/")) {
279
282
  throw new Error("DurableJob spec artifactDir must be an absolute path.");
280
283
  }
284
+ if (spec.files !== undefined) {
285
+ if (spec.scope !== "global" || spec.workspace !== join(spec.artifactDir, "workspace")) {
286
+ throw new Error("Job script snapshots require their own global Run workspace.");
287
+ }
288
+ validateJobScriptFiles(spec.files);
289
+ }
281
290
  if (typeof spec.head !== "string" || spec.head.length === 0) {
282
291
  throw new Error("DurableJob spec is missing head.");
283
292
  }
@@ -4,6 +4,8 @@ import { requireProjectSyncTarget } from "./projectSync.js";
4
4
  /** Internal compiled registration, not a caller identity accepted by RPC. */
5
5
  const actions = new Set(["system.noop", "project.refresh"]);
6
6
  export function requireServiceTarget(job) {
7
+ if (job.scope.kind === "global" && job.configuration.kind === "shell")
8
+ return;
7
9
  if (job.scope.kind !== "global" || job.configuration.kind !== "builtin"
8
10
  || !actions.has(job.configuration.action))
9
11
  throw new Error("Schedule service is not registered for this scope/action.");