@zq-silk/yui 2.10.0 → 2.12.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/artifacts/artifactCapability.js +14 -8
- package/dist/artifacts/artifactCommitLock.js +7 -5
- package/dist/artifacts/jobArtifact.js +93 -0
- package/dist/artifacts/jobArtifactTransport.js +13 -0
- package/dist/artifacts/taskArtifactRepository.js +58 -10
- package/dist/cli/callerDiscovery.js +18 -0
- package/dist/cli/commandCatalog.js +45 -14
- package/dist/cli/commandDiscovery.js +2 -3
- package/dist/commands/durableJobCommands.js +31 -6
- package/dist/commands/globalRoleCommands.js +5 -0
- package/dist/commands/scheduleCommands.js +14 -3
- package/dist/commands/taskCommands.js +5 -2
- package/dist/context/taskCatalog.js +6 -1
- package/dist/controlPlaneCli.js +12 -12
- package/dist/controller/agentJobControl.js +391 -0
- package/dist/controller/controller.js +24 -3
- package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
- package/dist/controller/globalInputDelivery.js +8 -1
- package/dist/controller/jobArtifactControl.js +111 -0
- package/dist/controller/jobControl.js +4 -0
- package/dist/controller/jobDefinitionControl.js +191 -19
- package/dist/controller/jobSupervisor.js +83 -3
- package/dist/controller/jobTaskDelegationControl.js +193 -0
- package/dist/controller/runtime.js +7 -1
- package/dist/controller/scheduleControl.js +83 -20
- package/dist/integration/gitIntegrationService.js +2 -0
- package/dist/job/agentJob.js +131 -0
- package/dist/job/agentJobObservation.js +61 -0
- package/dist/job/agentRoleScope.js +33 -0
- package/dist/job/durableJob.js +63 -28
- package/dist/job/jobAssignmentScope.js +1 -0
- package/dist/job/jobBusinessResult.js +72 -0
- package/dist/job/jobCleanupInspection.js +4 -0
- package/dist/job/jobDefinition.js +26 -2
- package/dist/job/jobRunner.js +4 -3
- package/dist/job/projectRefreshRunner.js +54 -18
- package/dist/job/projectSync.js +162 -0
- package/dist/job/scheduleService.js +55 -0
- package/dist/job/taskDelegation.js +48 -0
- package/dist/message/messageContinuation.js +1 -1
- package/dist/resources/projectResourceService.js +1 -1
- package/dist/resources/resourceGc.js +1 -1
- package/dist/role/role.js +22 -12
- package/dist/runtime/managedIdentity.js +5 -3
- package/dist/runtime/sessionCaller.js +9 -10
- package/dist/schedule/schedule.js +65 -15
- package/dist/scheduler/wakeReason.js +4 -0
- package/dist/storage/agentJobSchema.js +10 -0
- package/dist/storage/maintenanceCaller.js +17 -25
- package/dist/storage/nativeSessionLookup.js +23 -0
- package/dist/storage/recordValidation.js +2 -0
- package/dist/storage/sessionHistorySchema.js +17 -0
- package/dist/storage/sqliteSchema.js +96 -11
- package/dist/storage/sqliteStore.js +71 -4
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +4 -0
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
- package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
- package/dist/task/archivePreflight.js +15 -0
- package/dist/task/completionReadiness.js +7 -0
- package/dist/task/task.js +17 -1
- package/dist/verification/verificationGateService.js +4 -0
- package/dist/web/assets/client/app/catalog.js +16 -0
- package/dist/web/assets/client/i18n/en.js +29 -2
- package/dist/web/assets/client/i18n/zh-CN.js +29 -2
- package/dist/web/assets/client/layout/manageShell.js +9 -0
- package/dist/web/assets/client/layout/router.js +1 -0
- package/dist/web/assets/client/views/jobs.js +166 -37
- package/dist/web/assets/client/views/task/files.js +5 -4
- package/dist/web/assets/client/views/task/page.js +5 -1
- package/dist/web/taskPreviews.js +2 -2
- package/dist/web/webJobs.js +36 -11
- package/dist/web/webMaterials.js +21 -9
- package/dist/web/webServer.js +6 -5
- package/dist/web/webTaskSurface.js +1 -0
- package/docs/authorization.md +130 -0
- package/docs/job-runs.md +392 -17
- package/docs/managed-turn-and-session-runtime.md +10 -5
- package/docs/managed-turn-and-session-runtime.zh-CN.md +12 -5
- package/docs/release-workflow.md +79 -0
- package/docs/release-workflow.zh-CN.md +58 -0
- package/docs/schedules.md +69 -15
- package/docs/storage-baseline.md +45 -1
- package/docs/storage-baseline.zh-CN.md +33 -1
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/package.json +1 -1
- package/skills/yui-runtime/SKILL.md +12 -5
- package/skills/yui-runtime/VERSION +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { assertJobRoleRunnable } from "../job/agentRoleScope.js";
|
|
3
|
+
import { recordJobAgentObservation } from "../job/agentJobObservation.js";
|
|
2
4
|
import { isDeepStrictEqual } from "node:util";
|
|
3
5
|
import { nonReplayableMessageWake, pinMessageRevisions, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
|
|
4
6
|
import { contextSnapshotRef } from "../context/contextSnapshot.js";
|
|
@@ -275,6 +277,8 @@ export class FileSchedulerStoreAdapter {
|
|
|
275
277
|
&& input.kind !== "goal.updated" && input.kind !== "goal.cleared") {
|
|
276
278
|
this.persistRuntimeObservation(input, now);
|
|
277
279
|
}
|
|
280
|
+
if (outcome === "applied")
|
|
281
|
+
recordJobAgentObservation(this.store, { scope: "task", taskId, roleName: input.fence.roleName }, this.store.getTaskRoleSessionSet(taskId, input.fence.roleName)?.providerBinding, now);
|
|
278
282
|
return outcome;
|
|
279
283
|
}
|
|
280
284
|
/**
|
|
@@ -490,6 +494,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
490
494
|
});
|
|
491
495
|
}
|
|
492
496
|
store.saveGlobalRoleSessionSet(updateGlobalRoleProviderRuntime(sessions, binding, now));
|
|
497
|
+
recordJobAgentObservation(store, { scope: "global", roleName: fence.roleName }, binding, now);
|
|
493
498
|
settleGlobalRetryInput(store, fence.roleName, binding, now);
|
|
494
499
|
if (["turn.completed", "turn.failed", "turn.cancelled"].includes(input.kind)) {
|
|
495
500
|
checkInboxTurn(store, { scope: "global", roleName: fence.roleName, agentId: fence.agentId,
|
|
@@ -1129,6 +1134,9 @@ export class FileSchedulerStoreAdapter {
|
|
|
1129
1134
|
beginAgentHostProviderTurn(input) {
|
|
1130
1135
|
if (input.taskId === undefined) {
|
|
1131
1136
|
this.store.transaction((store) => {
|
|
1137
|
+
const role = store.getGlobalRole(input.roleName);
|
|
1138
|
+
if (role)
|
|
1139
|
+
assertJobRoleRunnable(store, role);
|
|
1132
1140
|
if (input.runId !== undefined || hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: input.roleName })))) {
|
|
1133
1141
|
throw new AgentHostProviderTurnFenceError("Global Session admission is stopped.");
|
|
1134
1142
|
}
|
|
@@ -1326,6 +1334,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
1326
1334
|
? { retry: { ...resolved.retry, failureRef } } : {}) };
|
|
1327
1335
|
}
|
|
1328
1336
|
store.saveGlobalRoleSessionSet(updateGlobalRoleProviderRuntime(sessions, resolved, input.now));
|
|
1337
|
+
recordJobAgentObservation(store, { scope: "global", roleName: input.roleName }, resolved, input.now);
|
|
1329
1338
|
settleGlobalRetryInput(store, input.roleName, resolved, input.now);
|
|
1330
1339
|
if (input.status !== "deferred" && !providerRetryPending(resolved)) {
|
|
1331
1340
|
recordGlobalRuntimeAttention(store, input.roleName, `${input.attemptId}:submission`, {
|
|
@@ -1561,7 +1570,9 @@ export class FileSchedulerStoreAdapter {
|
|
|
1561
1570
|
if ("taskId" in next)
|
|
1562
1571
|
throw new Error("A global JobRun cannot change scope.");
|
|
1563
1572
|
store.saveGlobalJobRun(next);
|
|
1564
|
-
|
|
1573
|
+
// Public and explicitly service-owned work have no Agent recipient.
|
|
1574
|
+
// Results stay queryable without implicitly starting an Operator.
|
|
1575
|
+
if (wakeup !== undefined && !["user", "service:schedule"].includes(current.operation.actorId))
|
|
1565
1576
|
enqueueWork(store, { kind: "operator" }, "global-job-finished", now, [{ type: "global-job-run", id: jobId }], { source: "global-job", dedupeKey: `operator-global-job-run:${jobId}` });
|
|
1566
1577
|
return next;
|
|
1567
1578
|
}
|
|
@@ -7,6 +7,7 @@ import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
|
|
|
7
7
|
import { globalMessagePresentation } from "../interaction/notificationPresentation.js";
|
|
8
8
|
import { recordGlobalRuntimeAttention, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
|
|
9
9
|
import { globalInboxRecovery } from "../message/globalInboxRecovery.js";
|
|
10
|
+
import { assertJobRoleRunnable } from "../job/agentRoleScope.js";
|
|
10
11
|
/** Messages are the only queue. Host's atomic begin arbitrates native writers. */
|
|
11
12
|
export async function deliverGlobalInputs(home, store, ensure, onError, options = {}) {
|
|
12
13
|
for (const role of store.listGlobalRoles()) {
|
|
@@ -17,7 +18,12 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
17
18
|
let sessions = store.getGlobalRoleSessionSet(role.name);
|
|
18
19
|
// A detached Host keeps an active Session and may reconnect. An explicit
|
|
19
20
|
// Session stop/switch must not be undone by background notification work.
|
|
20
|
-
|
|
21
|
+
const selected = sessions?.sessions[sessions.activeAgentId];
|
|
22
|
+
// An explicit Job session-new archives/removes the proven-ended writer.
|
|
23
|
+
// Its next original input may launch normally; an ended selected Session
|
|
24
|
+
// still means stopped and is never implicitly restarted.
|
|
25
|
+
if (sessions !== null && selected?.status !== "active"
|
|
26
|
+
&& !(role.jobOwner && selected === undefined))
|
|
21
27
|
continue;
|
|
22
28
|
if (sessions?.providerBinding?.retry !== undefined && !providerRetryPending(sessions.providerBinding)) {
|
|
23
29
|
store.transaction(tx => settleGlobalRetryInput(tx, role.name, tx.getGlobalRoleSessionSet(role.name)?.providerBinding, new Date()));
|
|
@@ -110,6 +116,7 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
110
116
|
pending = pending.filter(message => !obsoleteIds.has(message.id));
|
|
111
117
|
if (pending.length === 0 && !recovering)
|
|
112
118
|
continue;
|
|
119
|
+
assertJobRoleRunnable(store, role);
|
|
113
120
|
if (reservedPage.count > reserved.length && obsolete.length === reserved.length)
|
|
114
121
|
continue;
|
|
115
122
|
if (current != null && ["submitting", "accepted", "delivery-unknown"].includes(current.status))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { requireKnownFields } from "../domain/validation.js";
|
|
3
|
+
import { runReference } from "../job/jobBusinessResult.js";
|
|
4
|
+
import { createJobDefinitionControl } from "./jobDefinitionControl.js";
|
|
5
|
+
import { assertCallerAuthorized } from "./jobControl.js";
|
|
6
|
+
import { artifactInputBytes, presentArtifactContent } from "../artifacts/artifactCapability.js";
|
|
7
|
+
import { previewArtifactContent } from "../web/webMaterials.js";
|
|
8
|
+
import { boundedJobArtifactPayload, MAX_JOB_ARTIFACT_RESPONSE_BYTES } from "../artifacts/jobArtifactTransport.js";
|
|
9
|
+
import { jobArtifactOwner, openJobArtifactRepository, readJobArtifactReference, validateJobArtifactReference } from "../artifacts/jobArtifact.js";
|
|
10
|
+
/** Git commits precede database publication. A failed publication retains the exact
|
|
11
|
+
* commit and returns its reference for explicit registration, never business replay.
|
|
12
|
+
* Never hold a synchronous SQLite transaction across awaited Git operations. */
|
|
13
|
+
export async function controlJobArtifact(store, home, value, now) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
15
|
+
throw new Error("Invalid Job artifact request.");
|
|
16
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "runId", "artifact", "reference", "offset"], "Job artifact request");
|
|
17
|
+
const input = value;
|
|
18
|
+
const current = () => createJobDefinitionControl(store, home)({
|
|
19
|
+
op: "run-get", scope: input.scope, caller: input.caller, id: input.id, runId: input.runId
|
|
20
|
+
}, now);
|
|
21
|
+
const run = current(); // Both definition and historical Assignment authorization.
|
|
22
|
+
const repo = openJobArtifactRepository(home, jobArtifactOwner(run), runReference(run));
|
|
23
|
+
if (input.op === "artifact-list") {
|
|
24
|
+
const head = await repo.head();
|
|
25
|
+
const files = head === null ? [] : await repo.list(head);
|
|
26
|
+
if (files.length > 256)
|
|
27
|
+
throw new Error("Artifact repository exceeds the bounded 256-file listing.");
|
|
28
|
+
const saved = [];
|
|
29
|
+
if (head !== null)
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
// One bounded file at a time: a valid 256-file repository must not spawn
|
|
32
|
+
// hundreds of Git children or materialize all 8 MiB blobs concurrently.
|
|
33
|
+
const content = await repo.read(file.relativePath, head);
|
|
34
|
+
saved.push({ kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
|
|
35
|
+
version: head, relativePath: file.relativePath, digest: content.digest });
|
|
36
|
+
}
|
|
37
|
+
current();
|
|
38
|
+
return { sourceRun: runReference(run), artifacts: run.artifacts ?? [],
|
|
39
|
+
repository: { head, saved, workingChanges: await repo.workingChanges() } };
|
|
40
|
+
}
|
|
41
|
+
if (input.op === "artifact-read" || input.op === "artifact-preview") {
|
|
42
|
+
const ref = validateJobArtifactReference(input.reference, run);
|
|
43
|
+
// This grants only a deliberately selected, published object, never HEAD or
|
|
44
|
+
// all material in its Task. An arbitrary caller-supplied Task ref is not a grant.
|
|
45
|
+
if (!(run.artifacts ?? []).some(item => isDeepStrictEqual(item, ref))) {
|
|
46
|
+
throw new Error("Artifact reference is not registered for this exact Run.");
|
|
47
|
+
}
|
|
48
|
+
const content = await readJobArtifactReference(home, ref);
|
|
49
|
+
current(); // A replaced Session during I/O must not receive the material.
|
|
50
|
+
return boundedJobArtifactPayload({ reference: ref, ...(input.op === "artifact-preview"
|
|
51
|
+
? previewArtifactContent(content, input.offset) : presentArtifactContent(content)) }, "Artifact response", MAX_JOB_ARTIFACT_RESPONSE_BYTES);
|
|
52
|
+
}
|
|
53
|
+
if (!["artifact-save", "artifact-register"].includes(input.op))
|
|
54
|
+
throw new Error("Unknown Job artifact operation.");
|
|
55
|
+
let ref;
|
|
56
|
+
if (input.op === "artifact-save") {
|
|
57
|
+
const artifact = input.artifact;
|
|
58
|
+
requireKnownFields(artifact, ["relativePath", "content", "encoding", "message", "expectedHead"], "Job artifact save");
|
|
59
|
+
boundedJobArtifactPayload(artifact, "Artifact save");
|
|
60
|
+
if (typeof artifact.relativePath !== "string" || artifact.relativePath.length > 512)
|
|
61
|
+
throw new Error("Artifact path exceeds 512 characters.");
|
|
62
|
+
if ((run.artifacts?.length ?? 0) >= 256)
|
|
63
|
+
throw new Error("Run artifacts exceed the bounded reference limit.");
|
|
64
|
+
const bytes = artifactInputBytes(artifact);
|
|
65
|
+
const saved = await repo.save({ files: [{ relativePath: artifact.relativePath, bytes }],
|
|
66
|
+
message: artifact.message ?? `Save ${artifact.relativePath}`, expectedHead: artifact.expectedHead });
|
|
67
|
+
const content = await repo.read(artifact.relativePath, saved.commit);
|
|
68
|
+
ref = { kind: "artifact", owner: jobArtifactOwner(run), sourceRun: runReference(run),
|
|
69
|
+
version: saved.commit, relativePath: content.relativePath, digest: content.digest };
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ref = validateJobArtifactReference(input.reference, run);
|
|
73
|
+
if (ref.owner.kind === "task") {
|
|
74
|
+
// Only a Task Leader or Operator with current access may select Task material.
|
|
75
|
+
// Assignment readers cannot manufacture a cross-entity grant.
|
|
76
|
+
if (!(input.caller.scope === "global" && input.caller.role === "operator")
|
|
77
|
+
&& !(input.caller.scope === "task" && input.caller.role === "leader")) {
|
|
78
|
+
throw new Error("Selecting Task artifacts requires its authorized Leader or Operator.");
|
|
79
|
+
}
|
|
80
|
+
assertCallerAuthorized(store, input.caller, ref.owner.taskId);
|
|
81
|
+
}
|
|
82
|
+
await readJobArtifactReference(home, ref);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return store.transaction(tx => {
|
|
86
|
+
const fresh = current();
|
|
87
|
+
if (ref.owner.kind === "task")
|
|
88
|
+
assertCallerAuthorized(tx, input.caller, ref.owner.taskId);
|
|
89
|
+
const previous = fresh.artifacts ?? [];
|
|
90
|
+
if (previous.some(item => isDeepStrictEqual(item, ref)))
|
|
91
|
+
return { status: "registered", reference: ref };
|
|
92
|
+
const next = { ...fresh, artifacts: [...previous, ref],
|
|
93
|
+
updatedAt: new Date(Math.max(now.getTime(), Date.parse(fresh.updatedAt))).toISOString() };
|
|
94
|
+
if ("executor" in next)
|
|
95
|
+
tx.saveAgentJobRun(next);
|
|
96
|
+
else if ("taskId" in next)
|
|
97
|
+
tx.saveDurableJob(next.taskId, next);
|
|
98
|
+
else
|
|
99
|
+
tx.saveGlobalJobRun(next);
|
|
100
|
+
return { status: "registered", reference: ref };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
// An explicit partial receipt survives the RPC's bounded exception text.
|
|
105
|
+
// It is NOT a published result. Never replay the business action or silently
|
|
106
|
+
// retry the database write to conceal this boundary.
|
|
107
|
+
return { status: "saved-unregistered", reference: ref,
|
|
108
|
+
error: (cause instanceof Error ? cause.message : String(cause)).slice(0, 4096),
|
|
109
|
+
recovery: "Inspect artifact-list and register this exact reference; do not rerun business work." };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -86,6 +86,8 @@ export function createDurableJobControl(store) {
|
|
|
86
86
|
validateStartParams(tx, params);
|
|
87
87
|
const previous = params.retryOf === undefined ? null : tx.getDurableJob(params.taskId, params.retryOf);
|
|
88
88
|
if (previous !== null) {
|
|
89
|
+
if (previous.delegation)
|
|
90
|
+
throw jobDomainError("Use Job run retry for a Task delegation, not a process specification.");
|
|
89
91
|
requireJobAssignment(tx, previous, params.caller);
|
|
90
92
|
if (definition === null && durableJobIdempotencyKey(previous) !== specificationKey) {
|
|
91
93
|
throw jobDomainError("Legacy retry must preserve the original Run specification; update its Job definition for a changed configuration.");
|
|
@@ -190,6 +192,8 @@ export function createJobCallAuthority(store) {
|
|
|
190
192
|
* Job even when its management binding is revoked. Only a new spawn checks it.
|
|
191
193
|
*/
|
|
192
194
|
export function authorizeJobStart(store, job) {
|
|
195
|
+
if (job.delegation)
|
|
196
|
+
throw jobDomainError("Task delegation cannot start a process.");
|
|
193
197
|
const taskPrefix = `task:${job.taskId}/role:`;
|
|
194
198
|
const actor = job.operation.actorId;
|
|
195
199
|
const scope = actor.startsWith(taskPrefix) ? "task" : actor.startsWith("global:") ? "global" : undefined;
|
|
@@ -8,9 +8,26 @@ import { prepareBuiltinJob, projectRefreshDigest } from "../job/builtinJob.js";
|
|
|
8
8
|
import { projectRefreshTargetDigest, requireRefreshProject } from "../repository/projectRefresh.js";
|
|
9
9
|
import { assertCallerAuthorized, assertNonSecretJobInput, createDurableJobControl, jobAuthorityBinding, validateStartParams, requireJobAssignment, parseDurableJobCaller } from "./jobControl.js";
|
|
10
10
|
import { createFileArtifactPort } from "./jobSupervisor.js";
|
|
11
|
-
|
|
11
|
+
import { jobRunSnapshot, runReference } from "../job/jobBusinessResult.js";
|
|
12
|
+
import { controlJobArtifact } from "./jobArtifactControl.js";
|
|
13
|
+
import { ArtifactHeadConflictError, ArtifactManagedConfigViolationError, ArtifactRemoteViolationError } from "../artifacts/taskArtifactRepository.js";
|
|
14
|
+
import { ArtifactCommitLockedError } from "../artifacts/artifactCommitLock.js";
|
|
15
|
+
import { ManagedGitError } from "../artifacts/managedGit.js";
|
|
16
|
+
import { authorizeServiceJob, requireScheduleService, serviceRunBinding } from "../job/scheduleService.js";
|
|
17
|
+
import { authorizeAgentDefinition, controlAgentJob, summarizeAgentRuns } from "./agentJobControl.js";
|
|
18
|
+
import { admitTaskJob, authorizeTaskJob, reconcileTaskDelegation, taskDelegationView } from "./jobTaskDelegationControl.js";
|
|
19
|
+
import { requireProjectSyncTarget } from "../job/projectSync.js";
|
|
20
|
+
export function authorizeScope(store, scope, caller, allowPublic = false) {
|
|
12
21
|
parseDurableJobCaller(caller);
|
|
13
22
|
jobScopeKey(scope);
|
|
23
|
+
// Public local ingress is not a fabricated Operator. It grants only immediate
|
|
24
|
+
// controlled global management/actions. Schedule grants are explicit and
|
|
25
|
+
// Task-shell authority is unchanged.
|
|
26
|
+
if (allowPublic && scope.kind === "global" && caller.scope === "user") {
|
|
27
|
+
if (Object.keys(caller).some(key => key !== "scope"))
|
|
28
|
+
throw new Error("Public user Job caller must not claim a managed identity.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
14
31
|
// Match Task Context's Operator-only boundary for global callers as well
|
|
15
32
|
// as excluding Task callers from global Jobs. Session identity alone is not
|
|
16
33
|
// permission for a custom GlobalRole to read or manage Task definitions.
|
|
@@ -26,7 +43,13 @@ function taskParams(job, caller, requestId) {
|
|
|
26
43
|
return { ...job.configuration, taskId: job.scope.taskId, caller, requestId };
|
|
27
44
|
}
|
|
28
45
|
export function authorizeDefinition(store, job, caller, write) {
|
|
29
|
-
|
|
46
|
+
if (job.configuration.kind === "agent")
|
|
47
|
+
return authorizeAgentDefinition(store, job, caller, write);
|
|
48
|
+
authorizeScope(store, job.scope, caller, true);
|
|
49
|
+
if (job.configuration.kind === "task") {
|
|
50
|
+
authorizeTaskJob(store, job, caller, write);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
30
53
|
if (job.scope.kind === "task") {
|
|
31
54
|
const params = taskParams(job, caller, "definition-validation");
|
|
32
55
|
// A definition must not reveal another Assignment's commands/environment.
|
|
@@ -41,11 +64,13 @@ export function authorizeDefinition(store, job, caller, write) {
|
|
|
41
64
|
throw new Error("Global Jobs require builtin actions.");
|
|
42
65
|
prepareBuiltinJob(job.configuration, "/");
|
|
43
66
|
if (job.configuration.action === "project.refresh") {
|
|
67
|
+
if (job.builtin)
|
|
68
|
+
requireProjectSyncTarget(store, job);
|
|
44
69
|
const project = requireRefreshProject(store, job.configuration.projectId, true);
|
|
45
70
|
if (projectRefreshDigest(job.configuration) !== projectRefreshTargetDigest(project)) {
|
|
46
71
|
throw new Error("Project refresh target changed; inspect the Project and explicitly update the Job.");
|
|
47
72
|
}
|
|
48
|
-
const duplicate = store.listJobDefinitions({ kind: "global" }).find(other => other.id !== job.id && other.configuration.kind === "builtin"
|
|
73
|
+
const duplicate = !job.instantiation && store.listJobDefinitions({ kind: "global" }).find(other => !other.instantiation && !other.builtin && other.id !== job.id && other.configuration.kind === "builtin"
|
|
49
74
|
&& other.configuration.action === "project.refresh" && other.configuration.projectId === project.id);
|
|
50
75
|
if (duplicate)
|
|
51
76
|
throw new Error(`Project refresh Job already exists: ${duplicate.id}.`);
|
|
@@ -57,7 +82,29 @@ export function listJobRuns(store, job) {
|
|
|
57
82
|
.filter(run => run.definitionId === job.id);
|
|
58
83
|
}
|
|
59
84
|
/** Read-only presentation; attempts/results remain owned by exact JobRuns. */
|
|
60
|
-
function presentDefinition(store,
|
|
85
|
+
function presentDefinition(store, definition, caller) {
|
|
86
|
+
if (definition.configuration.kind === "agent")
|
|
87
|
+
return {
|
|
88
|
+
...definition, results: summarizeAgentRuns(store.listAgentJobRuns(definition.scope, definition.id))
|
|
89
|
+
};
|
|
90
|
+
const history = listJobRuns(store, definition).filter(run => {
|
|
91
|
+
try {
|
|
92
|
+
authorizeRunRead(store, run, caller);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}).sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
99
|
+
const result = (run) => run ? {
|
|
100
|
+
runId: run.id, sourceRun: runReference(run), status: run.status,
|
|
101
|
+
outcome: run.businessResult?.outcome ?? null,
|
|
102
|
+
summary: run.businessResult?.summary.slice(0, 512) ?? null,
|
|
103
|
+
artifactCount: run.artifacts?.length ?? 0, delegation: taskDelegationView(store, run)
|
|
104
|
+
} : null;
|
|
105
|
+
const job = { ...definition, results: {
|
|
106
|
+
latest: result(history[0]), latestSuccessful: result(history.find(run => run.businessResult?.outcome === "succeeded"))
|
|
107
|
+
} };
|
|
61
108
|
if (job.configuration.kind !== "builtin" || job.configuration.action !== "project.refresh")
|
|
62
109
|
return job;
|
|
63
110
|
const projectId = job.configuration.projectId;
|
|
@@ -69,14 +116,21 @@ function presentDefinition(store, job) {
|
|
|
69
116
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
70
117
|
const summarize = (run) => run === undefined ? null : {
|
|
71
118
|
runId: run.id, definitionRevision: run.definitionRevision, trigger: run.trigger,
|
|
72
|
-
status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null
|
|
119
|
+
status: run.status, attemptedAt: run.createdAt, finishedAt: run.terminalAt ?? null,
|
|
120
|
+
summary: run.businessResult?.summary.slice(0, 512) ?? null
|
|
73
121
|
};
|
|
74
122
|
return { ...job, refresh: {
|
|
75
123
|
projectId, lastAttempt: summarize(runs[0]),
|
|
76
124
|
lastSuccessfulRefresh: summarize(runs.find(run => run.status === "succeeded"))
|
|
77
125
|
} };
|
|
78
126
|
}
|
|
79
|
-
function authorizeRunRead(store, run, caller) {
|
|
127
|
+
export function authorizeRunRead(store, run, caller) {
|
|
128
|
+
if (run.delegation) {
|
|
129
|
+
authorizeScope(store, runReference(run).scope, caller, true);
|
|
130
|
+
if (caller.scope === "task" && caller.role !== "leader")
|
|
131
|
+
throw new Error("Task delegation is not an Assignment grant.");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
80
134
|
// A revised definition's Assignment does not grant access to earlier Runs.
|
|
81
135
|
if ("taskId" in run)
|
|
82
136
|
createDurableJobControl(store).getJob(run.taskId, run.id, caller);
|
|
@@ -86,12 +140,21 @@ function authorizeRunRead(store, run, caller) {
|
|
|
86
140
|
* schedule advancement. No external effects occur here; rollback removes the
|
|
87
141
|
* admission as well. The caller must be freshly authenticated on every call.
|
|
88
142
|
*/
|
|
89
|
-
export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf) {
|
|
143
|
+
export function admitJobRun(store, home, scope, definitionId, caller, trigger, now, retryOf, service) {
|
|
90
144
|
return store.transaction(tx => {
|
|
91
145
|
const job = tx.getJobDefinition(scope, definitionId);
|
|
92
146
|
if (job === null)
|
|
93
147
|
throw new Error("Job definition not found.");
|
|
148
|
+
if (job.configuration.kind === "agent")
|
|
149
|
+
throw new Error("Agent Jobs use their explicit Role message admission; Schedule service does not grant Agent execution.");
|
|
94
150
|
authorizeDefinition(tx, job, caller, false);
|
|
151
|
+
const serviceSchedule = service === undefined ? undefined : authorizeServiceJob(tx, job, service, trigger.key);
|
|
152
|
+
if (service && (scope.kind !== "global" || trigger.source !== "schedule" || retryOf !== undefined)) {
|
|
153
|
+
throw new Error("Invalid Schedule service admission.");
|
|
154
|
+
}
|
|
155
|
+
if (!service && caller.scope === "user" && !["manual", "retry"].includes(trigger.source)) {
|
|
156
|
+
throw new Error("Public Job admission only authorizes an immediate manual execution or explicit retry.");
|
|
157
|
+
}
|
|
95
158
|
if (!trigger.key.trim() || !["manual", "retry", "schedule", "integration"].includes(trigger.source)) {
|
|
96
159
|
throw new Error("Invalid JobRun trigger.");
|
|
97
160
|
}
|
|
@@ -116,6 +179,11 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
|
|
|
116
179
|
throw new Error("Retry requires an exact failed/cancelled/timed-out Run of this Job.");
|
|
117
180
|
}
|
|
118
181
|
const requestId = `job:${job.id}:${trigger.source}:${trigger.key}`;
|
|
182
|
+
if (job.configuration.kind === "task") {
|
|
183
|
+
if (service)
|
|
184
|
+
throw new Error("Schedule service does not authorize Task delegation.");
|
|
185
|
+
return { run: admitTaskJob(tx, { ...job, configuration: job.configuration }, caller, trigger, requestId, now, retryOf), created: true };
|
|
186
|
+
}
|
|
119
187
|
if (scope.kind === "task") {
|
|
120
188
|
const result = createDurableJobControl(tx).startJob({
|
|
121
189
|
...taskParams(job, caller, requestId), definitionId, trigger, retryOf
|
|
@@ -127,30 +195,85 @@ export function admitJobRun(store, home, scope, definitionId, caller, trigger, n
|
|
|
127
195
|
const id = `job-${randomUUID()}`;
|
|
128
196
|
const locator = `artifacts/jobs/global/${id}`;
|
|
129
197
|
const spec = prepareBuiltinJob(job.configuration, join(home, locator), { home, runId: id });
|
|
130
|
-
const actorId = `global:${caller.role}`;
|
|
131
|
-
|
|
198
|
+
const actorId = service ? "service:schedule" : caller.scope === "user" ? "user" : `global:${caller.role}`;
|
|
199
|
+
let run = {
|
|
132
200
|
schemaVersion: 1, scope: "global", id, definitionId: job.id, definitionRevision: job.revision,
|
|
133
201
|
trigger: structuredClone(trigger), action: job.configuration.action,
|
|
202
|
+
...(service === undefined ? {} : { serviceAuthorization: structuredClone(service) }),
|
|
134
203
|
actionInput: structuredClone(job.configuration.input),
|
|
135
204
|
...(job.configuration.projectId === undefined ? {} : { projectId: job.configuration.projectId }),
|
|
136
205
|
...spec, status: "queued", artifactsLocator: locator,
|
|
137
206
|
idempotencyKey: createHash("sha256").update(JSON.stringify([actorId, requestId])).digest("hex"),
|
|
138
207
|
operation: {
|
|
139
|
-
requestId, actorId, authorityRef: jobAuthorityBinding(tx, "global", caller.role, ""),
|
|
208
|
+
requestId, actorId, authorityRef: service || caller.scope === "user" ? "pending" : jobAuthorityBinding(tx, "global", caller.role, ""),
|
|
140
209
|
targetId: job.id, capability: "job.run", implementation: JOB_RUNNER_IMPLEMENTATION,
|
|
141
210
|
inputDigest: createHash("sha256").update(JSON.stringify([job, trigger, retryOf])).digest("hex"),
|
|
142
211
|
effect: "none", receiptRefs: [], partialResultRefs: []
|
|
143
212
|
},
|
|
144
213
|
...(retryOf === undefined ? {} : { retryOf }),
|
|
145
214
|
createdAt: now.toISOString(), updatedAt: now.toISOString()
|
|
146
|
-
}
|
|
215
|
+
};
|
|
216
|
+
if (serviceSchedule)
|
|
217
|
+
run = { ...run, operation: { ...run.operation, authorityRef: serviceRunBinding(tx, run, serviceSchedule) } };
|
|
218
|
+
else if (caller.scope === "user")
|
|
219
|
+
run = { ...run, operation: { ...run.operation, authorityRef: publicJobBinding(tx, run) } };
|
|
220
|
+
validateDurableJob(run);
|
|
147
221
|
tx.saveGlobalJobRun(run);
|
|
148
222
|
return { run, created: true };
|
|
149
223
|
});
|
|
150
224
|
}
|
|
225
|
+
/** Job owns instantiation; Schedule supplies only the exact version and occurrence. */
|
|
226
|
+
export function instantiateJobRun(store, home, scope, definitionId, revision, caller, trigger, now, service) {
|
|
227
|
+
return store.transaction(tx => {
|
|
228
|
+
const source = tx.getJobDefinition(scope, definitionId);
|
|
229
|
+
if (!source || source.revision !== revision)
|
|
230
|
+
throw new Error("Source Job revision changed; explicitly select an available version.");
|
|
231
|
+
authorizeDefinition(tx, source, caller, true);
|
|
232
|
+
const id = `instance-${createHash("sha256").update(JSON.stringify([source.scope, source.id, trigger])).digest("hex")}`;
|
|
233
|
+
const instantiation = { sourceId: source.id, sourceRevision: revision, key: trigger.key };
|
|
234
|
+
let job = tx.getJobDefinition(source.scope, id);
|
|
235
|
+
if (job) {
|
|
236
|
+
if (JSON.stringify(job.instantiation) !== JSON.stringify(instantiation)
|
|
237
|
+
|| JSON.stringify(job.configuration) !== JSON.stringify(source.configuration)) {
|
|
238
|
+
throw new Error("Job instance identity conflicts with its original source.");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
job = validateJobDefinition({ ...createJobDefinition({ id, scope: source.scope,
|
|
243
|
+
title: source.title, configuration: source.configuration }, now), instantiation });
|
|
244
|
+
tx.saveJobDefinition(job);
|
|
245
|
+
}
|
|
246
|
+
return admitJobRun(tx, home, source.scope, job.id, caller, trigger, now, undefined, service);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function publicJobBinding(store, run) {
|
|
250
|
+
return createHash("sha256").update(JSON.stringify([
|
|
251
|
+
"public-immediate-job/v1", store.getHomeIdentity().homeId, run.id, run.definitionId, run.definitionRevision,
|
|
252
|
+
run.action, run.projectId, run.actionInput, run.trigger, run.operation.requestId
|
|
253
|
+
])).digest("hex");
|
|
254
|
+
}
|
|
151
255
|
export function authorizeGlobalJobStart(store, run) {
|
|
152
|
-
if (run.
|
|
153
|
-
|
|
256
|
+
if (run.delegation)
|
|
257
|
+
throw new Error("Task delegation cannot start a builtin process.");
|
|
258
|
+
let serviceAdmission = false;
|
|
259
|
+
if (run.operation.actorId === "service:schedule" && run.serviceAuthorization && run.trigger.source === "schedule") {
|
|
260
|
+
try {
|
|
261
|
+
const schedule = requireScheduleService(store, run.serviceAuthorization);
|
|
262
|
+
const job = store.getJobDefinition({ kind: "global" }, run.definitionId);
|
|
263
|
+
if (!job || job.revision !== run.definitionRevision)
|
|
264
|
+
throw new Error("Schedule Run Job revision changed.");
|
|
265
|
+
authorizeServiceJob(store, job, run.serviceAuthorization, run.trigger.key);
|
|
266
|
+
serviceAdmission = serviceRunBinding(store, run, schedule) === run.operation.authorityRef;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
throw Object.assign(new Error(error instanceof Error ? error.message : String(error), { cause: error }), { name: "CoreJobError" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const publicAdmission = run.operation.actorId === "user"
|
|
273
|
+
&& ["manual", "retry"].includes(run.trigger.source)
|
|
274
|
+
&& publicJobBinding(store, run) === run.operation.authorityRef;
|
|
275
|
+
if (!serviceAdmission && !publicAdmission && (run.operation.actorId !== "global:operator"
|
|
276
|
+
|| jobAuthorityBinding(store, "global", "operator", "") !== run.operation.authorityRef)) {
|
|
154
277
|
throw Object.assign(new Error("Global JobRun caller binding was revoked; no execution was started."), { name: "CoreJobError" });
|
|
155
278
|
}
|
|
156
279
|
// Storage failures are not domain refusals or proof of no execution.
|
|
@@ -162,6 +285,9 @@ export function authorizeGlobalJobStart(store, run) {
|
|
|
162
285
|
...(run.projectId === undefined ? {} : { projectId: run.projectId })
|
|
163
286
|
}, run.workspace, { home, runId: run.id });
|
|
164
287
|
if (run.action === "project.refresh") {
|
|
288
|
+
const definition = store.getJobDefinition({ kind: "global" }, run.definitionId);
|
|
289
|
+
if (definition?.builtin)
|
|
290
|
+
requireProjectSyncTarget(store, definition);
|
|
165
291
|
const project = requireRefreshProject({ listProjects: () => projects, rootDirectory: () => home }, run.projectId, true);
|
|
166
292
|
if (projectRefreshTargetDigest(project) !== projectRefreshDigest({
|
|
167
293
|
kind: "builtin", action: run.action, projectId: run.projectId, input: run.actionInput
|
|
@@ -185,11 +311,28 @@ export function createJobDefinitionControl(store, home) {
|
|
|
185
311
|
return (value, now) => {
|
|
186
312
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
187
313
|
throw new Error("Invalid Job request.");
|
|
188
|
-
|
|
314
|
+
const op = value.op;
|
|
315
|
+
if (typeof op === "string" && op.startsWith("artifact-")) {
|
|
316
|
+
return controlJobArtifact(store, home, value, now).catch(error => {
|
|
317
|
+
// Preserve bounded domain diagnostics through RPC; authentication errors
|
|
318
|
+
// retain their existing CoreApplicationError identity and code.
|
|
319
|
+
if (error instanceof ArtifactHeadConflictError || error instanceof ArtifactManagedConfigViolationError
|
|
320
|
+
|| error instanceof ArtifactRemoteViolationError || error instanceof ArtifactCommitLockedError
|
|
321
|
+
|| error instanceof ManagedGitError)
|
|
322
|
+
error.name = "CoreJobError";
|
|
323
|
+
throw error;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
requireKnownFields(value, ["op", "scope", "caller", "id", "title", "configuration", "expectedRevision", "trigger", "retryOf", "runId", "result", "expectedSessionId", "expectedAttemptId"], "Job request");
|
|
189
327
|
const input = value;
|
|
190
|
-
|
|
328
|
+
// A Job execution reader is authorized against its exact definition below,
|
|
329
|
+
// not promoted to Operator or to a Task manager.
|
|
330
|
+
if (!(input.caller.scope === "global" && input.caller.role !== "operator")
|
|
331
|
+
&& !(input.caller.scope === "task" && input.scope.kind === "global"))
|
|
332
|
+
authorizeScope(store, input.scope, input.caller, true);
|
|
191
333
|
return store.transaction(tx => {
|
|
192
334
|
if (input.op === "list") {
|
|
335
|
+
authorizeScope(tx, input.scope, input.caller, true);
|
|
193
336
|
return tx.listJobDefinitions(input.scope).filter(job => {
|
|
194
337
|
try {
|
|
195
338
|
authorizeDefinition(tx, job, input.caller, false);
|
|
@@ -198,12 +341,15 @@ export function createJobDefinitionControl(store, home) {
|
|
|
198
341
|
catch {
|
|
199
342
|
return false;
|
|
200
343
|
}
|
|
201
|
-
}).map(job => presentDefinition(tx, job));
|
|
344
|
+
}).map(job => presentDefinition(tx, job, input.caller));
|
|
202
345
|
}
|
|
203
346
|
if (typeof input.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/u.test(input.id))
|
|
204
347
|
throw new Error("An explicit safe Job id is required.");
|
|
205
348
|
const existing = tx.getJobDefinition(input.scope, input.id);
|
|
206
349
|
if (input.op === "define" || input.op === "update") {
|
|
350
|
+
if (existing?.builtin || (!existing && input.scope.kind === "global" && input.id.startsWith("project-sync-"))) {
|
|
351
|
+
throw new Error("Builtin Project sync definitions are installer-owned; edit their Schedule.");
|
|
352
|
+
}
|
|
207
353
|
let configuration = input.configuration;
|
|
208
354
|
// Empty input is an explicit request to bind the current catalog target.
|
|
209
355
|
// A saved digest is never silently refreshed on run/retry.
|
|
@@ -238,9 +384,11 @@ export function createJobDefinitionControl(store, home) {
|
|
|
238
384
|
if (existing === null)
|
|
239
385
|
throw new Error("Job definition not found.");
|
|
240
386
|
validateJobDefinition(existing);
|
|
387
|
+
if (existing.configuration.kind === "agent")
|
|
388
|
+
return controlAgentJob(tx, home, existing, input, now);
|
|
241
389
|
authorizeDefinition(tx, existing, input.caller, false);
|
|
242
390
|
if (input.op === "show")
|
|
243
|
-
return presentDefinition(tx, existing);
|
|
391
|
+
return presentDefinition(tx, existing, input.caller);
|
|
244
392
|
if (input.op === "runs")
|
|
245
393
|
return listJobRuns(tx, existing).filter(run => {
|
|
246
394
|
try {
|
|
@@ -263,8 +411,24 @@ export function createJobDefinitionControl(store, home) {
|
|
|
263
411
|
if (run === undefined)
|
|
264
412
|
throw new Error("Exact JobRun not found.");
|
|
265
413
|
authorizeRunRead(tx, run, input.caller);
|
|
266
|
-
if (input.op === "run-
|
|
267
|
-
return run;
|
|
414
|
+
if (input.op === "run-delegation")
|
|
415
|
+
return taskDelegationView(tx, run);
|
|
416
|
+
if (input.op === "run-get" || input.op === "run-confirm")
|
|
417
|
+
return input.op === "run-confirm" && run.delegation
|
|
418
|
+
? reconcileTaskDelegation(tx, run, now) : run;
|
|
419
|
+
if (input.op === "run-result")
|
|
420
|
+
return {
|
|
421
|
+
snapshot: jobRunSnapshot(run),
|
|
422
|
+
acceptedAt: run.createdAt, startedAt: run.startedAt ?? null,
|
|
423
|
+
confirmedAt: run.businessResult === undefined ? null : run.terminalAt ?? null,
|
|
424
|
+
status: run.status, result: run.businessResult ?? null, artifacts: run.artifacts ?? []
|
|
425
|
+
};
|
|
426
|
+
if (input.op === "run-evidence")
|
|
427
|
+
return {
|
|
428
|
+
sourceRun: jobRunSnapshot(run), operation: run.operation,
|
|
429
|
+
process: run.process ?? null, checkpoint: run.checkpoint ?? null,
|
|
430
|
+
processResult: run.result ?? null, artifactsLocator: run.artifactsLocator
|
|
431
|
+
};
|
|
268
432
|
if (input.op === "logs") {
|
|
269
433
|
const steps = run.result?.steps ?? createFileArtifactPort(home)
|
|
270
434
|
.readCheckpoint(executionNamespace(run), run.id)?.completedSteps ?? run.checkpoint?.completedSteps ?? [];
|
|
@@ -288,6 +452,14 @@ export function createJobDefinitionControl(store, home) {
|
|
|
288
452
|
});
|
|
289
453
|
}
|
|
290
454
|
if (input.op === "run-cancel" || input.op === "run-acknowledge") {
|
|
455
|
+
if (run.delegation) {
|
|
456
|
+
const next = input.op === "run-cancel" ? requestDurableJobCancel(run, now) : acknowledgeUnknownDurableJob(run, now);
|
|
457
|
+
if ("taskId" in next)
|
|
458
|
+
tx.saveDurableJob(next.taskId, next);
|
|
459
|
+
else
|
|
460
|
+
tx.saveGlobalJobRun(next);
|
|
461
|
+
return next;
|
|
462
|
+
}
|
|
291
463
|
if ("taskId" in run) {
|
|
292
464
|
const control = createDurableJobControl(tx);
|
|
293
465
|
return input.op === "run-cancel"
|