@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -14,9 +14,11 @@ import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
|
14
14
|
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
15
15
|
import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
|
|
16
16
|
import { activeLiveRoleAgentSession } from "./agentExecutor.js";
|
|
17
|
-
import { effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
|
|
17
|
+
import { effectiveLaunchSnapshotsCompatibleForTaskMain, effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
|
|
18
18
|
import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeState, createExactControlPlaneDescriptor, createExactTaskRuntimeDescriptor, exactControlPlaneCommandPrefix, exactControlPlaneDigest, exactTaskRuntimeDescriptorPath, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
|
|
19
|
+
import { readActiveReleasePointer } from "../release/runtimeRelease.js";
|
|
19
20
|
import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
|
|
21
|
+
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
20
22
|
/** Builds managed native Agent launches from the authoritative Task records. */
|
|
21
23
|
export class FileRoleLaunchPlanner {
|
|
22
24
|
home;
|
|
@@ -27,6 +29,7 @@ export class FileRoleLaunchPlanner {
|
|
|
27
29
|
#createNativeSessionId;
|
|
28
30
|
#cliPath;
|
|
29
31
|
#controlPlane;
|
|
32
|
+
#resourceRegistrarValue;
|
|
30
33
|
constructor(home, store, options = {}) {
|
|
31
34
|
this.home = home;
|
|
32
35
|
this.store = store;
|
|
@@ -46,16 +49,38 @@ export class FileRoleLaunchPlanner {
|
|
|
46
49
|
// Freeze the complete control identity before any native process exists.
|
|
47
50
|
// This value is immutable for the planner/Controller lifetime and is never
|
|
48
51
|
// rewritten through a shared PATH launcher.
|
|
52
|
+
// Issue 02: bind the active release build ID so a Session created after a
|
|
53
|
+
// handover cannot mutate a different control plane.
|
|
54
|
+
const activeRelease = readActiveReleasePointer(this.home);
|
|
49
55
|
this.#controlPlane = createExactControlPlaneDescriptor({
|
|
50
56
|
executable: process.execPath,
|
|
51
57
|
cliEntry: this.#cliPath,
|
|
52
|
-
yuiHome: this.home
|
|
58
|
+
yuiHome: this.home,
|
|
59
|
+
...(activeRelease === null
|
|
60
|
+
? {}
|
|
61
|
+
: {
|
|
62
|
+
buildId: activeRelease.buildId,
|
|
63
|
+
activeReleaseDigest: activeRelease.packageDigest
|
|
64
|
+
})
|
|
53
65
|
});
|
|
54
66
|
}
|
|
67
|
+
#resourceRegistrar() {
|
|
68
|
+
return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home);
|
|
69
|
+
}
|
|
55
70
|
refreshAgentEnvironment(refresh) {
|
|
56
71
|
this.#agentEnvironment = patchEnvironment(this.#agentEnvironment, refresh.sourceNames, refresh.sources);
|
|
57
72
|
this.#nativeAgentEnvironment = patchEnvironment(this.#nativeAgentEnvironment, refresh.nativeNames, refresh.nativeSources);
|
|
58
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Commit the caller key for a task Session only after the runtime host has
|
|
76
|
+
* confirmed that it created a new native process. An ensure/resume request
|
|
77
|
+
* that reuses a live host must keep the old durable hash because its process
|
|
78
|
+
* still carries the old plaintext key.
|
|
79
|
+
*/
|
|
80
|
+
commitTaskCallerKey(input) {
|
|
81
|
+
const hash = createHash("sha256").update(input.callerKey).digest("hex");
|
|
82
|
+
this.store.setJobCallerKeyHash(input.taskId, input.roleName, input.agentId, hash);
|
|
83
|
+
}
|
|
59
84
|
/** Re-publishes provider-discovered native identity after its durable fold. */
|
|
60
85
|
refreshTaskRuntimeDescriptor(input) {
|
|
61
86
|
const task = this.store.getTask(input.taskId);
|
|
@@ -81,8 +106,20 @@ export class FileRoleLaunchPlanner {
|
|
|
81
106
|
launchId: session.launchId,
|
|
82
107
|
nativeSessionId: session.nativeSessionId
|
|
83
108
|
});
|
|
109
|
+
if (descriptor.runId !== input.runId
|
|
110
|
+
|| descriptor.launchId !== input.launchId
|
|
111
|
+
|| descriptor.nativeSessionId !== input.nativeSessionId
|
|
112
|
+
|| descriptor.agentId !== input.agentId
|
|
113
|
+
|| descriptor.adapterId !== input.adapterId
|
|
114
|
+
|| descriptor.workspace !== canonicalPath(input.workspace)) {
|
|
115
|
+
throw new Error("Prepared Task runtime generation is not current.");
|
|
116
|
+
}
|
|
84
117
|
assertExactTaskRuntimeState(descriptor, this.store);
|
|
85
|
-
|
|
118
|
+
// Publish only the current-control source. A reused native pane keeps its
|
|
119
|
+
// own stable source path; its Hook self-refreshes that source before the
|
|
120
|
+
// volatile fence instead of the Controller scanning history to find it.
|
|
121
|
+
const currentSource = exactTaskRuntimeDescriptorPath(this.home, descriptor);
|
|
122
|
+
this.#writeExactTaskRuntimeDescriptor(descriptor, currentSource);
|
|
86
123
|
}
|
|
87
124
|
plan(input) {
|
|
88
125
|
const task = this.store.getTask(input.taskId);
|
|
@@ -175,7 +212,9 @@ export class FileRoleLaunchPlanner {
|
|
|
175
212
|
const effective = input.effective ?? resolvedEffective;
|
|
176
213
|
const existing = sessionSet?.sessions[effective.agentId];
|
|
177
214
|
const compatibleExisting = existing !== undefined
|
|
178
|
-
&&
|
|
215
|
+
&& (input.mode === "resume"
|
|
216
|
+
? effectiveLaunchSnapshotsCompatibleForTaskMain(existing.effective, effective, runWorkspace ?? workspace)
|
|
217
|
+
: effectiveLaunchSnapshotsCompatible(existing.effective, effective));
|
|
179
218
|
if (input.mode === "resume" && !compatibleExisting) {
|
|
180
219
|
throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
|
|
181
220
|
}
|
|
@@ -309,6 +348,9 @@ export class FileRoleLaunchPlanner {
|
|
|
309
348
|
nativeSessionId: resumeNativeSessionId
|
|
310
349
|
})
|
|
311
350
|
: adapter.compileNew(compileInput);
|
|
351
|
+
if (owner.scope === "task" && sessionContext.managedContextFile !== undefined) {
|
|
352
|
+
this.#resourceRegistrar().registerSessionContext(sessionContext.managedContextFile, { home: resolve(this.home), taskId: owner.taskId, basis: "descriptor" });
|
|
353
|
+
}
|
|
312
354
|
let args = [...compiled.argv];
|
|
313
355
|
let session;
|
|
314
356
|
if (binding.adapterId === "codex") {
|
|
@@ -365,7 +407,15 @@ export class FileRoleLaunchPlanner {
|
|
|
365
407
|
? undefined
|
|
366
408
|
: exactTaskRuntimeDescriptorPath(this.home, runtimeDescriptor);
|
|
367
409
|
if (runtimeDescriptor !== undefined && runtimeDescriptorSource !== undefined) {
|
|
368
|
-
|
|
410
|
+
this.#writeExactTaskRuntimeDescriptor(runtimeDescriptor, runtimeDescriptorSource);
|
|
411
|
+
}
|
|
412
|
+
// rr13/rr26: Generate a per-Session DurableJob caller key for every
|
|
413
|
+
// task-scope launch, including resume. Resume/ensure may reuse a live
|
|
414
|
+
// host, so its candidate hash is committed by TmuxSessionHost only when
|
|
415
|
+
// hostCreated=true; otherwise the live process keeps its existing key.
|
|
416
|
+
let jobCallerKey;
|
|
417
|
+
if (owner.scope === "task" && (input.mode === "new" || input.mode === "resume")) {
|
|
418
|
+
jobCallerKey = randomBytes(32).toString("hex");
|
|
369
419
|
}
|
|
370
420
|
const launch = {
|
|
371
421
|
command: configured.command,
|
|
@@ -379,6 +429,7 @@ export class FileRoleLaunchPlanner {
|
|
|
379
429
|
YUI_AGENT_ID: configured.id,
|
|
380
430
|
YUI_ADAPTER_ID: configured.adapterId,
|
|
381
431
|
YUI_WORKSPACE: effectiveWorkspace,
|
|
432
|
+
...(jobCallerKey === undefined ? {} : { YUI_JOB_CALLER_KEY: jobCallerKey }),
|
|
382
433
|
...(runtimeDescriptor === undefined
|
|
383
434
|
? {}
|
|
384
435
|
: {
|
|
@@ -439,6 +490,10 @@ export class FileRoleLaunchPlanner {
|
|
|
439
490
|
}
|
|
440
491
|
};
|
|
441
492
|
}
|
|
493
|
+
#writeExactTaskRuntimeDescriptor(descriptor, sourcePath) {
|
|
494
|
+
writeTextFileAtomically(sourcePath, `${serializeExactDescriptor(descriptor)}\n`);
|
|
495
|
+
this.#resourceRegistrar().registerExactTaskRuntimeDescriptor(descriptor, sourcePath);
|
|
496
|
+
}
|
|
442
497
|
#selectConfiguredAgentEnvironment(source) {
|
|
443
498
|
const selected = {};
|
|
444
499
|
for (const agent of this.store.listConfiguredAgents()) {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { requireIdentity, requireText, requireTimestamp, requirePositiveInteger } from "../domain/validation.js";
|
|
2
|
+
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
3
|
+
export const CAPABILITY_GRANT_SCHEMA_VERSION = 1;
|
|
4
|
+
const IRREVERSIBILITY_RANK = {
|
|
5
|
+
none: 0,
|
|
6
|
+
reversible: 1,
|
|
7
|
+
irreversible: 2
|
|
8
|
+
};
|
|
9
|
+
export function createCapabilityGrant(id, taskId, input, now) {
|
|
10
|
+
const timestamp = now.toISOString();
|
|
11
|
+
return validateCapabilityGrant({
|
|
12
|
+
schemaVersion: CAPABILITY_GRANT_SCHEMA_VERSION,
|
|
13
|
+
id: validateTaskRecordReference({ taskId, localId: id }, "capabilityGrant").localId,
|
|
14
|
+
taskId: requireIdentity(taskId, "Task id"),
|
|
15
|
+
granter: requireText(input.granter, "Capability grant granter"),
|
|
16
|
+
scope: normalizeScope(input.scope, taskId),
|
|
17
|
+
actions: normalizeActions(input.actions),
|
|
18
|
+
parameterBounds: normalizeParameterBounds(input.parameterBounds),
|
|
19
|
+
...(input.expiresAt === undefined ? {} : { expiresAt: requireTimestamp(input.expiresAt, "Capability grant expiresAt") }),
|
|
20
|
+
...(input.maxUses === undefined ? {} : { maxUses: requirePositiveInteger(input.maxUses, "Capability grant maxUses") }),
|
|
21
|
+
usesUsed: 0,
|
|
22
|
+
irreversibilityCeiling: normalizeCeiling(input.irreversibilityCeiling),
|
|
23
|
+
createdAt: timestamp,
|
|
24
|
+
updatedAt: timestamp
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The pure authorization decision. Every denial carries a distinct
|
|
29
|
+
* machine-readable reason so a release workflow can fail closed precisely.
|
|
30
|
+
*
|
|
31
|
+
* `skipUsesCheck` is used when re-attempting a step that was already running:
|
|
32
|
+
* the grant use was already consumed for this step before the crash, so the
|
|
33
|
+
* uses check is skipped to avoid stranding the workflow.
|
|
34
|
+
*/
|
|
35
|
+
export function checkGrant(grant, request, now, options) {
|
|
36
|
+
validateCapabilityGrant(grant);
|
|
37
|
+
if (grant.revokedAt !== undefined) {
|
|
38
|
+
return { allowed: false, reason: "grant-revoked" };
|
|
39
|
+
}
|
|
40
|
+
if (grant.expiresAt !== undefined && now.getTime() > Date.parse(grant.expiresAt)) {
|
|
41
|
+
return { allowed: false, reason: "grant-expired" };
|
|
42
|
+
}
|
|
43
|
+
if (!options?.skipUsesCheck && grant.maxUses !== undefined && grant.usesUsed >= grant.maxUses) {
|
|
44
|
+
return { allowed: false, reason: "grant-uses-exhausted" };
|
|
45
|
+
}
|
|
46
|
+
if (!grant.actions.includes(request.action)) {
|
|
47
|
+
return { allowed: false, reason: "grant-action-not-allowed" };
|
|
48
|
+
}
|
|
49
|
+
for (const [name, allowedValues] of Object.entries(grant.parameterBounds)) {
|
|
50
|
+
const value = request.params?.[name];
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
return { allowed: false, reason: "grant-parameter-missing" };
|
|
53
|
+
}
|
|
54
|
+
if (!allowedValues.includes(value)) {
|
|
55
|
+
return { allowed: false, reason: "grant-parameter-value-not-allowed" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const requested = IRREVERSIBILITY_RANK[request.irreversibility ?? "none"];
|
|
59
|
+
if (requested > IRREVERSIBILITY_RANK[grant.irreversibilityCeiling]) {
|
|
60
|
+
return { allowed: false, reason: "grant-irreversibility-exceeds-ceiling" };
|
|
61
|
+
}
|
|
62
|
+
return { allowed: true };
|
|
63
|
+
}
|
|
64
|
+
/** The reservation keys recorded on a grant, or an empty list for legacy grants. */
|
|
65
|
+
export function grantUseReservations(grant) {
|
|
66
|
+
return grant.useReservations ?? [];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record one use against the grant, failing closed once the limit is reached.
|
|
70
|
+
* When `reservationKey` is supplied it is appended to the grant's durable
|
|
71
|
+
* reservations so a later resume can recognize this exact attempt and avoid a
|
|
72
|
+
* double charge. A use recorded without a key (legacy callers) leaves the
|
|
73
|
+
* reservation list untouched.
|
|
74
|
+
*/
|
|
75
|
+
export function recordGrantUse(grant, now, reservationKey) {
|
|
76
|
+
validateCapabilityGrant(grant);
|
|
77
|
+
if (grant.maxUses !== undefined && grant.usesUsed >= grant.maxUses) {
|
|
78
|
+
throw new Error(`Capability grant is exhausted: ${grant.id}.`);
|
|
79
|
+
}
|
|
80
|
+
return validateCapabilityGrant({
|
|
81
|
+
...grant,
|
|
82
|
+
usesUsed: grant.usesUsed + 1,
|
|
83
|
+
...(reservationKey === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { useReservations: Object.freeze([...grantUseReservations(grant), reservationKey]) }),
|
|
86
|
+
updatedAt: now.toISOString()
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Revoke a grant. Re-revoking an already-revoked grant is idempotent: the
|
|
91
|
+
* original revocation stands and the record is returned unchanged.
|
|
92
|
+
*/
|
|
93
|
+
export function revokeGrant(grant, revokedBy, now) {
|
|
94
|
+
validateCapabilityGrant(grant);
|
|
95
|
+
if (grant.revokedAt !== undefined)
|
|
96
|
+
return grant;
|
|
97
|
+
const actor = requireText(revokedBy, "Capability grant revokedBy");
|
|
98
|
+
const timestamp = now.toISOString();
|
|
99
|
+
return validateCapabilityGrant({
|
|
100
|
+
...grant,
|
|
101
|
+
revokedAt: timestamp,
|
|
102
|
+
revokedBy: actor,
|
|
103
|
+
updatedAt: timestamp
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function validateCapabilityGrant(grant) {
|
|
107
|
+
if (grant.schemaVersion !== CAPABILITY_GRANT_SCHEMA_VERSION) {
|
|
108
|
+
throw new Error("Capability grant must use schemaVersion 1.");
|
|
109
|
+
}
|
|
110
|
+
validateTaskRecordReference({ taskId: grant.taskId, localId: grant.id }, "capabilityGrant");
|
|
111
|
+
requireIdentity(grant.taskId, "Task id");
|
|
112
|
+
requireText(grant.granter, "Capability grant granter");
|
|
113
|
+
validateScope(grant.scope, grant.taskId);
|
|
114
|
+
normalizeActions(grant.actions);
|
|
115
|
+
normalizeParameterBounds(grant.parameterBounds);
|
|
116
|
+
if (grant.expiresAt !== undefined) {
|
|
117
|
+
requireTimestamp(grant.expiresAt, "Capability grant expiresAt");
|
|
118
|
+
}
|
|
119
|
+
if (grant.maxUses !== undefined) {
|
|
120
|
+
requirePositiveInteger(grant.maxUses, "Capability grant maxUses");
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isSafeInteger(grant.usesUsed) || grant.usesUsed < 0) {
|
|
123
|
+
throw new Error("Capability grant usesUsed must be a non-negative integer.");
|
|
124
|
+
}
|
|
125
|
+
if (grant.maxUses !== undefined && grant.usesUsed > grant.maxUses) {
|
|
126
|
+
throw new Error("Capability grant usesUsed cannot exceed maxUses.");
|
|
127
|
+
}
|
|
128
|
+
if (grant.useReservations !== undefined) {
|
|
129
|
+
if (!Array.isArray(grant.useReservations)) {
|
|
130
|
+
throw new Error("Capability grant useReservations must be an array.");
|
|
131
|
+
}
|
|
132
|
+
for (const key of grant.useReservations) {
|
|
133
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
134
|
+
throw new Error("Capability grant useReservations must be non-empty strings.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (new Set(grant.useReservations).size !== grant.useReservations.length) {
|
|
138
|
+
throw new Error("Capability grant useReservations must be unique.");
|
|
139
|
+
}
|
|
140
|
+
if (grant.useReservations.length > grant.usesUsed) {
|
|
141
|
+
throw new Error("Capability grant useReservations cannot outnumber usesUsed.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
normalizeCeiling(grant.irreversibilityCeiling);
|
|
145
|
+
if ((grant.revokedAt === undefined) !== (grant.revokedBy === undefined)) {
|
|
146
|
+
throw new Error("Capability grant revocation requires both revokedAt and revokedBy.");
|
|
147
|
+
}
|
|
148
|
+
if (grant.revokedAt !== undefined) {
|
|
149
|
+
requireTimestamp(grant.revokedAt, "Capability grant revokedAt");
|
|
150
|
+
requireText(grant.revokedBy, "Capability grant revokedBy");
|
|
151
|
+
}
|
|
152
|
+
requireTimestamp(grant.createdAt, "Capability grant createdAt");
|
|
153
|
+
requireTimestamp(grant.updatedAt, "Capability grant updatedAt");
|
|
154
|
+
return grant;
|
|
155
|
+
}
|
|
156
|
+
function normalizeCeiling(value) {
|
|
157
|
+
if (value === undefined)
|
|
158
|
+
return "none";
|
|
159
|
+
if (!Object.hasOwn(IRREVERSIBILITY_RANK, value)) {
|
|
160
|
+
throw new Error(`Capability grant irreversibility ceiling is invalid: ${String(value)}.`);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
function normalizeActions(actions) {
|
|
165
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
166
|
+
throw new Error("Capability grant actions must be a non-empty array.");
|
|
167
|
+
}
|
|
168
|
+
const normalized = actions.map((action) => requireText(action, "Capability grant action"));
|
|
169
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
170
|
+
throw new Error("Capability grant actions must be unique.");
|
|
171
|
+
}
|
|
172
|
+
return Object.freeze(normalized);
|
|
173
|
+
}
|
|
174
|
+
function normalizeParameterBounds(bounds) {
|
|
175
|
+
if (bounds === undefined)
|
|
176
|
+
return Object.freeze({});
|
|
177
|
+
if (typeof bounds !== "object" || bounds === null || Array.isArray(bounds)) {
|
|
178
|
+
throw new Error("Capability grant parameterBounds must be an object.");
|
|
179
|
+
}
|
|
180
|
+
const normalized = {};
|
|
181
|
+
for (const [name, values] of Object.entries(bounds)) {
|
|
182
|
+
const key = requireIdentity(name, "Capability grant parameter");
|
|
183
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
184
|
+
throw new Error(`Capability grant parameter bound must list allowed values: ${name}.`);
|
|
185
|
+
}
|
|
186
|
+
const allowed = values.map((value) => requireText(value, `Capability grant parameter ${name} value`));
|
|
187
|
+
if (new Set(allowed).size !== allowed.length) {
|
|
188
|
+
throw new Error(`Capability grant parameter bound values must be unique: ${name}.`);
|
|
189
|
+
}
|
|
190
|
+
normalized[key] = Object.freeze(allowed);
|
|
191
|
+
}
|
|
192
|
+
return Object.freeze(normalized);
|
|
193
|
+
}
|
|
194
|
+
function normalizeScope(scope, taskId) {
|
|
195
|
+
if (scope === undefined)
|
|
196
|
+
return Object.freeze({ taskId });
|
|
197
|
+
validateScope(scope, taskId);
|
|
198
|
+
const normalized = {
|
|
199
|
+
...(scope.taskId === undefined ? {} : { taskId: requireIdentity(scope.taskId, "Capability grant scope taskId") }),
|
|
200
|
+
...(scope.projectIds === undefined || scope.projectIds.length === 0
|
|
201
|
+
? {}
|
|
202
|
+
: { projectIds: normalizeScopeIdentities(scope.projectIds, "Capability grant scope Project") }),
|
|
203
|
+
...(scope.repositories === undefined || scope.repositories.length === 0
|
|
204
|
+
? {}
|
|
205
|
+
: { repositories: normalizeRepositories(scope.repositories) }),
|
|
206
|
+
...(scope.packages === undefined || scope.packages.length === 0
|
|
207
|
+
? {}
|
|
208
|
+
: { packages: normalizeScopeText(scope.packages, "Capability grant scope package") }),
|
|
209
|
+
...(scope.homePath === undefined ? {} : { homePath: requireText(scope.homePath, "Capability grant scope homePath") })
|
|
210
|
+
};
|
|
211
|
+
if (!hasScopeSelector(normalized)) {
|
|
212
|
+
return Object.freeze({ taskId });
|
|
213
|
+
}
|
|
214
|
+
return Object.freeze(normalized);
|
|
215
|
+
}
|
|
216
|
+
function validateScope(scope, taskId) {
|
|
217
|
+
if (typeof scope !== "object" || scope === null || Array.isArray(scope)) {
|
|
218
|
+
throw new Error("Capability grant scope must be an object.");
|
|
219
|
+
}
|
|
220
|
+
if (scope.taskId !== undefined) {
|
|
221
|
+
requireIdentity(scope.taskId, "Capability grant scope taskId");
|
|
222
|
+
}
|
|
223
|
+
if (scope.projectIds !== undefined) {
|
|
224
|
+
normalizeScopeIdentities(scope.projectIds, "Capability grant scope Project");
|
|
225
|
+
}
|
|
226
|
+
if (scope.repositories !== undefined) {
|
|
227
|
+
normalizeRepositories(scope.repositories);
|
|
228
|
+
}
|
|
229
|
+
if (scope.packages !== undefined) {
|
|
230
|
+
normalizeScopeText(scope.packages, "Capability grant scope package");
|
|
231
|
+
}
|
|
232
|
+
if (scope.homePath !== undefined) {
|
|
233
|
+
requireText(scope.homePath, "Capability grant scope homePath");
|
|
234
|
+
}
|
|
235
|
+
if (!hasScopeSelector(scope) && scope.taskId === undefined) {
|
|
236
|
+
throw new Error(`Capability grant scope requires at least one selector: ${taskId}.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function hasScopeSelector(scope) {
|
|
240
|
+
return scope.taskId !== undefined
|
|
241
|
+
|| (scope.projectIds?.length ?? 0) > 0
|
|
242
|
+
|| (scope.repositories?.length ?? 0) > 0
|
|
243
|
+
|| (scope.packages?.length ?? 0) > 0
|
|
244
|
+
|| scope.homePath !== undefined;
|
|
245
|
+
}
|
|
246
|
+
function normalizeScopeIdentities(values, label) {
|
|
247
|
+
if (!Array.isArray(values))
|
|
248
|
+
throw new Error(`${label} ids must be an array.`);
|
|
249
|
+
const normalized = values.map((value) => requireIdentity(value, label));
|
|
250
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
251
|
+
throw new Error(`${label} ids must be unique.`);
|
|
252
|
+
}
|
|
253
|
+
return Object.freeze(normalized);
|
|
254
|
+
}
|
|
255
|
+
function normalizeScopeText(values, label) {
|
|
256
|
+
if (!Array.isArray(values))
|
|
257
|
+
throw new Error(`${label}s must be an array.`);
|
|
258
|
+
const normalized = values.map((value) => requireText(value, label));
|
|
259
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
260
|
+
throw new Error(`${label}s must be unique.`);
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze(normalized);
|
|
263
|
+
}
|
|
264
|
+
function normalizeRepositories(repositories) {
|
|
265
|
+
if (!Array.isArray(repositories)) {
|
|
266
|
+
throw new Error("Capability grant scope repositories must be an array.");
|
|
267
|
+
}
|
|
268
|
+
const normalized = repositories.map((repository) => {
|
|
269
|
+
if (typeof repository !== "object" || repository === null) {
|
|
270
|
+
throw new Error("Capability grant scope repository is invalid.");
|
|
271
|
+
}
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
owner: requireText(repository.owner, "Capability grant scope repository owner"),
|
|
274
|
+
name: requireText(repository.name, "Capability grant scope repository name")
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
const keys = new Set(normalized.map(({ owner, name }) => `${owner}/${name}`));
|
|
278
|
+
if (keys.size !== normalized.length) {
|
|
279
|
+
throw new Error("Capability grant scope repositories must be unique.");
|
|
280
|
+
}
|
|
281
|
+
return Object.freeze(normalized);
|
|
282
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { normalizedUniqueText, requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
|
|
2
2
|
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
3
|
+
import { validateChangeSetManifest } from "./changeSetManifest.js";
|
|
3
4
|
export function createWorkItemChangeSet(input, now) {
|
|
4
5
|
return validateChangeSet({
|
|
5
|
-
schemaVersion:
|
|
6
|
+
schemaVersion: 3,
|
|
6
7
|
...input,
|
|
7
8
|
changedPaths: [...input.changedPaths],
|
|
9
|
+
...(input.manifest === undefined
|
|
10
|
+
? {}
|
|
11
|
+
: { manifest: validateChangeSetManifest(input.manifest) }),
|
|
8
12
|
createdAt: now.toISOString()
|
|
9
13
|
});
|
|
10
14
|
}
|
|
11
15
|
export function validateChangeSet(changeSet) {
|
|
12
|
-
if (changeSet.schemaVersion !==
|
|
13
|
-
throw new Error("ChangeSet must use schemaVersion
|
|
16
|
+
if (changeSet.schemaVersion !== 3)
|
|
17
|
+
throw new Error("ChangeSet must use schemaVersion 3.");
|
|
14
18
|
validateTaskRecordReference({ taskId: changeSet.taskId, localId: changeSet.id }, "changeSet");
|
|
15
19
|
validateTaskRecordReference({
|
|
16
20
|
taskId: changeSet.taskId,
|
|
@@ -24,6 +28,15 @@ export function validateChangeSet(changeSet) {
|
|
|
24
28
|
}
|
|
25
29
|
requireText(changeSet.branch, "ChangeSet branch");
|
|
26
30
|
normalizedUniqueText(changeSet.changedPaths, "ChangeSet path");
|
|
31
|
+
if (changeSet.manifest !== undefined) {
|
|
32
|
+
const manifest = validateChangeSetManifest(changeSet.manifest);
|
|
33
|
+
const changed = new Set(changeSet.changedPaths);
|
|
34
|
+
for (const deleted of manifest.deletedPaths) {
|
|
35
|
+
if (!changed.has(deleted)) {
|
|
36
|
+
throw new Error(`ChangeSet manifest deleted path is not a changed path: ${deleted}.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
27
40
|
requireTimestamp(changeSet.createdAt, "ChangeSet createdAt");
|
|
28
41
|
return changeSet;
|
|
29
42
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { normalizedUniqueText, requireText } from "../domain/validation.js";
|
|
2
|
+
export const CHANGE_SET_MANIFEST_SCHEMA_VERSION = 1;
|
|
3
|
+
export const CHANGE_SET_MANIFEST_TAGS = [
|
|
4
|
+
"contract",
|
|
5
|
+
"schema",
|
|
6
|
+
"migration",
|
|
7
|
+
"command",
|
|
8
|
+
"test",
|
|
9
|
+
"snapshot",
|
|
10
|
+
"package",
|
|
11
|
+
"deletion"
|
|
12
|
+
];
|
|
13
|
+
export function createChangeSetManifest(input) {
|
|
14
|
+
return validateChangeSetManifest({
|
|
15
|
+
schemaVersion: CHANGE_SET_MANIFEST_SCHEMA_VERSION,
|
|
16
|
+
tags: [...input.tags],
|
|
17
|
+
deletedPaths: [...input.deletedPaths],
|
|
18
|
+
evidenceRefs: [...(input.evidenceRefs ?? [])],
|
|
19
|
+
...(input.targetRef === undefined ? {} : { targetRef: input.targetRef })
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function validateChangeSetManifest(manifest) {
|
|
23
|
+
if (manifest.schemaVersion !== CHANGE_SET_MANIFEST_SCHEMA_VERSION) {
|
|
24
|
+
throw new Error("ChangeSet manifest must use schemaVersion 1.");
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(manifest.tags)) {
|
|
27
|
+
throw new Error("ChangeSet manifest tags must be an array.");
|
|
28
|
+
}
|
|
29
|
+
for (const tag of manifest.tags) {
|
|
30
|
+
if (!CHANGE_SET_MANIFEST_TAGS.includes(tag)) {
|
|
31
|
+
throw new Error(`ChangeSet manifest tag is invalid: ${String(tag)}.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const tags = normalizedUniqueText(manifest.tags, "ChangeSet manifest tag");
|
|
35
|
+
const deletedPaths = normalizedUniqueText(manifest.deletedPaths, "ChangeSet manifest deleted path");
|
|
36
|
+
const evidenceRefs = normalizedUniqueText(manifest.evidenceRefs ?? [], "ChangeSet manifest evidence reference");
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: CHANGE_SET_MANIFEST_SCHEMA_VERSION,
|
|
39
|
+
tags,
|
|
40
|
+
deletedPaths,
|
|
41
|
+
evidenceRefs,
|
|
42
|
+
...(manifest.targetRef === undefined ? {} : {
|
|
43
|
+
targetRef: requireText(manifest.targetRef, "ChangeSet manifest target ref")
|
|
44
|
+
})
|
|
45
|
+
};
|
|
46
|
+
}
|