@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,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immediate Resource registry instrumentation (Issue 10).
|
|
3
|
+
*
|
|
4
|
+
* Every Yui-created workspace/runtime path is registered in the same
|
|
5
|
+
* controlled step that creates it. Discovery remains the authority for
|
|
6
|
+
* historical resources, but these receipts let GC prove ownership for
|
|
7
|
+
* registry-only paths such as `/tmp/yi-*` integration runtimes.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { basename, dirname, resolve } from "node:path";
|
|
12
|
+
import { createResourceRegistryStore } from "./resourceRegistryStore.js";
|
|
13
|
+
import { upsertResourceRecord } from "./resourceRegistry.js";
|
|
14
|
+
import { createResourceRecord, isReleaseNamespacePath, resourceId } from "./resourceTypes.js";
|
|
15
|
+
export class ResourceRegistrar {
|
|
16
|
+
#home;
|
|
17
|
+
#now;
|
|
18
|
+
constructor(home, now = () => new Date()) {
|
|
19
|
+
this.#home = resolve(home);
|
|
20
|
+
this.#now = now;
|
|
21
|
+
}
|
|
22
|
+
registerManagedWorkspace(workspace) {
|
|
23
|
+
try {
|
|
24
|
+
this.#registerManagedWorkspace(workspace);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// A failed registry receipt must never block workspace creation.
|
|
28
|
+
// Discovery remains the authority for historical resources.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
#registerManagedWorkspace(workspace) {
|
|
32
|
+
const records = [];
|
|
33
|
+
const root = resolve(workspace.root);
|
|
34
|
+
if (existsSync(root)
|
|
35
|
+
&& !workspace.entries.some((entry) => resolve(entry.path) === root)
|
|
36
|
+
&& !isReleaseNamespacePath(this.#home, root)) {
|
|
37
|
+
records.push(this.#record({
|
|
38
|
+
kind: "runtime-artifact",
|
|
39
|
+
path: root,
|
|
40
|
+
owner: ownerFromManagedWorkspace(this.#home, workspace.owner),
|
|
41
|
+
cleanliness: "n/a"
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
for (const entry of workspace.entries) {
|
|
45
|
+
if (entry.access !== "write")
|
|
46
|
+
continue;
|
|
47
|
+
const path = resolve(entry.path);
|
|
48
|
+
if (!existsSync(path) || isReleaseNamespacePath(this.#home, path))
|
|
49
|
+
continue;
|
|
50
|
+
records.push(this.#record({
|
|
51
|
+
kind: "worktree",
|
|
52
|
+
path,
|
|
53
|
+
owner: ownerFromManagedWorkspace(this.#home, workspace.owner, entry.projectId),
|
|
54
|
+
git: readGitWorktreeMetadata(path),
|
|
55
|
+
cleanliness: readGitWorktreeCleanliness(path)
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
this.#save(records);
|
|
59
|
+
}
|
|
60
|
+
registerTaskRuntimeIsolation(descriptor) {
|
|
61
|
+
try {
|
|
62
|
+
this.#registerTaskRuntimeIsolation(descriptor);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Best-effort receipt; runtime isolation must proceed regardless.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
#registerTaskRuntimeIsolation(descriptor) {
|
|
69
|
+
const owner = ownerFromManagedWorkspace(this.#home, descriptor.workspace.owner);
|
|
70
|
+
this.#save([
|
|
71
|
+
descriptor.roots.generation,
|
|
72
|
+
descriptor.roots.data,
|
|
73
|
+
descriptor.roots.cache,
|
|
74
|
+
descriptor.roots.temporary
|
|
75
|
+
].filter((path, index, paths) => paths.indexOf(path) === index && existsSync(path))
|
|
76
|
+
.map((path) => this.#record({
|
|
77
|
+
kind: "runtime-artifact",
|
|
78
|
+
path: resolve(path),
|
|
79
|
+
owner,
|
|
80
|
+
cleanliness: "n/a"
|
|
81
|
+
})));
|
|
82
|
+
}
|
|
83
|
+
registerExactTaskRuntimeDescriptor(descriptor, sourcePath) {
|
|
84
|
+
try {
|
|
85
|
+
this.#registerExactTaskRuntimeDescriptor(descriptor, sourcePath);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Best-effort receipt; descriptor writes must proceed regardless.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
#registerExactTaskRuntimeDescriptor(descriptor, sourcePath) {
|
|
92
|
+
const path = resolve(sourcePath);
|
|
93
|
+
if (!existsSync(path) || isReleaseNamespacePath(this.#home, path))
|
|
94
|
+
return;
|
|
95
|
+
this.#save([this.#record({
|
|
96
|
+
kind: "runtime-artifact",
|
|
97
|
+
path,
|
|
98
|
+
owner: {
|
|
99
|
+
home: this.#home,
|
|
100
|
+
taskId: descriptor.taskId,
|
|
101
|
+
basis: "descriptor"
|
|
102
|
+
},
|
|
103
|
+
cleanliness: "n/a"
|
|
104
|
+
})]);
|
|
105
|
+
}
|
|
106
|
+
registerSessionContext(path, owner) {
|
|
107
|
+
try {
|
|
108
|
+
this.#registerSessionContext(path, owner);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Best-effort receipt; session launch must proceed regardless.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
#registerSessionContext(path, owner) {
|
|
115
|
+
const resolved = resolve(path);
|
|
116
|
+
if (!existsSync(resolved) || isReleaseNamespacePath(this.#home, resolved))
|
|
117
|
+
return;
|
|
118
|
+
this.#save([this.#record({
|
|
119
|
+
kind: "runtime-artifact",
|
|
120
|
+
path: resolved,
|
|
121
|
+
owner,
|
|
122
|
+
cleanliness: "n/a"
|
|
123
|
+
})]);
|
|
124
|
+
}
|
|
125
|
+
markWorkspaceDeleted(workspace) {
|
|
126
|
+
try {
|
|
127
|
+
this.markPathsDeleted([
|
|
128
|
+
workspace.root,
|
|
129
|
+
...workspace.entries.map((entry) => entry.path)
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Best-effort receipt; workspace deletion must proceed regardless.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
markPathsDeleted(paths) {
|
|
137
|
+
try {
|
|
138
|
+
this.#markPathsDeleted(paths);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Best-effort receipt; path deletion must proceed regardless.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
#markPathsDeleted(paths) {
|
|
145
|
+
const targets = new Set(paths.map((path) => resolve(path)));
|
|
146
|
+
const store = createResourceRegistryStore(this.#home);
|
|
147
|
+
try {
|
|
148
|
+
const state = store.load();
|
|
149
|
+
let next = state;
|
|
150
|
+
const timestamp = this.#now().toISOString();
|
|
151
|
+
for (const record of Object.values(state.records)) {
|
|
152
|
+
if (!targets.has(record.path) || record.disposition === "deleted")
|
|
153
|
+
continue;
|
|
154
|
+
next = upsertResourceRecord(next, {
|
|
155
|
+
...record,
|
|
156
|
+
activeRefs: Object.freeze([]),
|
|
157
|
+
disposition: "deleted",
|
|
158
|
+
blocker: undefined,
|
|
159
|
+
quarantine: undefined,
|
|
160
|
+
cleanupReceipt: {
|
|
161
|
+
removedAt: timestamp,
|
|
162
|
+
method: record.kind === "worktree" ? "git-worktree-remove" : "runtime-cleanup"
|
|
163
|
+
},
|
|
164
|
+
updatedAt: timestamp
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (next !== state)
|
|
168
|
+
store.save(next);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
store.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
#record(input) {
|
|
175
|
+
const timestamp = this.#now();
|
|
176
|
+
const store = createResourceRegistryStore(this.#home);
|
|
177
|
+
let existing;
|
|
178
|
+
try {
|
|
179
|
+
existing = store.load().records[resourceId(input.kind, input.path)];
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
store.close();
|
|
183
|
+
}
|
|
184
|
+
return createResourceRecord({
|
|
185
|
+
kind: input.kind,
|
|
186
|
+
path: input.path,
|
|
187
|
+
owner: input.owner,
|
|
188
|
+
...(input.git === undefined ? {} : { git: input.git }),
|
|
189
|
+
...(existing?.createdAt === undefined ? {} : { createdAt: existing.createdAt }),
|
|
190
|
+
lastReferencedAt: timestamp.toISOString(),
|
|
191
|
+
cleanliness: input.cleanliness,
|
|
192
|
+
activeRefs: [],
|
|
193
|
+
disposition: "active"
|
|
194
|
+
}, timestamp);
|
|
195
|
+
}
|
|
196
|
+
#save(records) {
|
|
197
|
+
if (records.length === 0)
|
|
198
|
+
return;
|
|
199
|
+
const store = createResourceRegistryStore(this.#home);
|
|
200
|
+
try {
|
|
201
|
+
let state = store.load();
|
|
202
|
+
for (const record of records)
|
|
203
|
+
state = upsertResourceRecord(state, record);
|
|
204
|
+
store.save(state);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
store.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function ownerFromManagedWorkspace(home, owner, projectId) {
|
|
212
|
+
return {
|
|
213
|
+
home,
|
|
214
|
+
...(projectId === undefined ? {} : { projectId }),
|
|
215
|
+
taskId: owner.taskId,
|
|
216
|
+
...(owner.type === "work-item" ? { workItemId: owner.workItemId } : {}),
|
|
217
|
+
...(owner.type === "review-round" ? { reviewRoundId: owner.reviewRoundId } : {}),
|
|
218
|
+
...(owner.type === "integration-attempt"
|
|
219
|
+
? { integrationAttemptId: owner.integrationAttemptId }
|
|
220
|
+
: {}),
|
|
221
|
+
...(owner.type === "execution-lane" && owner.workItemId !== undefined
|
|
222
|
+
? { workItemId: owner.workItemId }
|
|
223
|
+
: {}),
|
|
224
|
+
...(owner.type === "execution-lane" && owner.reviewRoundId !== undefined
|
|
225
|
+
? { reviewRoundId: owner.reviewRoundId }
|
|
226
|
+
: {}),
|
|
227
|
+
basis: "durable-record"
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function readGitWorktreeMetadata(path) {
|
|
231
|
+
const commonDir = execFileSync("git", ["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
232
|
+
const repositoryPath = basename(commonDir) === ".git" ? dirname(commonDir) : commonDir;
|
|
233
|
+
let branch;
|
|
234
|
+
try {
|
|
235
|
+
branch = execFileSync("git", ["-C", path, "symbolic-ref", "--quiet", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
branch = undefined;
|
|
239
|
+
}
|
|
240
|
+
const head = execFileSync("git", ["-C", path, "rev-parse", "HEAD^{commit}"], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
241
|
+
return {
|
|
242
|
+
repositoryPath,
|
|
243
|
+
commonDir,
|
|
244
|
+
...(branch === undefined || branch === "" ? {} : { branch }),
|
|
245
|
+
head
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function readGitWorktreeCleanliness(path) {
|
|
249
|
+
try {
|
|
250
|
+
const status = execFileSync("git", ["-C", path, "status", "--porcelain=v1", "--untracked-files=all"], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
251
|
+
return status.length > 0 ? "dirty" : "clean";
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return "unknown";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Resource registry (Issue 10).
|
|
3
|
+
*
|
|
4
|
+
* The registry is GC's own state. When the Home is SQLite-backed it lives in
|
|
5
|
+
* the `resource_registry` table inside `yui.db`; otherwise it falls back to a
|
|
6
|
+
* JSON file at `$YUI_HOME/runtime/resource-registry/registry.json`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { RESOURCE_REGISTRY_SCHEMA_VERSION } from "./resourceTypes.js";
|
|
11
|
+
export const RESOURCE_REGISTRY_DIRECTORY = "resource-registry";
|
|
12
|
+
export const RESOURCE_REGISTRY_FILE = "registry.json";
|
|
13
|
+
export const RESOURCE_QUARANTINE_DIRECTORY = "quarantine";
|
|
14
|
+
export function resourceRegistryPath(home) {
|
|
15
|
+
return join(resolve(home), "runtime", RESOURCE_REGISTRY_DIRECTORY, RESOURCE_REGISTRY_FILE);
|
|
16
|
+
}
|
|
17
|
+
export function resourceQuarantineRoot(home) {
|
|
18
|
+
return join(resolve(home), "runtime", RESOURCE_REGISTRY_DIRECTORY, RESOURCE_QUARANTINE_DIRECTORY);
|
|
19
|
+
}
|
|
20
|
+
/** True when `path` lives in this Home's Resource GC quarantine namespace. */
|
|
21
|
+
export function isResourceQuarantinePath(home, path) {
|
|
22
|
+
const root = resolve(resourceQuarantineRoot(home));
|
|
23
|
+
const resolved = resolve(path);
|
|
24
|
+
return resolved === root || resolved.startsWith(`${root}/`);
|
|
25
|
+
}
|
|
26
|
+
export function emptyResourceRegistry() {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
29
|
+
records: Object.freeze({})
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Load the registry. A missing registry is an empty Home. A corrupt or
|
|
34
|
+
* unreadable registry fails closed: GC must never invent ownership from a
|
|
35
|
+
* corrupt registry, and silently dropping quarantine receipts could let a
|
|
36
|
+
* resource be released twice.
|
|
37
|
+
*/
|
|
38
|
+
export function loadResourceRegistry(home) {
|
|
39
|
+
const path = resourceRegistryPath(home);
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
return emptyResourceRegistry();
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw new Error(`Resource registry is corrupt or unreadable at ${path}: `
|
|
48
|
+
+ `${error instanceof Error ? error.message : "unknown error"}. `
|
|
49
|
+
+ "Fix or remove the registry file before running GC.", { cause: error });
|
|
50
|
+
}
|
|
51
|
+
return parseResourceRegistryState(parsed);
|
|
52
|
+
}
|
|
53
|
+
export function saveResourceRegistry(home, state) {
|
|
54
|
+
const path = resourceRegistryPath(home);
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
56
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
57
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
58
|
+
renameSync(temporary, path);
|
|
59
|
+
}
|
|
60
|
+
export function upsertResourceRecord(state, record) {
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
63
|
+
records: Object.freeze({ ...state.records, [record.id]: Object.freeze(record) })
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function removeResourceRecord(state, id) {
|
|
67
|
+
if (!(id in state.records))
|
|
68
|
+
return state;
|
|
69
|
+
const records = { ...state.records };
|
|
70
|
+
delete records[id];
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
73
|
+
records: Object.freeze(records)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function listResourceRecords(state) {
|
|
77
|
+
return Object.values(state.records);
|
|
78
|
+
}
|
|
79
|
+
export function parseResourceRegistryState(value) {
|
|
80
|
+
if (typeof value !== "object" || value === null) {
|
|
81
|
+
throw new Error("Resource registry root is not an object.");
|
|
82
|
+
}
|
|
83
|
+
const record = value;
|
|
84
|
+
if (record.schemaVersion !== RESOURCE_REGISTRY_SCHEMA_VERSION) {
|
|
85
|
+
throw new Error(`Resource registry schemaVersion is ${String(record.schemaVersion)}; `
|
|
86
|
+
+ `expected ${RESOURCE_REGISTRY_SCHEMA_VERSION}.`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof record.records !== "object" || record.records === null) {
|
|
89
|
+
throw new Error("Resource registry records is not an object.");
|
|
90
|
+
}
|
|
91
|
+
const records = {};
|
|
92
|
+
for (const [key, entry] of Object.entries(record.records)) {
|
|
93
|
+
const parsed = parseResourceRecord(entry);
|
|
94
|
+
if (parsed === undefined) {
|
|
95
|
+
throw new Error(`Resource registry record ${key} is malformed.`);
|
|
96
|
+
}
|
|
97
|
+
if (parsed.id !== key) {
|
|
98
|
+
throw new Error(`Resource registry record key ${key} does not match id ${parsed.id}.`);
|
|
99
|
+
}
|
|
100
|
+
records[key] = parsed;
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
104
|
+
records: Object.freeze(records)
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function parseResourceRecord(value) {
|
|
108
|
+
if (typeof value !== "object" || value === null)
|
|
109
|
+
return undefined;
|
|
110
|
+
const record = value;
|
|
111
|
+
if (record.schemaVersion !== RESOURCE_REGISTRY_SCHEMA_VERSION)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (typeof record.id !== "string" || typeof record.kind !== "string"
|
|
114
|
+
|| typeof record.path !== "string" || typeof record.disposition !== "string"
|
|
115
|
+
|| typeof record.updatedAt !== "string") {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isFinite(Date.parse(record.updatedAt)))
|
|
119
|
+
return undefined;
|
|
120
|
+
if (typeof record.owner !== "object" || record.owner === null)
|
|
121
|
+
return undefined;
|
|
122
|
+
const owner = record.owner;
|
|
123
|
+
if (typeof owner.home !== "string" || typeof owner.basis !== "string") {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
128
|
+
id: record.id,
|
|
129
|
+
kind: record.kind,
|
|
130
|
+
path: record.path,
|
|
131
|
+
owner: Object.freeze({ ...owner }),
|
|
132
|
+
...(record.git === undefined ? {} : { git: record.git }),
|
|
133
|
+
...(typeof record.createdAt === "string" ? { createdAt: record.createdAt } : {}),
|
|
134
|
+
...(typeof record.lastReferencedAt === "string"
|
|
135
|
+
? { lastReferencedAt: record.lastReferencedAt }
|
|
136
|
+
: {}),
|
|
137
|
+
...(typeof record.sizeBytes === "number" ? { sizeBytes: record.sizeBytes } : {}),
|
|
138
|
+
cleanliness: (record.cleanliness ?? "unknown"),
|
|
139
|
+
activeRefs: Array.isArray(record.activeRefs)
|
|
140
|
+
? Object.freeze(record.activeRefs.filter((ref) => typeof ref === "string"))
|
|
141
|
+
: Object.freeze([]),
|
|
142
|
+
disposition: record.disposition,
|
|
143
|
+
...(typeof record.blocker === "string" ? { blocker: record.blocker } : {}),
|
|
144
|
+
...(record.quarantine === undefined ? {} : { quarantine: record.quarantine }),
|
|
145
|
+
...(record.cleanupReceipt === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { cleanupReceipt: record.cleanupReceipt }),
|
|
148
|
+
updatedAt: record.updatedAt
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource registry store abstraction (Issue 10, DB-only optimal).
|
|
3
|
+
*
|
|
4
|
+
* The GC engine depends on this interface, not on a specific storage backend.
|
|
5
|
+
* When the Home is SQLite-backed the registry lives in the `resource_registry`
|
|
6
|
+
* table inside `yui.db`; otherwise it falls back to a JSON file.
|
|
7
|
+
*/
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { loadResourceRegistry, saveResourceRegistry } from "./resourceRegistry.js";
|
|
11
|
+
import { SqliteResourceRegistry } from "./sqliteResourceRegistry.js";
|
|
12
|
+
/**
|
|
13
|
+
* JSON-file-backed registry store (File-store Homes).
|
|
14
|
+
*/
|
|
15
|
+
export class FileResourceRegistryStore {
|
|
16
|
+
#home;
|
|
17
|
+
constructor(home) {
|
|
18
|
+
this.#home = home;
|
|
19
|
+
}
|
|
20
|
+
load() {
|
|
21
|
+
return loadResourceRegistry(this.#home);
|
|
22
|
+
}
|
|
23
|
+
save(state) {
|
|
24
|
+
saveResourceRegistry(this.#home, state);
|
|
25
|
+
}
|
|
26
|
+
close() {
|
|
27
|
+
// No persistent connection to close.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create the appropriate registry store for a Home.
|
|
32
|
+
*
|
|
33
|
+
* SQLite-backed Homes use the `resource_registry` table in `yui.db`;
|
|
34
|
+
* File-store Homes fall back to the JSON file.
|
|
35
|
+
*/
|
|
36
|
+
export function createResourceRegistryStore(home) {
|
|
37
|
+
if (existsSync(join(home, "yui.db"))) {
|
|
38
|
+
return new SqliteResourceRegistry(home);
|
|
39
|
+
}
|
|
40
|
+
return new FileResourceRegistryStore(home);
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource GC data model (Issue 10).
|
|
3
|
+
*
|
|
4
|
+
* A Resource record describes one disk object Yui created — a managed Git
|
|
5
|
+
* worktree, a deployment, or a runtime artifact — together with everything GC
|
|
6
|
+
* needs to decide whether it may be released. The registry is GC's own state;
|
|
7
|
+
* it never owns the resource lifecycle of another Issue.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { relative, resolve } from "node:path";
|
|
11
|
+
export const RESOURCE_REGISTRY_SCHEMA_VERSION = 1;
|
|
12
|
+
/** Stable record identity: the same kind+path always maps to the same id. */
|
|
13
|
+
export function resourceId(kind, path) {
|
|
14
|
+
return createHash("sha256").update(`${kind}\u0000${path}`).digest("hex").slice(0, 16);
|
|
15
|
+
}
|
|
16
|
+
export function createResourceRecord(input, now) {
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
19
|
+
id: resourceId(input.kind, input.path),
|
|
20
|
+
...input,
|
|
21
|
+
updatedAt: now.toISOString()
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** A resource may be quarantined only when every live reference is gone. */
|
|
25
|
+
export function isReleasable(record) {
|
|
26
|
+
return record.disposition === "releasable"
|
|
27
|
+
&& record.activeRefs.length === 0
|
|
28
|
+
&& record.cleanliness !== "dirty"
|
|
29
|
+
&& record.cleanliness !== "unknown";
|
|
30
|
+
}
|
|
31
|
+
/** Terminal Task statuses: a terminal owner keeps no active runtime claim. */
|
|
32
|
+
export function isTerminalTaskStatus(status) {
|
|
33
|
+
return status === "completed" || status === "retired" || status === "archived";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The immutable release namespace owned by the package installer (Issue 02).
|
|
37
|
+
* GC never registers, quarantines, or deletes anything below it.
|
|
38
|
+
*/
|
|
39
|
+
export function isReleaseNamespacePath(home, path) {
|
|
40
|
+
const rel = relative(resolve(home), resolve(path));
|
|
41
|
+
return rel === "runtime/releases" || rel.startsWith("runtime/releases/");
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed Resource registry (Issue 10, DB-only optimal).
|
|
3
|
+
*
|
|
4
|
+
* When the Home is SQLite-backed (`yui.db` exists), the resource registry
|
|
5
|
+
* lives in the `resource_registry` table inside the same database. This
|
|
6
|
+
* keeps GC state transactional, crash-safe, and queryable alongside the
|
|
7
|
+
* aggregate — no separate JSON file to corrupt or lose.
|
|
8
|
+
*
|
|
9
|
+
* The registry is GC's own state: it is not part of the aggregate and never
|
|
10
|
+
* participates in aggregate versioning. The `resource_registry` table is
|
|
11
|
+
* created by schema migration 8.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
import { RESOURCE_REGISTRY_SCHEMA_VERSION } from "./resourceTypes.js";
|
|
17
|
+
import { emptyResourceRegistry, parseResourceRegistryState } from "./resourceRegistry.js";
|
|
18
|
+
import { migrateSqliteSchema } from "../storage/sqliteSchema.js";
|
|
19
|
+
export const SQLITE_RESOURCE_REGISTRY_TABLE = "resource_registry";
|
|
20
|
+
/**
|
|
21
|
+
* A SQLite-backed resource registry store. Each `save` is a single
|
|
22
|
+
* transaction that upserts every record and removes rows that disappeared
|
|
23
|
+
* from the in-memory state, so the on-disk table always matches the state
|
|
24
|
+
* the GC engine computed.
|
|
25
|
+
*/
|
|
26
|
+
export class SqliteResourceRegistry {
|
|
27
|
+
#db;
|
|
28
|
+
constructor(home) {
|
|
29
|
+
const dbPath = join(home, "yui.db");
|
|
30
|
+
if (!existsSync(dbPath)) {
|
|
31
|
+
throw new Error(`SQLite database not found at ${dbPath}`);
|
|
32
|
+
}
|
|
33
|
+
this.#db = new Database(dbPath);
|
|
34
|
+
this.#db.pragma("journal_mode = WAL");
|
|
35
|
+
this.#db.pragma("foreign_keys = ON");
|
|
36
|
+
this.#db.pragma("busy_timeout = 5000");
|
|
37
|
+
migrateSqliteSchema(this.#db);
|
|
38
|
+
}
|
|
39
|
+
load() {
|
|
40
|
+
const rows = this.#db.prepare(`SELECT payload FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
|
|
41
|
+
if (rows.length === 0)
|
|
42
|
+
return emptyResourceRegistry();
|
|
43
|
+
const records = {};
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(row.payload);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`SQLite resource registry has a corrupt payload: `
|
|
51
|
+
+ `${error instanceof Error ? error.message : "unknown error"}.`, { cause: error });
|
|
52
|
+
}
|
|
53
|
+
const state = parseResourceRegistryState({
|
|
54
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
55
|
+
records: { [typeof parsed === "object" && parsed !== null && "id" in parsed
|
|
56
|
+
? String(parsed.id)
|
|
57
|
+
: ""]: parsed }
|
|
58
|
+
});
|
|
59
|
+
Object.assign(records, state.records);
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
|
|
63
|
+
records: Object.freeze(records)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
save(state) {
|
|
67
|
+
const upsert = this.#db.prepare(`INSERT INTO ${SQLITE_RESOURCE_REGISTRY_TABLE}
|
|
68
|
+
(id, kind, path, disposition, task_id, payload, created_at, updated_at)
|
|
69
|
+
VALUES
|
|
70
|
+
(@id, @kind, @path, @disposition, @task_id, @payload, @created_at, @updated_at)
|
|
71
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
72
|
+
kind = excluded.kind,
|
|
73
|
+
path = excluded.path,
|
|
74
|
+
disposition = excluded.disposition,
|
|
75
|
+
task_id = excluded.task_id,
|
|
76
|
+
payload = excluded.payload,
|
|
77
|
+
updated_at = excluded.updated_at`);
|
|
78
|
+
const remove = this.#db.prepare(`DELETE FROM ${SQLITE_RESOURCE_REGISTRY_TABLE} WHERE id = ?`);
|
|
79
|
+
const existingRows = this.#db.prepare(`SELECT id FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
|
|
80
|
+
const existingIds = new Set(existingRows.map((row) => row.id));
|
|
81
|
+
const currentIds = new Set(Object.keys(state.records));
|
|
82
|
+
const tx = this.#db.transaction(() => {
|
|
83
|
+
for (const record of Object.values(state.records)) {
|
|
84
|
+
upsert.run({
|
|
85
|
+
id: record.id,
|
|
86
|
+
kind: record.kind,
|
|
87
|
+
path: record.path,
|
|
88
|
+
disposition: record.disposition,
|
|
89
|
+
task_id: record.owner.taskId ?? null,
|
|
90
|
+
payload: JSON.stringify(record),
|
|
91
|
+
created_at: record.createdAt ?? record.updatedAt,
|
|
92
|
+
updated_at: record.updatedAt
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const id of existingIds) {
|
|
96
|
+
if (!currentIds.has(id))
|
|
97
|
+
remove.run(id);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
tx();
|
|
101
|
+
}
|
|
102
|
+
close() {
|
|
103
|
+
this.#db.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Detect whether a Home uses SQLite storage (yui.db exists).
|
|
108
|
+
*/
|
|
109
|
+
export function isSqliteHome(home) {
|
|
110
|
+
return existsSync(join(home, "yui.db"));
|
|
111
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { requireIdentity } from "../domain/validation.js";
|
|
2
2
|
export const REVIEW_TRIGGERS = ["always", "leader", "final"];
|
|
3
|
+
/**
|
|
4
|
+
* Issue 06 feature flag. `shadow` records the finding ledger and serves Leader
|
|
5
|
+
* commands without changing completion behavior; `enforce` additionally fails
|
|
6
|
+
* Task completion closed on undispositioned open P1/P2 findings.
|
|
7
|
+
*/
|
|
8
|
+
export const REVIEW_FINDING_LEDGER_MODES = ["shadow", "enforce"];
|
|
3
9
|
/** The Reviewer Role seeded in a new Home by `yui setup`. */
|
|
4
10
|
export const DEFAULT_REVIEWER_ROLE = "reviewer";
|
|
5
11
|
export function validateReviewConfig(config) {
|
|
@@ -7,5 +13,9 @@ export function validateReviewConfig(config) {
|
|
|
7
13
|
if (!REVIEW_TRIGGERS.includes(config.trigger)) {
|
|
8
14
|
throw new Error(`Review trigger is invalid: ${String(config.trigger)}.`);
|
|
9
15
|
}
|
|
16
|
+
if (config.findingLedger !== undefined
|
|
17
|
+
&& !REVIEW_FINDING_LEDGER_MODES.includes(config.findingLedger)) {
|
|
18
|
+
throw new Error(`Review finding ledger mode is invalid: ${String(config.findingLedger)}.`);
|
|
19
|
+
}
|
|
10
20
|
return config;
|
|
11
21
|
}
|