@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.
@@ -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
- /** Version of the on-disk layout (`schema.json`, root `state.json`, and locks). */
9
- export const CURRENT_STORAGE_LAYOUT_VERSION = 6;
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
- * `FileTaskStore` gate to detect real structural/reference corruption. This is
107
- * only ever called when the source equals `latest` across all three axes, so a
108
- * version error cannot occur here and any throw is genuine corruption (bad
109
- * record shape, a broken reference graph).
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
- const store = new FileTaskStore(home);
114
- store.getConfig();
115
- store.listTasks();
116
- store.listProjects();
117
- store.listConfiguredAgents();
118
- store.listWorkMailboxes();
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;