@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,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource GC engine (Issue 10).
|
|
3
|
+
*
|
|
4
|
+
* Quarantine-first garbage collection for managed worktrees, deployments, and
|
|
5
|
+
* runtime artifacts. Permanent deletion is always delayed behind an
|
|
6
|
+
* observation window; anything whose ownership, cleanliness, or liveness
|
|
7
|
+
* cannot be proven is retained and reported.
|
|
8
|
+
*
|
|
9
|
+
* Safety model:
|
|
10
|
+
* - `planResourceGc` is strictly read-only: it discovers, scans, classifies,
|
|
11
|
+
* and returns a plan. It never writes the registry or moves files.
|
|
12
|
+
* - `applyResourceGc` re-discovers and re-scans live references immediately
|
|
13
|
+
* before each mutation, so a stale plan can never release a resource that
|
|
14
|
+
* gained a reference after planning.
|
|
15
|
+
* - `purgeResourceQuarantine` scans both the original and quarantine paths;
|
|
16
|
+
* a file held open inside quarantine triggers a restore instead of deletion.
|
|
17
|
+
* - `restoreAllResourceGc` is the explicit rollback entry point.
|
|
18
|
+
*/
|
|
19
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
20
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
import { removeResourceRecord, isResourceQuarantinePath, resourceQuarantineRoot, upsertResourceRecord } from "./resourceRegistry.js";
|
|
24
|
+
import { createResourceRegistryStore } from "./resourceRegistryStore.js";
|
|
25
|
+
import { isReleasable, isTerminalTaskStatus } from "./resourceTypes.js";
|
|
26
|
+
import { scanLiveReferences } from "./liveReferences.js";
|
|
27
|
+
import { discoverResources } from "./resourceDiscovery.js";
|
|
28
|
+
const executeFile = promisify(execFile);
|
|
29
|
+
export const DEFAULT_QUARANTINE_TTL_HOURS = 24;
|
|
30
|
+
/**
|
|
31
|
+
* Plan a GC pass: discover resources, scan live references, and classify each
|
|
32
|
+
* record. Planning is strictly read-only — it never writes the registry,
|
|
33
|
+
* moves files, or restores quarantined resources.
|
|
34
|
+
*/
|
|
35
|
+
export async function planResourceGc(input) {
|
|
36
|
+
const home = resolve(input.home);
|
|
37
|
+
const registryStore = input.registryStore ?? createResourceRegistryStore(home);
|
|
38
|
+
const discovered = await discoverResources({
|
|
39
|
+
home,
|
|
40
|
+
projects: input.projects,
|
|
41
|
+
managedWorkspaces: input.managedWorkspaces,
|
|
42
|
+
taskStatusById: input.taskStatusById,
|
|
43
|
+
now: input.now
|
|
44
|
+
});
|
|
45
|
+
const paths = discovered.map(({ record }) => record.path);
|
|
46
|
+
// Also scan original paths of quarantined records so a new reference is
|
|
47
|
+
// visible in the plan (restore itself happens in apply/purge/restore-all).
|
|
48
|
+
const registry = registryStore.load();
|
|
49
|
+
appendRegistryScanPaths(registry, paths, home);
|
|
50
|
+
const scan = await scanLiveReferences({
|
|
51
|
+
home,
|
|
52
|
+
paths,
|
|
53
|
+
environment: input.environment,
|
|
54
|
+
tmuxServerName: input.tmuxServerName,
|
|
55
|
+
ports: {
|
|
56
|
+
...input.liveReferencePorts,
|
|
57
|
+
managedWorkspaces: () => input.managedWorkspaces,
|
|
58
|
+
activeWorkspaceOwners: () => collectActiveWorkspaceOwners(input)
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
const classified = classifyDiscoveredResources(discovered, registry, scan, input, home);
|
|
62
|
+
const records = classified.map(({ record }) => record);
|
|
63
|
+
// Include quarantined records whose original path no longer hosts the
|
|
64
|
+
// resource. They are reported as-is; restore is an explicit apply action.
|
|
65
|
+
const discoveredIds = new Set(records.map((record) => record.id));
|
|
66
|
+
for (const record of Object.values(registry.records)) {
|
|
67
|
+
if (record.disposition !== "quarantined" || discoveredIds.has(record.id))
|
|
68
|
+
continue;
|
|
69
|
+
records.push(record);
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
home,
|
|
73
|
+
mode: input.mode,
|
|
74
|
+
generatedAt: input.now.toISOString(),
|
|
75
|
+
records: Object.freeze(records),
|
|
76
|
+
releasable: Object.freeze(records.filter((record) => record.disposition === "releasable")),
|
|
77
|
+
retained: Object.freeze(records.filter((record) => record.disposition === "active"
|
|
78
|
+
|| record.disposition === "retained-dirty"
|
|
79
|
+
|| record.disposition === "retained-unowned"
|
|
80
|
+
|| record.disposition === "retained-unproven"
|
|
81
|
+
|| record.disposition === "cleanup-failed")),
|
|
82
|
+
quarantined: Object.freeze(records.filter((record) => record.disposition === "quarantined")),
|
|
83
|
+
deleted: Object.freeze(records.filter((record) => record.disposition === "deleted")),
|
|
84
|
+
scan
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function appendRegistryScanPaths(registry, paths, home) {
|
|
88
|
+
for (const record of Object.values(registry.records)) {
|
|
89
|
+
if (record.disposition === "quarantined" && record.quarantine !== undefined) {
|
|
90
|
+
paths.push(record.quarantine.originalPath);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (record.disposition === "deleted")
|
|
94
|
+
continue;
|
|
95
|
+
if (isResourceQuarantinePath(home, record.path))
|
|
96
|
+
continue;
|
|
97
|
+
if (existsSync(record.path))
|
|
98
|
+
paths.push(record.path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function classifyDiscoveredResources(discovered, registry, scan, input, home) {
|
|
102
|
+
const classified = discovered.map(({ record, ownerTerminal }) => ({
|
|
103
|
+
record: classifyRecord(record, ownerTerminal, scan, registry, input.now),
|
|
104
|
+
ownerTerminal
|
|
105
|
+
}));
|
|
106
|
+
const discoveredIds = new Set(classified.map(({ record }) => record.id));
|
|
107
|
+
for (const record of Object.values(registry.records)) {
|
|
108
|
+
if (discoveredIds.has(record.id))
|
|
109
|
+
continue;
|
|
110
|
+
if (record.disposition === "quarantined" || record.disposition === "deleted") {
|
|
111
|
+
classified.push({ record, ownerTerminal: false });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isResourceQuarantinePath(home, record.path) || !existsSync(record.path))
|
|
115
|
+
continue;
|
|
116
|
+
const ownerTerminal = record.owner.taskId === undefined
|
|
117
|
+
? false
|
|
118
|
+
: isTerminalTaskStatus(input.taskStatusById.get(record.owner.taskId));
|
|
119
|
+
classified.push({
|
|
120
|
+
record: classifyRecord(record, ownerTerminal, scan, registry, input.now),
|
|
121
|
+
ownerTerminal
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return classified;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Collect active workspace owner path fragments from durable state.
|
|
128
|
+
* These protect resources that an active Session, Job, or AgentRun still
|
|
129
|
+
* references even when the Task itself is terminal.
|
|
130
|
+
*/
|
|
131
|
+
function collectActiveWorkspaceOwners(input) {
|
|
132
|
+
return input.activeWorkspaceOwnerPaths ?? [];
|
|
133
|
+
}
|
|
134
|
+
function classifyRecord(record, ownerTerminal, scan, registry, now) {
|
|
135
|
+
const liveRefs = scan.refsByPath.get(record.path) ?? [];
|
|
136
|
+
const existing = registry.records[record.id];
|
|
137
|
+
// A quarantined resource stays quarantined until purged or restored.
|
|
138
|
+
if (existing?.disposition === "quarantined") {
|
|
139
|
+
return existing;
|
|
140
|
+
}
|
|
141
|
+
if (existing?.disposition === "deleted") {
|
|
142
|
+
return existing;
|
|
143
|
+
}
|
|
144
|
+
if (scan.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
145
|
+
return {
|
|
146
|
+
...record,
|
|
147
|
+
activeRefs: Object.freeze([...liveRefs]),
|
|
148
|
+
disposition: "retained-unproven",
|
|
149
|
+
blocker: "Live-reference source is untrusted; retained.",
|
|
150
|
+
updatedAt: now.toISOString()
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const refs = [...liveRefs];
|
|
154
|
+
if (refs.length > 0) {
|
|
155
|
+
return { ...record, activeRefs: Object.freeze(refs), disposition: "active", updatedAt: now.toISOString() };
|
|
156
|
+
}
|
|
157
|
+
if (record.cleanliness === "dirty") {
|
|
158
|
+
return {
|
|
159
|
+
...record,
|
|
160
|
+
activeRefs: Object.freeze([]),
|
|
161
|
+
disposition: "retained-dirty",
|
|
162
|
+
blocker: dirtyWorktreeSuggestion(record),
|
|
163
|
+
updatedAt: now.toISOString()
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (record.cleanliness === "unknown") {
|
|
167
|
+
return {
|
|
168
|
+
...record,
|
|
169
|
+
activeRefs: Object.freeze([]),
|
|
170
|
+
disposition: "retained-unproven",
|
|
171
|
+
blocker: "Cleanliness cannot be proven; retained. Verify the worktree state manually.",
|
|
172
|
+
updatedAt: now.toISOString()
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (record.owner.basis === "unattributed") {
|
|
176
|
+
return {
|
|
177
|
+
...record,
|
|
178
|
+
activeRefs: Object.freeze([]),
|
|
179
|
+
disposition: "retained-unowned",
|
|
180
|
+
blocker: "Ownership cannot be proven; reported, never removed.",
|
|
181
|
+
updatedAt: now.toISOString()
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!ownerTerminal) {
|
|
185
|
+
return {
|
|
186
|
+
...record,
|
|
187
|
+
activeRefs: Object.freeze([]),
|
|
188
|
+
disposition: "active",
|
|
189
|
+
blocker: "Owner is not terminal.",
|
|
190
|
+
updatedAt: now.toISOString()
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
...record,
|
|
195
|
+
activeRefs: Object.freeze([]),
|
|
196
|
+
disposition: "releasable",
|
|
197
|
+
updatedAt: now.toISOString()
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Suggest how to preserve dirty evidence before releasing a worktree. */
|
|
201
|
+
function dirtyWorktreeSuggestion(record) {
|
|
202
|
+
const path = record.path;
|
|
203
|
+
return [
|
|
204
|
+
"Worktree has uncommitted changes; preserve dirty evidence before release.",
|
|
205
|
+
` git -C ${path} diff > ${path}.patch`,
|
|
206
|
+
` git -C ${path} bundle create ${path}.bundle --all`,
|
|
207
|
+
" or commit the changes to a branch."
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Apply a GC plan: quarantine every releasable resource. In `report` mode this
|
|
212
|
+
* is a shadow pass — the plan is returned unchanged and nothing is mutated.
|
|
213
|
+
*
|
|
214
|
+
* Before each mutation the engine re-discovers and re-scans live references,
|
|
215
|
+
* so a plan that went stale after generation cannot release a resource that
|
|
216
|
+
* gained a reference.
|
|
217
|
+
*/
|
|
218
|
+
export async function applyResourceGc(input, plan) {
|
|
219
|
+
if (plan.mode === "report") {
|
|
220
|
+
return Object.freeze({
|
|
221
|
+
planned: plan,
|
|
222
|
+
applied: Object.freeze([]),
|
|
223
|
+
failed: Object.freeze([]),
|
|
224
|
+
restored: Object.freeze([]),
|
|
225
|
+
purged: Object.freeze([])
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const home = resolve(input.home);
|
|
229
|
+
const now = input.now;
|
|
230
|
+
const registryStore = input.registryStore ?? createResourceRegistryStore(home);
|
|
231
|
+
let registry = registryStore.load();
|
|
232
|
+
const applied = [];
|
|
233
|
+
const failed = [];
|
|
234
|
+
const restored = [];
|
|
235
|
+
// Re-discover and re-scan with fresh state so the plan is validated against
|
|
236
|
+
// the world as it exists right now, not when the plan was generated.
|
|
237
|
+
const discovered = await discoverResources({
|
|
238
|
+
home,
|
|
239
|
+
projects: input.projects,
|
|
240
|
+
managedWorkspaces: input.managedWorkspaces,
|
|
241
|
+
taskStatusById: input.taskStatusById,
|
|
242
|
+
now
|
|
243
|
+
});
|
|
244
|
+
const freshPaths = discovered.map(({ record }) => record.path);
|
|
245
|
+
appendRegistryScanPaths(registry, freshPaths, home);
|
|
246
|
+
const freshScan = await scanLiveReferences({
|
|
247
|
+
home,
|
|
248
|
+
paths: freshPaths,
|
|
249
|
+
environment: input.environment,
|
|
250
|
+
tmuxServerName: input.tmuxServerName,
|
|
251
|
+
ports: {
|
|
252
|
+
...input.liveReferencePorts,
|
|
253
|
+
managedWorkspaces: () => input.managedWorkspaces,
|
|
254
|
+
activeWorkspaceOwners: () => collectActiveWorkspaceOwners(input)
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const freshClassified = classifyDiscoveredResources(discovered, registry, freshScan, input, home);
|
|
258
|
+
const freshById = new Map(freshClassified.map(({ record }) => [record.id, record]));
|
|
259
|
+
// Restore quarantined records whose owner is no longer terminal or that
|
|
260
|
+
// gained a live reference. This is the apply-time counterpart of the old
|
|
261
|
+
// planning-time restore: planning is read-only, so restore happens here.
|
|
262
|
+
for (const record of Object.values(registry.records)) {
|
|
263
|
+
if (record.disposition !== "quarantined" || record.quarantine === undefined)
|
|
264
|
+
continue;
|
|
265
|
+
const ownerTaskId = record.owner.taskId;
|
|
266
|
+
const ownerNoLongerTerminal = ownerTaskId !== undefined
|
|
267
|
+
&& input.taskStatusById.has(ownerTaskId)
|
|
268
|
+
&& !isTerminalTaskStatus(input.taskStatusById.get(ownerTaskId));
|
|
269
|
+
const originalRefs = freshScan.refsByPath.get(record.quarantine.originalPath) ?? [];
|
|
270
|
+
if (ownerNoLongerTerminal || originalRefs.length > 0) {
|
|
271
|
+
const restoredRecord = restoreQuarantinedRecord(record, originalRefs, now);
|
|
272
|
+
registry = upsertResourceRecord(registry, restoredRecord);
|
|
273
|
+
if (restoredRecord.disposition === "active") {
|
|
274
|
+
restored.push(restoredRecord);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
failed.push(restoredRecord);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const planned of plan.records) {
|
|
282
|
+
if (!isReleasable(planned))
|
|
283
|
+
continue;
|
|
284
|
+
// Re-validate against fresh state: the resource must still exist, still
|
|
285
|
+
// be clean, and still have zero live references.
|
|
286
|
+
const fresh = freshById.get(planned.id);
|
|
287
|
+
if (fresh === undefined) {
|
|
288
|
+
// Resource disappeared since planning; reconcile the stale record.
|
|
289
|
+
registry = removeResourceRecord(registry, planned.id);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!isReleasable(fresh)) {
|
|
293
|
+
// Fresh liveness, cleanliness, ownership, or diagnostic evidence changed
|
|
294
|
+
// since planning: retain the resource and reconcile its registry record.
|
|
295
|
+
registry = upsertResourceRecord(registry, fresh);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const result = await quarantineResource(home, fresh, now);
|
|
299
|
+
registry = upsertResourceRecord(registry, result.record);
|
|
300
|
+
if (result.ok) {
|
|
301
|
+
applied.push(result.record);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
failed.push(result.record);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
registryStore.save(registry);
|
|
308
|
+
registryStore.close();
|
|
309
|
+
return Object.freeze({
|
|
310
|
+
planned: plan,
|
|
311
|
+
applied: Object.freeze(applied),
|
|
312
|
+
failed: Object.freeze(failed),
|
|
313
|
+
restored: Object.freeze(restored),
|
|
314
|
+
purged: Object.freeze([])
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function quarantineResource(home, record, now) {
|
|
318
|
+
const quarantineRoot = resourceQuarantineRoot(home);
|
|
319
|
+
const quarantinePath = join(quarantineRoot, record.id);
|
|
320
|
+
const receiptPath = `${quarantinePath}.receipt.json`;
|
|
321
|
+
try {
|
|
322
|
+
if (isGitWorktreePath(record.path)) {
|
|
323
|
+
// Move the linked worktree, including its exact checkout and Git
|
|
324
|
+
// metadata, into the Home-local quarantine. Restore is the inverse
|
|
325
|
+
// move, so the recorded HEAD cannot drift with a branch.
|
|
326
|
+
mkdirSync(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
327
|
+
await executeFile("git", ["-C", record.path, "worktree", "move", "--", record.path, quarantinePath], { timeout: 30_000 });
|
|
328
|
+
writeQuarantineReceipt(home, record, now, "move", receiptPath);
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
record: {
|
|
332
|
+
...record,
|
|
333
|
+
disposition: "quarantined",
|
|
334
|
+
quarantine: {
|
|
335
|
+
path: quarantinePath,
|
|
336
|
+
originalPath: record.path,
|
|
337
|
+
movedAt: now.toISOString(),
|
|
338
|
+
method: "move",
|
|
339
|
+
...(record.git === undefined ? {} : {
|
|
340
|
+
gitRestore: {
|
|
341
|
+
repositoryPath: record.git.repositoryPath,
|
|
342
|
+
...(record.git.branch === undefined ? {} : { branch: record.git.branch }),
|
|
343
|
+
...(record.git.head === undefined ? {} : { head: record.git.head })
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
},
|
|
347
|
+
updatedAt: now.toISOString()
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// Non-Git artifact: move into the Home-local quarantine.
|
|
352
|
+
mkdirSync(quarantineRoot, { recursive: true, mode: 0o700 });
|
|
353
|
+
renameSync(record.path, quarantinePath);
|
|
354
|
+
writeQuarantineReceipt(home, record, now, "move", receiptPath);
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
record: {
|
|
358
|
+
...record,
|
|
359
|
+
disposition: "quarantined",
|
|
360
|
+
quarantine: {
|
|
361
|
+
path: quarantinePath,
|
|
362
|
+
originalPath: record.path,
|
|
363
|
+
movedAt: now.toISOString(),
|
|
364
|
+
method: "move"
|
|
365
|
+
},
|
|
366
|
+
updatedAt: now.toISOString()
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
record: {
|
|
374
|
+
...record,
|
|
375
|
+
disposition: "cleanup-failed",
|
|
376
|
+
blocker: `Quarantine failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
377
|
+
updatedAt: now.toISOString()
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function writeQuarantineReceipt(home, record, now, method, receiptPath) {
|
|
383
|
+
try {
|
|
384
|
+
mkdirSync(dirname(receiptPath), { recursive: true, mode: 0o700 });
|
|
385
|
+
writeFileSync(receiptPath, `${JSON.stringify({
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
id: record.id,
|
|
388
|
+
kind: record.kind,
|
|
389
|
+
originalPath: record.path,
|
|
390
|
+
owner: record.owner,
|
|
391
|
+
method,
|
|
392
|
+
...(record.git === undefined ? {} : { git: record.git }),
|
|
393
|
+
movedAt: now.toISOString()
|
|
394
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// A missing receipt does not block quarantine; the registry is authoritative.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function isGitWorktreePath(path) {
|
|
401
|
+
return existsSync(join(path, ".git"));
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Restore a quarantined resource to its original path. Move-based Git
|
|
405
|
+
* quarantine is reversed with `git worktree move`, preserving the exact
|
|
406
|
+
* checkout. Legacy remove-based receipts rebuild a detached worktree at the
|
|
407
|
+
* recorded HEAD; a branch tip is never used as a substitute.
|
|
408
|
+
*/
|
|
409
|
+
function restoreQuarantinedRecord(record, liveRefs, now) {
|
|
410
|
+
const quarantine = record.quarantine;
|
|
411
|
+
if (quarantine === undefined)
|
|
412
|
+
return record;
|
|
413
|
+
try {
|
|
414
|
+
if (quarantine.method === "git-worktree-remove" && quarantine.gitRestore !== undefined) {
|
|
415
|
+
const git = quarantine.gitRestore;
|
|
416
|
+
if (existsSync(quarantine.originalPath)) {
|
|
417
|
+
return {
|
|
418
|
+
...record,
|
|
419
|
+
disposition: "cleanup-failed",
|
|
420
|
+
blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
|
|
421
|
+
updatedAt: now.toISOString()
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
425
|
+
if (git.head !== undefined) {
|
|
426
|
+
execFileSync("git", ["-C", git.repositoryPath, "worktree", "add", "--detach", "--", quarantine.originalPath, git.head]);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
throw new Error("no head recorded for Git worktree restore");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
// Move-based restore: rename the quarantine directory back.
|
|
434
|
+
if (!existsSync(quarantine.path)) {
|
|
435
|
+
return {
|
|
436
|
+
...record,
|
|
437
|
+
disposition: "cleanup-failed",
|
|
438
|
+
blocker: `Restore failed: quarantine path missing: ${quarantine.path}`,
|
|
439
|
+
updatedAt: now.toISOString()
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
if (existsSync(quarantine.originalPath)) {
|
|
443
|
+
return {
|
|
444
|
+
...record,
|
|
445
|
+
disposition: "cleanup-failed",
|
|
446
|
+
blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
|
|
447
|
+
updatedAt: now.toISOString()
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (isGitWorktreePath(quarantine.path)) {
|
|
451
|
+
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
452
|
+
execFileSync("git", ["-C", quarantine.path, "worktree", "move", "--", quarantine.path, quarantine.originalPath], { timeout: 30_000 });
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
mkdirSync(dirname(quarantine.originalPath), { recursive: true });
|
|
456
|
+
renameSync(quarantine.path, quarantine.originalPath);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
return {
|
|
462
|
+
...record,
|
|
463
|
+
disposition: "cleanup-failed",
|
|
464
|
+
blocker: `Restore failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
465
|
+
updatedAt: now.toISOString()
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
...record,
|
|
470
|
+
disposition: "active",
|
|
471
|
+
activeRefs: Object.freeze([...liveRefs]),
|
|
472
|
+
quarantine: undefined,
|
|
473
|
+
blocker: "Restored from quarantine.",
|
|
474
|
+
updatedAt: now.toISOString()
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Permanently delete quarantined resources whose observation window has
|
|
479
|
+
* elapsed. A resource with a new live reference — on either the original or
|
|
480
|
+
* the quarantine path — is restored instead.
|
|
481
|
+
*/
|
|
482
|
+
export async function purgeResourceQuarantine(home, options) {
|
|
483
|
+
const resolvedHome = resolve(home);
|
|
484
|
+
const ttlHours = options.ttlHours ?? DEFAULT_QUARANTINE_TTL_HOURS;
|
|
485
|
+
const ttlMs = ttlHours * 3_600_000;
|
|
486
|
+
const registryStore = createResourceRegistryStore(resolvedHome);
|
|
487
|
+
const registry = registryStore.load();
|
|
488
|
+
const quarantined = Object.values(registry.records)
|
|
489
|
+
.filter((record) => record.quarantine !== undefined
|
|
490
|
+
&& (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
|
|
491
|
+
// Re-scan live references for both original AND quarantine paths before
|
|
492
|
+
// purging. A file held open inside quarantine must block deletion.
|
|
493
|
+
const paths = [];
|
|
494
|
+
for (const record of quarantined) {
|
|
495
|
+
if (record.quarantine === undefined)
|
|
496
|
+
continue;
|
|
497
|
+
paths.push(record.quarantine.originalPath);
|
|
498
|
+
paths.push(record.quarantine.path);
|
|
499
|
+
}
|
|
500
|
+
const scan = await scanLiveReferences({
|
|
501
|
+
home: resolvedHome,
|
|
502
|
+
paths,
|
|
503
|
+
environment: options.environment,
|
|
504
|
+
tmuxServerName: options.tmuxServerName,
|
|
505
|
+
ports: {
|
|
506
|
+
...options.liveReferencePorts,
|
|
507
|
+
managedWorkspaces: () => options.managedWorkspaces ?? [],
|
|
508
|
+
activeWorkspaceOwners: () => []
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
let state = registry;
|
|
512
|
+
const purged = [];
|
|
513
|
+
const restored = [];
|
|
514
|
+
const failed = [];
|
|
515
|
+
const scanUntrusted = scan.diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
516
|
+
for (const record of quarantined) {
|
|
517
|
+
const quarantine = record.quarantine;
|
|
518
|
+
if (quarantine === undefined)
|
|
519
|
+
continue;
|
|
520
|
+
const ageMs = options.now.getTime() - Date.parse(quarantine.movedAt);
|
|
521
|
+
if (!Number.isFinite(ageMs) || ageMs < ttlMs)
|
|
522
|
+
continue;
|
|
523
|
+
// A live-reference source that cannot be trusted fails closed: keep the
|
|
524
|
+
// resource quarantined instead of permanently deleting it.
|
|
525
|
+
if (scanUntrusted)
|
|
526
|
+
continue;
|
|
527
|
+
const originalRefs = scan.refsByPath.get(quarantine.originalPath) ?? [];
|
|
528
|
+
const quarantineRefs = scan.refsByPath.get(quarantine.path) ?? [];
|
|
529
|
+
const allRefs = [...originalRefs, ...quarantineRefs];
|
|
530
|
+
if (allRefs.length > 0) {
|
|
531
|
+
const restoredRecord = restoreQuarantinedRecord(record, allRefs, options.now);
|
|
532
|
+
state = upsertResourceRecord(state, restoredRecord);
|
|
533
|
+
if (restoredRecord.disposition === "active") {
|
|
534
|
+
restored.push(restoredRecord);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
failed.push(restoredRecord);
|
|
538
|
+
}
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
if (existsSync(quarantine.path)) {
|
|
543
|
+
if (isGitWorktreePath(quarantine.path)) {
|
|
544
|
+
await executeFile("git", ["-C", quarantine.path, "worktree", "remove", "--force", "--", quarantine.path], { timeout: 30_000 });
|
|
545
|
+
}
|
|
546
|
+
rmSync(quarantine.path, { recursive: true, force: true });
|
|
547
|
+
}
|
|
548
|
+
// Also remove the receipt sibling.
|
|
549
|
+
const receiptPath = `${quarantine.path}.receipt.json`;
|
|
550
|
+
if (existsSync(receiptPath)) {
|
|
551
|
+
rmSync(receiptPath, { force: true });
|
|
552
|
+
}
|
|
553
|
+
const deleted = {
|
|
554
|
+
...record,
|
|
555
|
+
disposition: "deleted",
|
|
556
|
+
quarantine: undefined,
|
|
557
|
+
cleanupReceipt: {
|
|
558
|
+
removedAt: options.now.toISOString(),
|
|
559
|
+
method: "quarantine-purge"
|
|
560
|
+
},
|
|
561
|
+
updatedAt: options.now.toISOString()
|
|
562
|
+
};
|
|
563
|
+
state = upsertResourceRecord(state, deleted);
|
|
564
|
+
purged.push(deleted);
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
const failedRecord = {
|
|
568
|
+
...record,
|
|
569
|
+
disposition: "cleanup-failed",
|
|
570
|
+
blocker: `Purge failed: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
571
|
+
updatedAt: options.now.toISOString()
|
|
572
|
+
};
|
|
573
|
+
state = upsertResourceRecord(state, failedRecord);
|
|
574
|
+
failed.push(failedRecord);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
registryStore.save(state);
|
|
578
|
+
registryStore.close();
|
|
579
|
+
return Object.freeze({
|
|
580
|
+
planned: Object.freeze({
|
|
581
|
+
home: resolvedHome,
|
|
582
|
+
mode: "quarantine",
|
|
583
|
+
generatedAt: options.now.toISOString(),
|
|
584
|
+
records: Object.freeze([]),
|
|
585
|
+
releasable: Object.freeze([]),
|
|
586
|
+
retained: Object.freeze([]),
|
|
587
|
+
quarantined: Object.freeze(quarantined),
|
|
588
|
+
deleted: Object.freeze([]),
|
|
589
|
+
scan
|
|
590
|
+
}),
|
|
591
|
+
applied: Object.freeze([]),
|
|
592
|
+
failed: Object.freeze(failed),
|
|
593
|
+
restored: Object.freeze(restored),
|
|
594
|
+
purged: Object.freeze(purged)
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Explicitly restore every quarantined resource to its original path. This is
|
|
599
|
+
* the rollback entry point: it does not require live references and does not
|
|
600
|
+
* depend on the GC mode.
|
|
601
|
+
*/
|
|
602
|
+
export async function restoreAllResourceGc(home, options) {
|
|
603
|
+
const resolvedHome = resolve(home);
|
|
604
|
+
const registryStore = createResourceRegistryStore(resolvedHome);
|
|
605
|
+
const registry = registryStore.load();
|
|
606
|
+
const quarantined = Object.values(registry.records)
|
|
607
|
+
.filter((record) => record.quarantine !== undefined
|
|
608
|
+
&& (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
|
|
609
|
+
let state = registry;
|
|
610
|
+
const restored = [];
|
|
611
|
+
const failed = [];
|
|
612
|
+
for (const record of quarantined) {
|
|
613
|
+
const restoredRecord = restoreQuarantinedRecord(record, [], options.now);
|
|
614
|
+
state = upsertResourceRecord(state, restoredRecord);
|
|
615
|
+
if (restoredRecord.disposition === "active") {
|
|
616
|
+
restored.push(restoredRecord);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
failed.push(restoredRecord);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
registryStore.save(state);
|
|
623
|
+
registryStore.close();
|
|
624
|
+
return Object.freeze({
|
|
625
|
+
planned: Object.freeze({
|
|
626
|
+
home: resolvedHome,
|
|
627
|
+
mode: "quarantine",
|
|
628
|
+
generatedAt: options.now.toISOString(),
|
|
629
|
+
records: Object.freeze([]),
|
|
630
|
+
releasable: Object.freeze([]),
|
|
631
|
+
retained: Object.freeze([]),
|
|
632
|
+
quarantined: Object.freeze(quarantined),
|
|
633
|
+
deleted: Object.freeze([]),
|
|
634
|
+
scan: Object.freeze({
|
|
635
|
+
refsByPath: new Map(),
|
|
636
|
+
protectedPaths: Object.freeze([]),
|
|
637
|
+
diagnostics: Object.freeze([])
|
|
638
|
+
})
|
|
639
|
+
}),
|
|
640
|
+
applied: Object.freeze([]),
|
|
641
|
+
failed: Object.freeze(failed),
|
|
642
|
+
restored: Object.freeze(restored),
|
|
643
|
+
purged: Object.freeze([])
|
|
644
|
+
});
|
|
645
|
+
}
|