@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.
- package/dist/cli/commandCatalog.js +2 -1
- package/dist/commands/controllerCommands.js +13 -4
- package/dist/commands/durableJobCommands.js +50 -3
- package/dist/commands/globalRoleCommands.js +18 -12
- package/dist/commands/inboxCommands.js +1 -0
- package/dist/commands/operatorCommands.js +10 -2
- package/dist/controlPlaneCli.js +21 -4
- package/dist/controller/clientRuntime.js +34 -13
- package/dist/controller/controller.js +35 -22
- package/dist/controller/diagnosticJournal.js +214 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +139 -101
- package/dist/controller/globalInputDelivery.js +6 -2
- package/dist/controller/globalRuntimeAttention.js +11 -3
- package/dist/controller/jobControl.js +1 -1
- package/dist/controller/jobDefinitionControl.js +24 -13
- package/dist/controller/jobSupervisor.js +4 -0
- package/dist/controller/runtime.js +66 -4
- package/dist/controller/workerSchedulerStore.js +24 -0
- package/dist/core/boundedRpc.js +4 -0
- package/dist/core/controllerServer.js +1 -1
- package/dist/core/controllerTelemetry.js +92 -19
- package/dist/job/globalShell.js +51 -0
- package/dist/job/jobBusinessResult.js +4 -3
- package/dist/job/jobDefinition.js +6 -1
- package/dist/job/jobRunner.js +9 -0
- package/dist/job/scheduleService.js +2 -0
- package/dist/message/globalInboxRecovery.js +30 -19
- package/dist/message/globalProviderRetry.js +7 -1
- package/dist/runtime/builtinAgentErrorMappers.js +4 -0
- package/dist/runtime/globalSessionHealth.js +39 -0
- package/dist/scheduler/roleRunStall.js +10 -10
- package/dist/storage/persistenceWorker.js +41 -19
- package/dist/storage/sqliteSchema.js +35 -11
- package/dist/storage/sqliteStore.js +86 -8
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +25 -11
- package/dist/storage/taskReadRevisionSchema.js +39 -0
- package/dist/web/assets/client/app/catalog.js +0 -29
- package/dist/web/assets/client/app/jobs.js +115 -0
- package/dist/web/assets/client/app/main.js +17 -6
- package/dist/web/assets/client/app/manage.js +1 -0
- package/dist/web/assets/client/app/settings.js +1 -5
- package/dist/web/assets/client/i18n/en.js +203 -18
- package/dist/web/assets/client/i18n/zh-CN.js +202 -17
- package/dist/web/assets/client/index.js +2 -0
- package/dist/web/assets/client/layout/manageShell.js +61 -15
- package/dist/web/assets/client/layout/router.js +2 -1
- package/dist/web/assets/client/lib/prefs.js +1 -1
- package/dist/web/assets/client/views/jobs.js +1042 -160
- package/dist/web/assets/client/views/sidebar.js +3 -0
- package/dist/web/assets/shared/manageNav.js +3 -1
- package/dist/web/assets/shell/appRail.js +2 -1
- package/dist/web/assets/shell/manage.js +7 -2
- package/dist/web/assets/shell/models.js +4 -3
- package/dist/web/assets/shell/settingsContent.js +0 -3
- package/dist/web/assets/shell/sidebar.js +0 -1
- package/dist/web/assets/styles/views/jobs.js +301 -12
- package/dist/web/assets/styles/views/sidebar.js +1 -2
- package/dist/web/webServer.js +28 -1
- package/docs/global-shell-jobs.md +78 -0
- package/docs/job-runs.md +6 -3
- package/docs/managed-turn-and-session-runtime.md +74 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +51 -0
- package/docs/release-workflow.md +43 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/storage-baseline.md +14 -1
- package/docs/storage-baseline.zh-CN.md +6 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- 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("
|
|
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("
|
|
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
|
|
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:
|
|
210
|
+
trigger: structuredClone(trigger), action: shell ? "shell.exec" : config.action,
|
|
202
211
|
...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
|
|
203
|
-
actionInput: structuredClone(
|
|
204
|
-
...(
|
|
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 =
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/boundedRpc.js
CHANGED
|
@@ -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
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
157
|
+
const oldest = this.#inFlightStarted.size === 0
|
|
91
158
|
? null
|
|
92
|
-
: Math.max(0, this.#clock() - this.#inFlightStarted
|
|
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.#
|
|
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: "
|
|
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");
|
package/dist/job/jobRunner.js
CHANGED
|
@@ -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.");
|