@zq-silk/yui 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +2 -2
- package/dist/commands/taskWorkspaceCommands.js +11 -4
- package/dist/controller/controller.js +11 -5
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +82 -16
- package/dist/controller/runtimeEventProcessor.js +268 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/repository/taskWorkspacePreparer.js +10 -7
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/migration/productionRegistry.js +33 -0
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +444 -0
- package/dist/storage/sqliteStore.js +1222 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +277 -0
- package/dist/storage/taskStore.js +9 -0
- package/dist/storage/upgrade/homeClassification.js +38 -11
- package/dist/storage/upgrade/sqliteMigrationTarget.js +299 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +457 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +41 -15
- package/package.json +2 -1
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* and `taskStore.ts` can all import them without creating a circular dependency.
|
|
6
6
|
* `storageSchema.ts` re-exports them for backward compatibility.
|
|
7
7
|
*/
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Version of the on-disk layout (`schema.json`, root `state.json`, and locks).
|
|
10
|
+
*
|
|
11
|
+
* Layout 7 is the SQLite WAL control-plane layout (task-21 §8): the authoritative
|
|
12
|
+
* store moves from the aggregate `state.json` document to `yui.db`. A layout-6
|
|
13
|
+
* Home is migrated offline by the staged state.json→SQLite migration; layout 7
|
|
14
|
+
* is the current layout this release reads and writes.
|
|
15
|
+
*/
|
|
16
|
+
export const CURRENT_STORAGE_LAYOUT_VERSION = 7;
|
|
10
17
|
/** Version of the authoritative aggregate stored in `state.json`. */
|
|
11
18
|
export const CURRENT_AGGREGATE_SCHEMA_VERSION = 18;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded RPC seam for the persistence Worker Thread (task-21, work-item-5).
|
|
3
|
+
*
|
|
4
|
+
* The main thread talks to the persistence worker (storage/persistenceWorker)
|
|
5
|
+
* over a `MessageChannel` port. The backpressure, cancellation, and
|
|
6
|
+
* fault-boundary machinery lives in the shared `core/boundedRpc` module; this
|
|
7
|
+
* module adds the storage dialect:
|
|
8
|
+
*
|
|
9
|
+
* - {@link AsyncTaskStore} ........ the async counterpart to `TaskStore`
|
|
10
|
+
* (design §6): every method returns a promise; the worker owns the
|
|
11
|
+
* `SqliteTaskStore` connection, the main thread never touches the db.
|
|
12
|
+
* - {@link AsyncTaskStoreClient} .. the client: serializes requests, applies
|
|
13
|
+
* the storage idempotency/dialect rules on top of the shared bounded RPC
|
|
14
|
+
* (outbox §5.4, `AbortSignal` cancellation §3.1, restart + replay §3.1).
|
|
15
|
+
* - {@link StoreCommand} .......... one variant per `TaskStore` op, so
|
|
16
|
+
* `transactionAsync` ships an ordered batch that the worker runs inside one
|
|
17
|
+
* `BEGIN IMMEDIATE … COMMIT` (§3.2).
|
|
18
|
+
*
|
|
19
|
+
* The file `TaskStore` remains the default for CLI tools and tests; the worker
|
|
20
|
+
* backend is opt-in via `YUI_STORE_BACKEND=sqlite` + `YUI_STORE_WORKER=1`
|
|
21
|
+
* ({@link resolveStoreWorkerEnabled}). Rollback to the file store is a config
|
|
22
|
+
* flip (§6).
|
|
23
|
+
*/
|
|
24
|
+
import { BoundedRpcClient, nextRequestId } from "../core/boundedRpc.js";
|
|
25
|
+
import { StorageCancelledError, StorageConflictError, StorageRecordError } from "./taskStore.js";
|
|
26
|
+
// -- Read-only classification ------------------------------------------------
|
|
27
|
+
/**
|
|
28
|
+
* TaskStore methods that do not mutate. Reads use the worker's read pool
|
|
29
|
+
* (separate WAL connections that never take the write lock, §3.2). Everything
|
|
30
|
+
* else is routed to the writer connection.
|
|
31
|
+
*/
|
|
32
|
+
const READ_ONLY_STORE_METHODS = new Set([
|
|
33
|
+
"rootDirectory",
|
|
34
|
+
"getConfig",
|
|
35
|
+
"getHomeIdentity",
|
|
36
|
+
"getReviewConfig",
|
|
37
|
+
"getRevision",
|
|
38
|
+
"listConfiguredAgents",
|
|
39
|
+
"getConfiguredAgent",
|
|
40
|
+
"listProjects",
|
|
41
|
+
"getProject",
|
|
42
|
+
"listAgentProfiles",
|
|
43
|
+
"getAgentProfile",
|
|
44
|
+
"listGlobalRoles",
|
|
45
|
+
"getGlobalRole",
|
|
46
|
+
"getGlobalRoleSessionSet",
|
|
47
|
+
"listGlobalRoleSessionSets",
|
|
48
|
+
"listTasks",
|
|
49
|
+
"getTask",
|
|
50
|
+
"listActiveTaskIds",
|
|
51
|
+
"getTaskBrief",
|
|
52
|
+
"listChangeSets",
|
|
53
|
+
"getChangeSet",
|
|
54
|
+
"listIntegrationAttempts",
|
|
55
|
+
"getIntegrationAttempt",
|
|
56
|
+
"listRoles",
|
|
57
|
+
"getRole",
|
|
58
|
+
"listManagedWorkspaces",
|
|
59
|
+
"listManagedWorkspace",
|
|
60
|
+
"getManagedWorkspace",
|
|
61
|
+
"getTaskWorkspace",
|
|
62
|
+
"getWorkItemWorkspace",
|
|
63
|
+
"getReviewRoundWorkspace",
|
|
64
|
+
"getIntegrationWorkspace",
|
|
65
|
+
"getRoleSessionSet",
|
|
66
|
+
"getTaskRoleSessionSet",
|
|
67
|
+
"listRoleSessionSets",
|
|
68
|
+
"getRoleSession",
|
|
69
|
+
"getWorkItem",
|
|
70
|
+
"listWorkItems",
|
|
71
|
+
"getAgentRun",
|
|
72
|
+
"listAgentRuns",
|
|
73
|
+
"peekNextAgentRunId",
|
|
74
|
+
"getReviewRound",
|
|
75
|
+
"listReviewRuns",
|
|
76
|
+
"getActiveAgentRun",
|
|
77
|
+
"getActiveExecutionLaneRun",
|
|
78
|
+
"listMessages",
|
|
79
|
+
"getInputRequest",
|
|
80
|
+
"listInputRequests",
|
|
81
|
+
"listAllInputRequests",
|
|
82
|
+
"listDecisions",
|
|
83
|
+
"getDecision",
|
|
84
|
+
"listMilestones",
|
|
85
|
+
"getMilestone",
|
|
86
|
+
"listEvents",
|
|
87
|
+
"getWorkMailbox",
|
|
88
|
+
"listWorkMailboxes",
|
|
89
|
+
"getPendingWakeup",
|
|
90
|
+
"listPendingWakeups",
|
|
91
|
+
"getLeaderFailure",
|
|
92
|
+
"getOperatorNotification",
|
|
93
|
+
"listTelemetry",
|
|
94
|
+
"countTelemetry",
|
|
95
|
+
"hasOutboxEntry",
|
|
96
|
+
"listPendingOutbox"
|
|
97
|
+
]);
|
|
98
|
+
function isReadOnlyMethod(method) {
|
|
99
|
+
return READ_ONLY_STORE_METHODS.has(method);
|
|
100
|
+
}
|
|
101
|
+
// -- Error deserialization (storage dialect) ---------------------------------
|
|
102
|
+
function deserializeError(serialized) {
|
|
103
|
+
const { name, message } = serialized;
|
|
104
|
+
if (name === "StorageConflictError")
|
|
105
|
+
return new StorageConflictError(message);
|
|
106
|
+
if (name === "StorageRecordError")
|
|
107
|
+
return new StorageRecordError(message);
|
|
108
|
+
if (name === "StorageCancelledError" || name === "AbortError") {
|
|
109
|
+
return new StorageCancelledError(message);
|
|
110
|
+
}
|
|
111
|
+
const error = new Error(message);
|
|
112
|
+
error.name = name;
|
|
113
|
+
return error;
|
|
114
|
+
}
|
|
115
|
+
// -- Storage protocol adapter -------------------------------------------------
|
|
116
|
+
function storageProtocol(home, options) {
|
|
117
|
+
return {
|
|
118
|
+
initRequest: () => ({
|
|
119
|
+
kind: "init",
|
|
120
|
+
home,
|
|
121
|
+
...(options.readPoolSize === undefined ? {} : { readPoolSize: options.readPoolSize }),
|
|
122
|
+
...(options.observerModule === undefined
|
|
123
|
+
? {}
|
|
124
|
+
: { observerModule: String(options.observerModule) })
|
|
125
|
+
}),
|
|
126
|
+
cancelRequest: (requestId) => ({ kind: "cancel", requestId }),
|
|
127
|
+
shutdownRequest: () => ({ kind: "shutdown" }),
|
|
128
|
+
isReady: (response) => response.kind === "ready",
|
|
129
|
+
responseRequestId: (response) => {
|
|
130
|
+
if (response.kind === "ready") {
|
|
131
|
+
throw new Error("ready response has no requestId.");
|
|
132
|
+
}
|
|
133
|
+
return response.requestId;
|
|
134
|
+
},
|
|
135
|
+
settle: (response, settlement) => {
|
|
136
|
+
if (response.kind === "result") {
|
|
137
|
+
settlement.resolve(response.result);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (response.kind === "already-applied") {
|
|
141
|
+
// The effect committed before a crash; the retry is deduped (§5.4). The
|
|
142
|
+
// original result is not retained; callers needing it re-read. Observer
|
|
143
|
+
// callers treat `undefined` as "applied".
|
|
144
|
+
settlement.resolve(undefined);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (response.kind === "error") {
|
|
148
|
+
settlement.reject(deserializeError(response.error));
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
abortError: (beforeSend) => new StorageCancelledError(beforeSend ? "Request aborted before it was sent." : "Request aborted.")
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// -- Client ------------------------------------------------------------------
|
|
155
|
+
/**
|
|
156
|
+
* The main-thread client for the persistence worker. Implements
|
|
157
|
+
* {@link AsyncTaskStore} (via a Proxy that forwards `TaskStore` methods as `call`
|
|
158
|
+
* RPCs) plus {@link transactionAsync}, {@link invokeObserver}, and {@link close}.
|
|
159
|
+
*/
|
|
160
|
+
export class AsyncTaskStoreClient {
|
|
161
|
+
#home;
|
|
162
|
+
#options;
|
|
163
|
+
#rpc;
|
|
164
|
+
constructor(home, options = {}) {
|
|
165
|
+
this.#home = home;
|
|
166
|
+
this.#options = options;
|
|
167
|
+
this.#rpc = new BoundedRpcClient(storageProtocol(home, options), {
|
|
168
|
+
maxInFlight: options.maxInFlight,
|
|
169
|
+
maxQueue: options.maxQueue,
|
|
170
|
+
workerScript: options.workerScript ?? new URL("./persistenceWorker.js", import.meta.url)
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** Invoke a TaskStore method over the RPC (used by the Proxy). */
|
|
174
|
+
callStore(method, args, options) {
|
|
175
|
+
const requestId = options?.requestId ?? nextRequestId();
|
|
176
|
+
return this.#rpc.send(requestId, {
|
|
177
|
+
kind: "call",
|
|
178
|
+
requestId,
|
|
179
|
+
method,
|
|
180
|
+
args,
|
|
181
|
+
readOnly: isReadOnlyMethod(method)
|
|
182
|
+
}, { signal: options?.signal });
|
|
183
|
+
}
|
|
184
|
+
/** Run a command batch atomically in the worker (§3.2). */
|
|
185
|
+
transactionAsync(commands, options) {
|
|
186
|
+
const requestId = options?.requestId ?? nextRequestId();
|
|
187
|
+
const wireCommands = commands.map((command) => ({
|
|
188
|
+
op: command.op,
|
|
189
|
+
args: command.args
|
|
190
|
+
}));
|
|
191
|
+
return this.#rpc.send(requestId, {
|
|
192
|
+
kind: "transaction",
|
|
193
|
+
requestId,
|
|
194
|
+
commands: wireCommands,
|
|
195
|
+
...(options?.expectedRevision === undefined ? {} : { expectedRevision: options.expectedRevision })
|
|
196
|
+
}, { signal: options?.signal });
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Invoke a controller observer method hosted by the worker (the
|
|
200
|
+
* `FileSchedulerStoreAdapter` observer surface). The adapter's folds run in
|
|
201
|
+
* the worker, off the main event loop.
|
|
202
|
+
*/
|
|
203
|
+
invokeObserver(method, args, options) {
|
|
204
|
+
const requestId = options?.requestId ?? nextRequestId();
|
|
205
|
+
return this.#rpc.send(requestId, { kind: "observer", requestId, method, args }, { signal: options?.signal });
|
|
206
|
+
}
|
|
207
|
+
/** Close the worker and release its connections. */
|
|
208
|
+
close() {
|
|
209
|
+
return this.#rpc.close();
|
|
210
|
+
}
|
|
211
|
+
/** Currently in-flight requests (metrics/tests). */
|
|
212
|
+
get inFlight() {
|
|
213
|
+
return this.#rpc.inFlight;
|
|
214
|
+
}
|
|
215
|
+
/** Currently queued requests waiting for a slot (metrics/tests). */
|
|
216
|
+
get queueDepth() {
|
|
217
|
+
return this.#rpc.queueDepth;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Test-only fault injection: abruptly terminate the worker (simulating a
|
|
221
|
+
* crash) so the exit handler restarts it and replays unacknowledged requests
|
|
222
|
+
* (§3.1 fault boundary). The pending requests stay pending; they resolve
|
|
223
|
+
* after the restart + replay.
|
|
224
|
+
*/
|
|
225
|
+
crashForTest() {
|
|
226
|
+
return this.#rpc.crashForTest();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Open an {@link AsyncTaskStore} backed by the persistence worker. The returned
|
|
231
|
+
* object is a Proxy that forwards `TaskStore` methods as RPCs; `transactionAsync`
|
|
232
|
+
* and `close` are handled directly.
|
|
233
|
+
*/
|
|
234
|
+
export function openAsyncTaskStoreClient(home, options = {}) {
|
|
235
|
+
const client = new AsyncTaskStoreClient(home, options);
|
|
236
|
+
const proxy = new Proxy(client, {
|
|
237
|
+
get(target, property) {
|
|
238
|
+
if (property === "then")
|
|
239
|
+
return undefined; // not promise-like
|
|
240
|
+
if (property in target) {
|
|
241
|
+
// Access on the target (not the proxy) so getters that touch private
|
|
242
|
+
// fields (#slots, etc.) resolve against the class instance.
|
|
243
|
+
const value = Reflect.get(target, property);
|
|
244
|
+
if (typeof value === "function")
|
|
245
|
+
return value.bind(target);
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
if (typeof property === "string") {
|
|
249
|
+
return (...args) => {
|
|
250
|
+
const options = args.length > 0 && isRpcCallOptions(args[args.length - 1])
|
|
251
|
+
? args.pop()
|
|
252
|
+
: undefined;
|
|
253
|
+
return target.callStore(property, args, options);
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return proxy;
|
|
260
|
+
}
|
|
261
|
+
function isRpcCallOptions(value) {
|
|
262
|
+
if (typeof value !== "object" || value === null)
|
|
263
|
+
return false;
|
|
264
|
+
const record = value;
|
|
265
|
+
return "requestId" in record || "signal" in record;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Resolve whether the persistence worker backend is enabled (design §6).
|
|
269
|
+
* The worker requires the SQLite backend (`YUI_STORE_BACKEND=sqlite`) and is
|
|
270
|
+
* opt-in via `YUI_STORE_WORKER=1`. The file store remains the default.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveStoreWorkerEnabled(env = process.env) {
|
|
273
|
+
if (env.YUI_STORE_BACKEND?.toLowerCase() !== "sqlite")
|
|
274
|
+
return false;
|
|
275
|
+
const flag = env.YUI_STORE_WORKER;
|
|
276
|
+
return flag === "1" || flag?.toLowerCase() === "true";
|
|
277
|
+
}
|
|
@@ -1165,6 +1165,15 @@ export class StorageRecordError extends Error {
|
|
|
1165
1165
|
export class StorageConflictError extends Error {
|
|
1166
1166
|
constructor(message) { super(message); this.name = "StorageConflictError"; }
|
|
1167
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Raised by the persistence worker when an `AbortSignal` cancels an in-flight
|
|
1170
|
+
* command batch. The open transaction is rolled back; the database is unchanged.
|
|
1171
|
+
* Already-committed transactions are not undone (their effects are idempotent
|
|
1172
|
+
* and semantically owned by the caller, design §3.1).
|
|
1173
|
+
*/
|
|
1174
|
+
export class StorageCancelledError extends Error {
|
|
1175
|
+
constructor(message) { super(message); this.name = "StorageCancelledError"; }
|
|
1176
|
+
}
|
|
1168
1177
|
export function resolveYuiHome(env) {
|
|
1169
1178
|
return env.YUI_HOME === undefined || env.YUI_HOME.length === 0
|
|
1170
1179
|
? join(homedir(), ".yui")
|
|
@@ -23,9 +23,11 @@
|
|
|
23
23
|
* non-empty families agree. This keeps a record-only-older Home on its version
|
|
24
24
|
* axis without letting an empty target family masquerade as current.
|
|
25
25
|
*/
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
26
27
|
import { classifyStorage } from "../migration/index.js";
|
|
27
28
|
import { inspectStorageSchema } from "../storageSchema.js";
|
|
28
29
|
import { FileTaskStore, StorageRecordError } from "../taskStore.js";
|
|
30
|
+
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
29
31
|
import { inspectSourceVersionState } from "./homeMigrationTarget.js";
|
|
30
32
|
/**
|
|
31
33
|
* Classify a real Home. Reads `schema.json` (and, for a current Home,
|
|
@@ -102,26 +104,51 @@ export function classifyHome(options) {
|
|
|
102
104
|
};
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
105
|
-
* Load a Home whose every axis is already current through the strict
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
107
|
+
* Load a Home whose every axis is already current through the strict store
|
|
108
|
+
* gate to detect real structural/reference corruption. This is only ever
|
|
109
|
+
* called when the source equals `latest` across all three axes, so a version
|
|
110
|
+
* error cannot occur here and any throw is genuine corruption (bad record
|
|
111
|
+
* shape, a broken reference graph, or a damaged SQLite database).
|
|
112
|
+
*
|
|
113
|
+
* A layout-7 Home whose authoritative store is `yui.db` is verified through
|
|
114
|
+
* {@link SqliteTaskStore}; a layout-7 Home that still uses the aggregate
|
|
115
|
+
* `state.json` (or a layout-6 Home in tests) is verified through
|
|
116
|
+
* {@link FileTaskStore}.
|
|
110
117
|
*/
|
|
111
118
|
function detectCurrentHomeCorruption(home) {
|
|
112
119
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
if (existsSync(`${home}/yui.db`)) {
|
|
121
|
+
const store = new SqliteTaskStore(home);
|
|
122
|
+
try {
|
|
123
|
+
store.getConfig();
|
|
124
|
+
store.listTasks();
|
|
125
|
+
store.listProjects();
|
|
126
|
+
store.listConfiguredAgents();
|
|
127
|
+
store.listWorkMailboxes();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
store.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const store = new FileTaskStore(home);
|
|
135
|
+
store.getConfig();
|
|
136
|
+
store.listTasks();
|
|
137
|
+
store.listProjects();
|
|
138
|
+
store.listConfiguredAgents();
|
|
139
|
+
store.listWorkMailboxes();
|
|
140
|
+
}
|
|
119
141
|
return undefined;
|
|
120
142
|
}
|
|
121
143
|
catch (error) {
|
|
122
144
|
if (error instanceof StorageRecordError) {
|
|
123
145
|
return { corrupted: true, detail: error.message };
|
|
124
146
|
}
|
|
147
|
+
// A SQLite-level error (corrupt database file, I/O fault) is structural
|
|
148
|
+
// damage, not a version mismatch.
|
|
149
|
+
if (error instanceof Error && (error.name === "SqliteError" || error.message.includes("SQLite"))) {
|
|
150
|
+
return { corrupted: true, detail: error.message };
|
|
151
|
+
}
|
|
125
152
|
// A non-record error (e.g. an unexpected I/O fault) is surfaced, not
|
|
126
153
|
// silently swallowed as "usable".
|
|
127
154
|
throw error;
|