@zq-silk/yui 0.6.0 → 0.6.2
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side DurableJob control: the only path through which a DurableJob
|
|
3
|
+
* record is created or cancel-requested. The Controller socket layer calls
|
|
4
|
+
* this port for `job.*` requests; nothing else writes queued jobs.
|
|
5
|
+
*
|
|
6
|
+
* Creation is idempotent per (owner, project, head, steps, workspace, env):
|
|
7
|
+
* a repeated `job.start` with the same inputs returns the existing job with
|
|
8
|
+
* `created: false`, so a Leader retry can never spawn duplicate runners.
|
|
9
|
+
*/
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { resolve, sep } from "node:path";
|
|
13
|
+
import { acknowledgeUnknownDurableJob, createDurableJob, durableJobIdempotencyKey, isDurableJobTerminal, requestDurableJobCancel, retryDurableJobIdempotencyKey } from "../job/durableJob.js";
|
|
14
|
+
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
15
|
+
export function createDurableJobControl(store) {
|
|
16
|
+
return {
|
|
17
|
+
startJob(params, now) {
|
|
18
|
+
// rr4/finding-3: The entire create path — validation, idempotency
|
|
19
|
+
// lookup, id allocation, and save — must be one transaction. A gap
|
|
20
|
+
// between the idempotency check and the save lets a concurrent
|
|
21
|
+
// startJob with the same key create a duplicate job.
|
|
22
|
+
return store.transaction((tx) => {
|
|
23
|
+
validateStartParams(tx, params);
|
|
24
|
+
const baseKey = durableJobIdempotencyKey({
|
|
25
|
+
owner: params.owner,
|
|
26
|
+
projectId: params.projectId,
|
|
27
|
+
head: params.head,
|
|
28
|
+
steps: params.steps,
|
|
29
|
+
workspace: params.workspace,
|
|
30
|
+
env: params.env
|
|
31
|
+
});
|
|
32
|
+
const key = params.retryOf === undefined
|
|
33
|
+
? baseKey
|
|
34
|
+
: retryDurableJobIdempotencyKey(baseKey, params.retryOf);
|
|
35
|
+
const existing = tx.findDurableJobByIdempotencyKey(params.taskId, key);
|
|
36
|
+
if (existing !== null)
|
|
37
|
+
return { job: existing, created: false };
|
|
38
|
+
const id = tx.nextDurableJobId(params.taskId);
|
|
39
|
+
const job = createDurableJob({
|
|
40
|
+
id,
|
|
41
|
+
taskId: params.taskId,
|
|
42
|
+
owner: params.owner,
|
|
43
|
+
projectId: params.projectId,
|
|
44
|
+
head: params.head,
|
|
45
|
+
workspace: params.workspace,
|
|
46
|
+
env: params.env,
|
|
47
|
+
steps: params.steps,
|
|
48
|
+
artifactsLocator: `artifacts/jobs/${params.taskId}/${id}`,
|
|
49
|
+
...(params.retryOf === undefined ? {} : { retryOf: params.retryOf })
|
|
50
|
+
}, now);
|
|
51
|
+
tx.saveDurableJob(params.taskId, job);
|
|
52
|
+
return { job, created: true };
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
getJob(taskId, jobId) {
|
|
56
|
+
return store.getDurableJob(taskId, jobId);
|
|
57
|
+
},
|
|
58
|
+
cancelJob(taskId, jobId, now, caller) {
|
|
59
|
+
return store.transaction((tx) => {
|
|
60
|
+
const current = tx.getDurableJob(taskId, jobId);
|
|
61
|
+
if (current === null)
|
|
62
|
+
return null;
|
|
63
|
+
// rr8: Bind the cancel request to the caller's managed identity. The
|
|
64
|
+
// same rules as job.start apply, checked against the job's owner.
|
|
65
|
+
assertCallerAuthorized(tx, caller, current.owner, taskId);
|
|
66
|
+
const next = requestDurableJobCancel(current, now);
|
|
67
|
+
if (next !== current)
|
|
68
|
+
tx.saveDurableJob(taskId, next);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
acknowledgeJob(taskId, jobId, now, caller) {
|
|
73
|
+
return store.transaction((tx) => {
|
|
74
|
+
const current = tx.getDurableJob(taskId, jobId);
|
|
75
|
+
if (current === null)
|
|
76
|
+
return null;
|
|
77
|
+
// Acknowledge is a Leader-only recovery decision. Reuse the shared
|
|
78
|
+
// caller-key, active-Run, receipt, and Session checks, but explicitly
|
|
79
|
+
// require the Leader role; start/cancel's owner binding intentionally
|
|
80
|
+
// permits a Worker to operate its own Work Item.
|
|
81
|
+
assertCallerAuthorized(tx, caller, current.owner, taskId, { leaderOnly: true });
|
|
82
|
+
const next = acknowledgeUnknownDurableJob(current, now);
|
|
83
|
+
if (next !== current)
|
|
84
|
+
tx.saveDurableJob(taskId, next);
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Persisted-boundary validation for `job.start`. The owner must resolve to a
|
|
92
|
+
* live Task record, the Task must be active, the workspace must be the exact
|
|
93
|
+
* managed workspace for that owner with write access to the Project, and the
|
|
94
|
+
* stable Project checkout is always rejected.
|
|
95
|
+
*
|
|
96
|
+
* f2: Previously this only checked that some records existed and that the
|
|
97
|
+
* workspace was not the stable checkout. Now it binds the job to the exact
|
|
98
|
+
* managed workspace, verifies write access, and requires an active Task.
|
|
99
|
+
*/
|
|
100
|
+
function validateStartParams(store, params) {
|
|
101
|
+
// The Task must be active — a terminal Task cannot run jobs.
|
|
102
|
+
const task = store.getTask(params.taskId);
|
|
103
|
+
if (task === null) {
|
|
104
|
+
throw jobDomainError(`Task not found: ${params.taskId}.`);
|
|
105
|
+
}
|
|
106
|
+
if (task.status !== "active") {
|
|
107
|
+
throw jobDomainError(`DurableJob requires an active Task; ${params.taskId} is ${task.status}.`);
|
|
108
|
+
}
|
|
109
|
+
// The owner record must exist and must not be terminal. A terminal owner
|
|
110
|
+
// cannot run new jobs — its workspace is eligible for cleanup.
|
|
111
|
+
if (params.owner.kind === "work-item") {
|
|
112
|
+
const workItem = store.getWorkItem(params.taskId, params.owner.workItemId);
|
|
113
|
+
if (workItem === null) {
|
|
114
|
+
throw jobDomainError(`Work Item not found: ${params.taskId}/${params.owner.workItemId}.`);
|
|
115
|
+
}
|
|
116
|
+
if (isTerminalWorkItemStatus(workItem.status)) {
|
|
117
|
+
throw jobDomainError(`DurableJob owner Work Item is terminal: `
|
|
118
|
+
+ `${params.taskId}/${params.owner.workItemId} is ${workItem.status}.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (params.owner.kind === "integration-attempt") {
|
|
122
|
+
const attempt = store.getIntegrationAttempt(params.taskId, params.owner.integrationAttemptId);
|
|
123
|
+
if (attempt === null) {
|
|
124
|
+
throw jobDomainError(`Integration Attempt not found: ${params.taskId}/${params.owner.integrationAttemptId}.`);
|
|
125
|
+
}
|
|
126
|
+
if (attempt.status === "committed" || attempt.status === "failed") {
|
|
127
|
+
throw jobDomainError(`DurableJob owner Integration Attempt is terminal: `
|
|
128
|
+
+ `${params.taskId}/${params.owner.integrationAttemptId} is ${attempt.status}.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const project = store.getProject(params.projectId);
|
|
132
|
+
if (project === null) {
|
|
133
|
+
throw jobDomainError(`Project not found: ${params.projectId}.`);
|
|
134
|
+
}
|
|
135
|
+
// f2: The workspace must be the exact managed workspace for this owner.
|
|
136
|
+
const managedWorkspace = resolveManagedWorkspace(store, params);
|
|
137
|
+
if (managedWorkspace === null) {
|
|
138
|
+
throw jobDomainError(`DurableJob workspace is not a registered managed workspace for `
|
|
139
|
+
+ `${params.owner.kind} ${params.taskId}: ${params.workspace}.`);
|
|
140
|
+
}
|
|
141
|
+
// The workspace path must match the managed workspace root.
|
|
142
|
+
const workspace = resolve(params.workspace);
|
|
143
|
+
if (workspace !== resolve(managedWorkspace.root)) {
|
|
144
|
+
throw jobDomainError(`DurableJob workspace must be the managed workspace root `
|
|
145
|
+
+ `${managedWorkspace.root}; got ${params.workspace}.`);
|
|
146
|
+
}
|
|
147
|
+
// The Project must have write access in this workspace.
|
|
148
|
+
const entry = managedWorkspace.entries.find((e) => e.projectId === params.projectId);
|
|
149
|
+
if (entry === undefined) {
|
|
150
|
+
throw jobDomainError(`Project ${params.projectId} is not bound to workspace `
|
|
151
|
+
+ `${managedWorkspace.root}.`);
|
|
152
|
+
}
|
|
153
|
+
if (entry.access !== "write") {
|
|
154
|
+
throw jobDomainError(`Project ${params.projectId} has read-only access in workspace `
|
|
155
|
+
+ `${managedWorkspace.root}; DurableJob requires write access.`);
|
|
156
|
+
}
|
|
157
|
+
// The stable Project checkout is always rejected (belt and suspenders —
|
|
158
|
+
// a managed workspace should never be the checkout, but verify).
|
|
159
|
+
const checkout = resolve(project.path);
|
|
160
|
+
if (workspace === checkout || workspace.startsWith(`${checkout}${sep}`)) {
|
|
161
|
+
throw jobDomainError(`DurableJob workspace must be a managed workspace; the stable Project checkout is read-only: ${project.path}.`);
|
|
162
|
+
}
|
|
163
|
+
// rr4/finding-2: The declared head must match the repository's physical
|
|
164
|
+
// HEAD. A job that runs against a drifted workspace (checked out at a
|
|
165
|
+
// different commit than declared) produces evidence for the wrong code.
|
|
166
|
+
// This binds the job to the exact Git state of the managed workspace.
|
|
167
|
+
const repoHead = readGitHead(entry.path);
|
|
168
|
+
if (repoHead === null) {
|
|
169
|
+
throw jobDomainError(`DurableJob workspace project path is not a git repository: ${entry.path}.`);
|
|
170
|
+
}
|
|
171
|
+
if (repoHead !== params.head.toLowerCase()) {
|
|
172
|
+
throw jobDomainError(`DurableJob head ${params.head} does not match the workspace HEAD `
|
|
173
|
+
+ `${repoHead} at ${entry.path}.`);
|
|
174
|
+
}
|
|
175
|
+
// f2: A retry must reference an existing terminal job in the same Task.
|
|
176
|
+
if (params.retryOf !== undefined) {
|
|
177
|
+
const original = store.getDurableJob(params.taskId, params.retryOf);
|
|
178
|
+
if (original === null) {
|
|
179
|
+
throw jobDomainError(`Retry original job not found: ${params.taskId}/${params.retryOf}.`);
|
|
180
|
+
}
|
|
181
|
+
if (!isDurableJobTerminal(original.status)) {
|
|
182
|
+
throw jobDomainError(`Retry original job must be terminal: ${params.retryOf} is ${original.status}.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// rr8: Bind the declared owner to the caller's managed identity. A
|
|
186
|
+
// Reviewer can never start jobs; a Worker can only start jobs owned by
|
|
187
|
+
// its own Work Item; a Leader or a plain user retains full access.
|
|
188
|
+
assertCallerAuthorized(store, params.caller, params.owner, params.taskId);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* rr8/rr12: Bind the declared job owner to the caller's managed identity. The
|
|
192
|
+
* Controller validates the declared owner/workspace and verifies the caller
|
|
193
|
+
* against durable Run/Session state — a self-reported role or scope is never
|
|
194
|
+
* authority on its own. The rules:
|
|
195
|
+
*
|
|
196
|
+
* - `user` (non-managed): full access, but only when the request carries a
|
|
197
|
+
* `leaderAssertion` the Controller verifies against the active in-flight
|
|
198
|
+
* Leader Run. A managed Session that sheds its identity and declares
|
|
199
|
+
* `scope: "user"` without that proof is rejected.
|
|
200
|
+
* - `task` + mismatched taskId: rejected.
|
|
201
|
+
* - `task` + missing `runId`: rejected (a managed caller must bind to a Run).
|
|
202
|
+
* - `task` + Run not found / not active / `roleName !== role`: rejected — the
|
|
203
|
+
* claimed Role must be the real Role of an active Run.
|
|
204
|
+
* - `task` + Reviewer: always rejected.
|
|
205
|
+
* - `task` + Leader: must carry `receiptId` and pass the same
|
|
206
|
+
* active-Run + in-flight-receipt + Session check as `job.acknowledge`, so a
|
|
207
|
+
* borrowed or stale Leader Run cannot authorize the request.
|
|
208
|
+
* - `task` + any other Role (Worker etc.): the owner must be `work-item` and
|
|
209
|
+
* the caller's Run must be for that same Work Item.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* rr13: `job.start`/`job.cancel` caller authorization at the Controller
|
|
213
|
+
* boundary. Every identity claim the Controller can verify from durable state
|
|
214
|
+
* (role, runId, receiptId, leaderAssertion) is replayable by any client in the
|
|
215
|
+
* same Home that reads state.json. The channel itself is therefore not
|
|
216
|
+
* authenticated by those claims alone. A non-replayable per-Session caller key
|
|
217
|
+
* closes the gap:
|
|
218
|
+
*
|
|
219
|
+
* - `user` scope: **rejected outright** for start/cancel. A bare shell or a
|
|
220
|
+
* managed Session that sheds its identity has no channel binding. The human
|
|
221
|
+
* operator acts through the Leader Session, which carries a caller key.
|
|
222
|
+
* - `task` scope: the caller must present `callerKey` — the
|
|
223
|
+
* `YUI_JOB_CALLER_KEY` injected at its native Session launch. The Controller
|
|
224
|
+
* hashes it (SHA-256) and compares against the durable `jobCallerKeyHashes`
|
|
225
|
+
* map for the caller's Role + Agent. No legacy fallback: an absent hash or a
|
|
226
|
+
* mismatched key is UNAUTHORIZED.
|
|
227
|
+
*
|
|
228
|
+
* The existing Run/role/WorkItem binding checks (rr8/rr12) run after the key
|
|
229
|
+
* check, so a forged identity that also lacks the key is rejected at the
|
|
230
|
+
* channel boundary before any durable-state lookup.
|
|
231
|
+
*/
|
|
232
|
+
function assertCallerAuthorized(store, caller, owner, taskId, options = {}) {
|
|
233
|
+
if (caller.scope === "user") {
|
|
234
|
+
// rr13: A user-scope caller has no per-Session channel binding. Every
|
|
235
|
+
// durable-state claim it could carry (leaderAssertion etc.) is replayable
|
|
236
|
+
// by any client in the same Home. Reject outright (fail-closed).
|
|
237
|
+
throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key; user scope is rejected.");
|
|
238
|
+
}
|
|
239
|
+
if (caller.taskId !== taskId) {
|
|
240
|
+
throw jobControlError("UNAUTHORIZED", "A managed Task Session may not start or cancel Jobs for a different Task.");
|
|
241
|
+
}
|
|
242
|
+
if (caller.runId === undefined) {
|
|
243
|
+
throw jobControlError("UNAUTHORIZED", "A managed Task Session must bind its request to a Run.");
|
|
244
|
+
}
|
|
245
|
+
const run = store.getAgentRun(taskId, caller.runId);
|
|
246
|
+
if (run === null || run.status !== "active" || run.roleName !== caller.role) {
|
|
247
|
+
throw jobControlError("UNAUTHORIZED", "A managed Task Session's Role is not bound to an active Run.");
|
|
248
|
+
}
|
|
249
|
+
// rr13: Verify the non-replayable per-Session caller key. The key is injected
|
|
250
|
+
// at native Session launch and never persisted in plaintext; only its SHA-256
|
|
251
|
+
// hash is durable. A client that reads state.json can see the hash but cannot
|
|
252
|
+
// recover the key.
|
|
253
|
+
if (caller.callerKey === undefined) {
|
|
254
|
+
throw jobControlError("UNAUTHORIZED", "job.start/job.cancel requires a managed Session caller key.");
|
|
255
|
+
}
|
|
256
|
+
const expectedHash = store.getJobCallerKeyHash(taskId, caller.role ?? "", run.effective.agentId);
|
|
257
|
+
if (expectedHash === null) {
|
|
258
|
+
throw jobControlError("UNAUTHORIZED", "The managed Session has no durable caller key; it must be relaunched.");
|
|
259
|
+
}
|
|
260
|
+
const presentedHash = createHash("sha256").update(caller.callerKey).digest("hex");
|
|
261
|
+
if (presentedHash !== expectedHash) {
|
|
262
|
+
throw jobControlError("UNAUTHORIZED", "The managed Session caller key does not match the durable hash.");
|
|
263
|
+
}
|
|
264
|
+
if (options.leaderOnly && caller.role !== "leader") {
|
|
265
|
+
throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current Task Leader Session.");
|
|
266
|
+
}
|
|
267
|
+
if (caller.role === "reviewer") {
|
|
268
|
+
throw jobControlError("UNAUTHORIZED", "A Reviewer may not start or cancel DurableJobs.");
|
|
269
|
+
}
|
|
270
|
+
if (caller.role === "leader") {
|
|
271
|
+
// rr12: Verify the caller is THE current in-flight Task Leader, not a
|
|
272
|
+
// borrowed or stale Leader Run. This mirrors job.acknowledge.
|
|
273
|
+
if (caller.receiptId === undefined) {
|
|
274
|
+
throw jobControlError("UNAUTHORIZED", "A Leader must carry the current in-flight Turn receipt.");
|
|
275
|
+
}
|
|
276
|
+
assertLeaderActionRun(store, taskId, {
|
|
277
|
+
runId: run.id,
|
|
278
|
+
receiptId: caller.receiptId
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Worker (or any non-leader, non-reviewer managed role): the owner must be
|
|
283
|
+
// the caller's own Work Item, verified through the caller's Run.
|
|
284
|
+
if (owner.kind !== "work-item" || run.workItemId !== owner.workItemId) {
|
|
285
|
+
throw jobControlError("UNAUTHORIZED", "A managed Worker may only start or cancel Jobs owned by its own Work Item.");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Resolve the managed workspace for the job's owner. Returns null if no
|
|
290
|
+
* managed workspace exists for this owner kind + id.
|
|
291
|
+
*/
|
|
292
|
+
function resolveManagedWorkspace(store, params) {
|
|
293
|
+
if (params.owner.kind === "work-item") {
|
|
294
|
+
return store.getManagedWorkspace({
|
|
295
|
+
type: "work-item",
|
|
296
|
+
taskId: params.taskId,
|
|
297
|
+
workItemId: params.owner.workItemId
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (params.owner.kind === "integration-attempt") {
|
|
301
|
+
return store.getManagedWorkspace({
|
|
302
|
+
type: "integration-attempt",
|
|
303
|
+
taskId: params.taskId,
|
|
304
|
+
integrationAttemptId: params.owner.integrationAttemptId
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// owner.kind === "task"
|
|
308
|
+
return store.getManagedWorkspace({
|
|
309
|
+
type: "task",
|
|
310
|
+
taskId: params.taskId
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Read the physical Git HEAD of a repository path. Returns null if the path
|
|
315
|
+
* is not a git repository or the HEAD cannot be resolved.
|
|
316
|
+
*/
|
|
317
|
+
function readGitHead(path) {
|
|
318
|
+
try {
|
|
319
|
+
const head = execSync("git rev-parse HEAD", {
|
|
320
|
+
cwd: path,
|
|
321
|
+
encoding: "utf8",
|
|
322
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
323
|
+
}).trim();
|
|
324
|
+
return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(head) ? head : null;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function isTerminalWorkItemStatus(status) {
|
|
331
|
+
return status === "completed" || status === "failed" || status === "retired";
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* rr5/f5: Verify that the caller is the current in-flight Task Leader. The
|
|
335
|
+
* runId must identify the active Leader Run and the receiptId must match the
|
|
336
|
+
* Run's current in-flight Turn receipt. This binds the assertion to the
|
|
337
|
+
* Leader identity/session the Controller already tracks — a bare flag or a
|
|
338
|
+
* stale Run/receipt pair is rejected.
|
|
339
|
+
*/
|
|
340
|
+
function assertLeaderActionRun(store, taskId, assertion) {
|
|
341
|
+
const run = store.getActiveAgentRun(taskId, "leader");
|
|
342
|
+
if (run === null || run.status !== "active" || run.id !== assertion.runId) {
|
|
343
|
+
throw jobControlError("UNAUTHORIZED", "job.acknowledge requires the current active Task Leader Run.");
|
|
344
|
+
}
|
|
345
|
+
const expectedReceipt = formatAgentRunReceiptId(taskId, run.id);
|
|
346
|
+
if (assertion.receiptId !== expectedReceipt) {
|
|
347
|
+
throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader receipt does not match the current Run.");
|
|
348
|
+
}
|
|
349
|
+
const sessions = store.getTaskRoleSessionSet(taskId, "leader");
|
|
350
|
+
if (sessions === null
|
|
351
|
+
|| sessions.inFlight === null
|
|
352
|
+
|| sessions.inFlight.runId !== run.id
|
|
353
|
+
|| sessions.inFlight.receiptId !== expectedReceipt) {
|
|
354
|
+
throw jobControlError("UNAUTHORIZED", "job.acknowledge Leader Run is not in flight.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Strict `job.start` params parsing. Throws CoreApplicationError-shaped errors
|
|
359
|
+
* so the socket layer reports INVALID_PARAMS without leaking internals.
|
|
360
|
+
*/
|
|
361
|
+
export function parseDurableJobStartParams(value) {
|
|
362
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
363
|
+
throw jobControlError("INVALID_PARAMS", "job.start params are invalid.");
|
|
364
|
+
}
|
|
365
|
+
const record = value;
|
|
366
|
+
const allowed = new Set([
|
|
367
|
+
"taskId", "owner", "projectId", "head", "workspace", "env", "steps",
|
|
368
|
+
"retryOf", "caller"
|
|
369
|
+
]);
|
|
370
|
+
for (const key of Object.keys(record)) {
|
|
371
|
+
if (!allowed.has(key)) {
|
|
372
|
+
throw jobControlError("INVALID_PARAMS", "job.start params are invalid.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const taskId = requiredId(record.taskId, "job.start taskId");
|
|
376
|
+
const owner = parseJobOwner(record.owner);
|
|
377
|
+
const projectId = requiredId(record.projectId, "job.start projectId");
|
|
378
|
+
const head = requiredId(record.head, "job.start head");
|
|
379
|
+
const workspace = requiredId(record.workspace, "job.start workspace");
|
|
380
|
+
if (!workspace.startsWith("/")) {
|
|
381
|
+
throw jobControlError("INVALID_PARAMS", "job.start workspace must be an absolute path.");
|
|
382
|
+
}
|
|
383
|
+
const env = parseStringMap(record.env, "job.start env");
|
|
384
|
+
const steps = parseSteps(record.steps);
|
|
385
|
+
const retryOf = record.retryOf === undefined
|
|
386
|
+
? undefined
|
|
387
|
+
: requiredId(record.retryOf, "job.start retryOf");
|
|
388
|
+
const caller = parseCaller(record.caller);
|
|
389
|
+
return {
|
|
390
|
+
taskId,
|
|
391
|
+
owner,
|
|
392
|
+
projectId,
|
|
393
|
+
head,
|
|
394
|
+
workspace,
|
|
395
|
+
env,
|
|
396
|
+
steps,
|
|
397
|
+
caller,
|
|
398
|
+
...(retryOf === undefined ? {} : { retryOf })
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
export function parseDurableJobRefParams(value) {
|
|
402
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
403
|
+
|| Object.keys(value).length !== 2) {
|
|
404
|
+
throw jobControlError("INVALID_PARAMS", "DurableJob ref params are invalid.");
|
|
405
|
+
}
|
|
406
|
+
const record = value;
|
|
407
|
+
return {
|
|
408
|
+
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
409
|
+
jobId: requiredId(record.jobId, "DurableJob jobId")
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* rr8: `job.cancel` params carry the caller identity so the Controller can
|
|
414
|
+
* bind the cancel request to the caller's managed scope. Distinct from
|
|
415
|
+
* `parseDurableJobRefParams` (used by `job.get`) because cancel requires the
|
|
416
|
+
* third `caller` key.
|
|
417
|
+
*/
|
|
418
|
+
export function parseDurableJobCancelParams(value) {
|
|
419
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
420
|
+
|| Object.keys(value).length !== 3) {
|
|
421
|
+
throw jobControlError("INVALID_PARAMS", "DurableJob cancel params are invalid.");
|
|
422
|
+
}
|
|
423
|
+
const record = value;
|
|
424
|
+
return {
|
|
425
|
+
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
426
|
+
jobId: requiredId(record.jobId, "DurableJob jobId"),
|
|
427
|
+
caller: parseCaller(record.caller)
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* rr26: `job.acknowledge` carries the same managed task caller as
|
|
432
|
+
* job.start/job.cancel. A replayable leaderAssertion without the ephemeral
|
|
433
|
+
* Session caller key is rejected by assertCallerAuthorized.
|
|
434
|
+
*/
|
|
435
|
+
export function parseDurableJobAcknowledgeParams(value) {
|
|
436
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
437
|
+
|| Object.keys(value).length !== 3) {
|
|
438
|
+
throw jobControlError("INVALID_PARAMS", "DurableJob acknowledge params are invalid.");
|
|
439
|
+
}
|
|
440
|
+
const record = value;
|
|
441
|
+
return {
|
|
442
|
+
taskId: requiredId(record.taskId, "DurableJob taskId"),
|
|
443
|
+
jobId: requiredId(record.jobId, "DurableJob jobId"),
|
|
444
|
+
caller: parseCaller(record.caller)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function parseJobOwner(value) {
|
|
448
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
449
|
+
throw jobControlError("INVALID_PARAMS", "job.start owner is invalid.");
|
|
450
|
+
}
|
|
451
|
+
const record = value;
|
|
452
|
+
if (record.kind === "task" && Object.keys(record).length === 1) {
|
|
453
|
+
return { kind: "task" };
|
|
454
|
+
}
|
|
455
|
+
if (record.kind === "work-item" && Object.keys(record).length === 2) {
|
|
456
|
+
return { kind: "work-item", workItemId: requiredId(record.workItemId, "job.start owner workItemId") };
|
|
457
|
+
}
|
|
458
|
+
if (record.kind === "integration-attempt" && Object.keys(record).length === 2) {
|
|
459
|
+
return {
|
|
460
|
+
kind: "integration-attempt",
|
|
461
|
+
integrationAttemptId: requiredId(record.integrationAttemptId, "job.start owner integrationAttemptId")
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
throw jobControlError("INVALID_PARAMS", "job.start owner is invalid.");
|
|
465
|
+
}
|
|
466
|
+
function parseSteps(value) {
|
|
467
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) {
|
|
468
|
+
throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
|
|
469
|
+
}
|
|
470
|
+
const names = new Set();
|
|
471
|
+
const steps = [];
|
|
472
|
+
for (const entry of value) {
|
|
473
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
474
|
+
throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
|
|
475
|
+
}
|
|
476
|
+
const record = entry;
|
|
477
|
+
const allowed = new Set(["name", "command", "timeoutMs"]);
|
|
478
|
+
for (const key of Object.keys(record)) {
|
|
479
|
+
if (!allowed.has(key)) {
|
|
480
|
+
throw jobControlError("INVALID_PARAMS", "job.start steps are invalid.");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const name = requiredId(record.name, "job.start step name");
|
|
484
|
+
const command = requiredId(record.command, "job.start step command");
|
|
485
|
+
if (names.has(name)) {
|
|
486
|
+
throw jobControlError("INVALID_PARAMS", `job.start step names must be unique: ${name}.`);
|
|
487
|
+
}
|
|
488
|
+
names.add(name);
|
|
489
|
+
if (record.timeoutMs !== undefined
|
|
490
|
+
&& (typeof record.timeoutMs !== "number"
|
|
491
|
+
|| !Number.isSafeInteger(record.timeoutMs)
|
|
492
|
+
|| record.timeoutMs < 1)) {
|
|
493
|
+
throw jobControlError("INVALID_PARAMS", `job.start step timeoutMs is invalid: ${name}.`);
|
|
494
|
+
}
|
|
495
|
+
const step = {
|
|
496
|
+
name,
|
|
497
|
+
command,
|
|
498
|
+
...(record.timeoutMs === undefined ? {} : { timeoutMs: record.timeoutMs })
|
|
499
|
+
};
|
|
500
|
+
steps.push(step);
|
|
501
|
+
}
|
|
502
|
+
return steps;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* rr9/rr12: Parse the caller identity carried by `job.start`/`job.cancel`. The
|
|
506
|
+
* caller is REQUIRED: a request without one is rejected at the socket
|
|
507
|
+
* boundary (fail-closed). A non-managed caller must explicitly resolve to
|
|
508
|
+
* `{scope: "user"}`; the Controller never defaults a missing identity to
|
|
509
|
+
* user scope. A present caller must carry a valid `scope` and may carry
|
|
510
|
+
* optional `taskId`, `role`, `runId`, `receiptId`, and `leaderAssertion`
|
|
511
|
+
* fields. The identity is verified against durable Run state by
|
|
512
|
+
* `assertCallerAuthorized`; parsing only validates the JSON shape.
|
|
513
|
+
*/
|
|
514
|
+
function parseCaller(value) {
|
|
515
|
+
if (value === undefined) {
|
|
516
|
+
throw jobControlError("INVALID_PARAMS", "job caller is required.");
|
|
517
|
+
}
|
|
518
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
519
|
+
throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
|
|
520
|
+
}
|
|
521
|
+
const record = value;
|
|
522
|
+
const allowed = new Set([
|
|
523
|
+
"scope", "taskId", "role", "runId", "receiptId", "leaderAssertion", "callerKey"
|
|
524
|
+
]);
|
|
525
|
+
for (const key of Object.keys(record)) {
|
|
526
|
+
if (!allowed.has(key)) {
|
|
527
|
+
throw jobControlError("INVALID_PARAMS", "job caller is invalid.");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (record.scope !== "user" && record.scope !== "task") {
|
|
531
|
+
throw jobControlError("INVALID_PARAMS", "job caller scope is invalid.");
|
|
532
|
+
}
|
|
533
|
+
const optionalId = (key) => {
|
|
534
|
+
const entry = record[key];
|
|
535
|
+
if (entry === undefined)
|
|
536
|
+
return undefined;
|
|
537
|
+
return requiredId(entry, `job caller ${key}`);
|
|
538
|
+
};
|
|
539
|
+
const taskId = optionalId("taskId");
|
|
540
|
+
const role = optionalId("role");
|
|
541
|
+
const runId = optionalId("runId");
|
|
542
|
+
const receiptId = optionalId("receiptId");
|
|
543
|
+
const leaderAssertion = parseLeaderAssertion(record.leaderAssertion);
|
|
544
|
+
const callerKey = record.callerKey === undefined
|
|
545
|
+
? undefined
|
|
546
|
+
: requiredId(record.callerKey, "job caller callerKey");
|
|
547
|
+
return {
|
|
548
|
+
scope: record.scope,
|
|
549
|
+
...(taskId === undefined ? {} : { taskId }),
|
|
550
|
+
...(role === undefined ? {} : { role }),
|
|
551
|
+
...(runId === undefined ? {} : { runId }),
|
|
552
|
+
...(receiptId === undefined ? {} : { receiptId }),
|
|
553
|
+
...(leaderAssertion === undefined ? {} : { leaderAssertion }),
|
|
554
|
+
...(callerKey === undefined ? {} : { callerKey })
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* rr12: Parse a `leaderAssertion` sub-object (`{runId, receiptId}`) carried by
|
|
559
|
+
* a user-scope caller. Returns undefined when absent; throws on a malformed
|
|
560
|
+
* shape.
|
|
561
|
+
*/
|
|
562
|
+
function parseLeaderAssertion(value) {
|
|
563
|
+
if (value === undefined)
|
|
564
|
+
return undefined;
|
|
565
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)
|
|
566
|
+
|| Object.keys(value).length !== 2) {
|
|
567
|
+
throw jobControlError("INVALID_PARAMS", "job caller leaderAssertion must have runId and receiptId.");
|
|
568
|
+
}
|
|
569
|
+
const record = value;
|
|
570
|
+
return {
|
|
571
|
+
runId: requiredId(record.runId, "job caller leaderAssertion runId"),
|
|
572
|
+
receiptId: requiredId(record.receiptId, "job caller leaderAssertion receiptId")
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function parseStringMap(value, label) {
|
|
576
|
+
if (value === undefined)
|
|
577
|
+
return {};
|
|
578
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
579
|
+
throw jobControlError("INVALID_PARAMS", `${label} is invalid.`);
|
|
580
|
+
}
|
|
581
|
+
const map = {};
|
|
582
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
583
|
+
if (typeof entry !== "string") {
|
|
584
|
+
throw jobControlError("INVALID_PARAMS", `${label} is invalid.`);
|
|
585
|
+
}
|
|
586
|
+
map[key] = entry;
|
|
587
|
+
}
|
|
588
|
+
return map;
|
|
589
|
+
}
|
|
590
|
+
function requiredId(value, label) {
|
|
591
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
592
|
+
throw jobControlError("INVALID_PARAMS", `${label} is required.`);
|
|
593
|
+
}
|
|
594
|
+
return value;
|
|
595
|
+
}
|
|
596
|
+
function jobControlError(code, message) {
|
|
597
|
+
const error = Object.assign(new Error(message), { code });
|
|
598
|
+
error.name = "CoreApplicationError";
|
|
599
|
+
return error;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* rr7: Expected job-domain rejections from `validateStartParams` (unknown or
|
|
603
|
+
* terminal Task/owner, unmanaged or read-only workspace, stable checkout,
|
|
604
|
+
* HEAD mismatch, invalid retry) must cross the Controller socket as
|
|
605
|
+
* JOB_ERROR with their actionable message. The socket only passes
|
|
606
|
+
* CoreApplicationError/CoreServiceError/CoreJobError through; a plain Error
|
|
607
|
+
* collapses to INTERNAL_ERROR, losing the reason the caller needs to act on.
|
|
608
|
+
*/
|
|
609
|
+
function jobDomainError(message) {
|
|
610
|
+
const error = new Error(message);
|
|
611
|
+
error.name = "CoreJobError";
|
|
612
|
+
return error;
|
|
613
|
+
}
|