gsd-pi 2.78.1-dev.d8826a445 → 2.78.1-dev.eccf86e27
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 -7
- package/dist/help-text.js +1 -1
- package/dist/resource-loader.js +6 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
- package/dist/resources/extensions/gsd/auto/loop.js +235 -36
- package/dist/resources/extensions/gsd/auto/phases.js +7 -5
- package/dist/resources/extensions/gsd/auto/session.js +33 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +46 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +19 -11
- package/dist/resources/extensions/gsd/auto-worktree.js +26 -187
- package/dist/resources/extensions/gsd/auto.js +79 -50
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +9 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
- package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
- package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
- package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
- package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
- package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
- package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
- package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
- package/dist/resources/extensions/gsd/doctor.js +12 -2
- package/dist/resources/extensions/gsd/gsd-db.js +161 -3
- package/dist/resources/extensions/gsd/guided-flow.js +6 -2
- package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
- package/dist/resources/extensions/gsd/state.js +21 -6
- package/dist/resources/extensions/gsd/worktree-resolver.js +64 -0
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
- package/src/resources/extensions/gsd/auto/loop.ts +263 -41
- package/src/resources/extensions/gsd/auto/phases.ts +7 -5
- package/src/resources/extensions/gsd/auto/session.ts +36 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +53 -2
- package/src/resources/extensions/gsd/auto-post-unit.ts +19 -11
- package/src/resources/extensions/gsd/auto-worktree.ts +26 -211
- package/src/resources/extensions/gsd/auto.ts +89 -44
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
- package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
- package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
- package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
- package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
- package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
- package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
- package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
- package/src/resources/extensions/gsd/doctor.ts +10 -2
- package/src/resources/extensions/gsd/gsd-db.ts +170 -3
- package/src/resources/extensions/gsd/guided-flow.ts +6 -2
- package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
- package/src/resources/extensions/gsd/state.ts +44 -6
- package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
- package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
- package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
- package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
- package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
- package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
- package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
- package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +3 -5
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
- package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
- package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
- package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +110 -0
- package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
- package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
- package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
- package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +7 -26
- package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +4 -8
- package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
- package/src/resources/extensions/gsd/tests/workspace.test.ts +15 -9
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +31 -23
- package/src/resources/extensions/gsd/worktree-resolver.ts +62 -0
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
- package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
- /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
|
@@ -182,7 +182,7 @@ function openRawDb(path: string): unknown {
|
|
|
182
182
|
return new Database(path);
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
export const SCHEMA_VERSION =
|
|
185
|
+
export const SCHEMA_VERSION = 25;
|
|
186
186
|
|
|
187
187
|
function indexExists(db: DbAdapter, name: string): boolean {
|
|
188
188
|
return !!db.prepare(
|
|
@@ -190,6 +190,137 @@ function indexExists(db: DbAdapter, name: string): boolean {
|
|
|
190
190
|
).get(name);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Create the v24 coordination tables (workers, milestone_leases,
|
|
195
|
+
* unit_dispatches, cancellation_requests, command_queue) and their indexes.
|
|
196
|
+
*
|
|
197
|
+
* Idempotent — uses IF NOT EXISTS throughout. Called from both the
|
|
198
|
+
* fresh-install path and the v24 migration block in migrateSchema().
|
|
199
|
+
*
|
|
200
|
+
* Single-host invariant: these tables coordinate concurrent auto-mode
|
|
201
|
+
* workers via shared SQLite WAL on local disk only. NFS / network
|
|
202
|
+
* filesystems break the coordination semantics — multi-host execution
|
|
203
|
+
* needs a real coordinator (etcd, Postgres) and is out of scope.
|
|
204
|
+
*/
|
|
205
|
+
function createCoordinationTablesV24(db: DbAdapter): void {
|
|
206
|
+
const ddl = [
|
|
207
|
+
`CREATE TABLE IF NOT EXISTS workers (
|
|
208
|
+
worker_id TEXT PRIMARY KEY,
|
|
209
|
+
host TEXT NOT NULL,
|
|
210
|
+
pid INTEGER NOT NULL,
|
|
211
|
+
started_at TEXT NOT NULL,
|
|
212
|
+
version TEXT NOT NULL,
|
|
213
|
+
last_heartbeat_at TEXT NOT NULL,
|
|
214
|
+
status TEXT NOT NULL,
|
|
215
|
+
project_root_realpath TEXT NOT NULL
|
|
216
|
+
)`,
|
|
217
|
+
`CREATE TABLE IF NOT EXISTS milestone_leases (
|
|
218
|
+
milestone_id TEXT PRIMARY KEY,
|
|
219
|
+
worker_id TEXT NOT NULL,
|
|
220
|
+
fencing_token INTEGER NOT NULL,
|
|
221
|
+
acquired_at TEXT NOT NULL,
|
|
222
|
+
expires_at TEXT NOT NULL,
|
|
223
|
+
status TEXT NOT NULL,
|
|
224
|
+
FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
|
|
225
|
+
FOREIGN KEY (milestone_id) REFERENCES milestones(id)
|
|
226
|
+
)`,
|
|
227
|
+
`CREATE TABLE IF NOT EXISTS unit_dispatches (
|
|
228
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
229
|
+
trace_id TEXT NOT NULL,
|
|
230
|
+
turn_id TEXT,
|
|
231
|
+
worker_id TEXT NOT NULL,
|
|
232
|
+
milestone_lease_token INTEGER NOT NULL,
|
|
233
|
+
milestone_id TEXT NOT NULL,
|
|
234
|
+
slice_id TEXT,
|
|
235
|
+
task_id TEXT,
|
|
236
|
+
unit_type TEXT NOT NULL,
|
|
237
|
+
unit_id TEXT NOT NULL,
|
|
238
|
+
status TEXT NOT NULL,
|
|
239
|
+
attempt_n INTEGER NOT NULL DEFAULT 1,
|
|
240
|
+
started_at TEXT NOT NULL,
|
|
241
|
+
ended_at TEXT,
|
|
242
|
+
exit_reason TEXT,
|
|
243
|
+
error_summary TEXT,
|
|
244
|
+
verification_evidence_id INTEGER,
|
|
245
|
+
next_run_at TEXT,
|
|
246
|
+
retry_after_ms INTEGER,
|
|
247
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
248
|
+
last_error_code TEXT,
|
|
249
|
+
last_error_at TEXT,
|
|
250
|
+
FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
|
|
251
|
+
FOREIGN KEY (verification_evidence_id) REFERENCES verification_evidence(id)
|
|
252
|
+
)`,
|
|
253
|
+
`CREATE TABLE IF NOT EXISTS cancellation_requests (
|
|
254
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
255
|
+
requested_at TEXT NOT NULL,
|
|
256
|
+
requested_by TEXT NOT NULL,
|
|
257
|
+
scope TEXT NOT NULL,
|
|
258
|
+
scope_id TEXT NOT NULL,
|
|
259
|
+
dispatch_id INTEGER,
|
|
260
|
+
reason TEXT NOT NULL,
|
|
261
|
+
status TEXT NOT NULL,
|
|
262
|
+
acked_at TEXT,
|
|
263
|
+
acked_worker_id TEXT,
|
|
264
|
+
FOREIGN KEY (dispatch_id) REFERENCES unit_dispatches(id),
|
|
265
|
+
FOREIGN KEY (acked_worker_id) REFERENCES workers(worker_id)
|
|
266
|
+
)`,
|
|
267
|
+
`CREATE TABLE IF NOT EXISTS command_queue (
|
|
268
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
269
|
+
target_worker TEXT,
|
|
270
|
+
command TEXT NOT NULL,
|
|
271
|
+
args_json TEXT NOT NULL DEFAULT '{}',
|
|
272
|
+
enqueued_at TEXT NOT NULL,
|
|
273
|
+
claimed_at TEXT,
|
|
274
|
+
claimed_by TEXT,
|
|
275
|
+
completed_at TEXT,
|
|
276
|
+
result_json TEXT
|
|
277
|
+
)`,
|
|
278
|
+
];
|
|
279
|
+
for (const stmt of ddl) db.exec(stmt);
|
|
280
|
+
|
|
281
|
+
// Indexes — created here so both fresh-install and v24-migration paths
|
|
282
|
+
// produce identical structure.
|
|
283
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_active ON unit_dispatches(milestone_id, status)");
|
|
284
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_trace ON unit_dispatches(trace_id, turn_id)");
|
|
285
|
+
// Partial unique index — prevents two workers from claiming the same
|
|
286
|
+
// unit concurrently. Codex review MEDIUM B2: enforces double-claim guard
|
|
287
|
+
// at the DB level.
|
|
288
|
+
db.exec(
|
|
289
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_unit_dispatches_active_per_unit "
|
|
290
|
+
+ "ON unit_dispatches(unit_id) WHERE status IN ('claimed','running')",
|
|
291
|
+
);
|
|
292
|
+
// command_queue index — SQLite indexes NULLs in B-trees, so this single
|
|
293
|
+
// index serves both targeted (target_worker = ?) and broadcast
|
|
294
|
+
// (target_worker IS NULL) queries. Codex review LOW B4 documented.
|
|
295
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_command_queue_pending ON command_queue(target_worker, claimed_at)");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Create the v25 runtime_kv table. Idempotent — uses IF NOT EXISTS.
|
|
300
|
+
*
|
|
301
|
+
* STRICT INVARIANT: runtime_kv is NON-CORRECTNESS-CRITICAL. UI cursors,
|
|
302
|
+
* dashboard caches, last-seen-version markers, resume cursors, and other
|
|
303
|
+
* "soft" state are OK. Anything that drives auto-mode control flow gets
|
|
304
|
+
* typed columns in unit_dispatches / workers / milestone_leases — never
|
|
305
|
+
* a bag of JSON in runtime_kv.
|
|
306
|
+
*
|
|
307
|
+
* Scope partitioning: ('global', '', key) for project-wide values;
|
|
308
|
+
* ('worker', worker_id, key) for per-worker state (resume cursors);
|
|
309
|
+
* ('milestone', milestone_id, key) for per-milestone soft state.
|
|
310
|
+
*/
|
|
311
|
+
function createRuntimeKvTableV25(db: DbAdapter): void {
|
|
312
|
+
db.exec(`
|
|
313
|
+
CREATE TABLE IF NOT EXISTS runtime_kv (
|
|
314
|
+
scope TEXT NOT NULL,
|
|
315
|
+
scope_id TEXT NOT NULL DEFAULT '',
|
|
316
|
+
key TEXT NOT NULL,
|
|
317
|
+
value_json TEXT NOT NULL,
|
|
318
|
+
updated_at TEXT NOT NULL,
|
|
319
|
+
PRIMARY KEY (scope, scope_id, key)
|
|
320
|
+
)
|
|
321
|
+
`);
|
|
322
|
+
}
|
|
323
|
+
|
|
193
324
|
function dedupeVerificationEvidenceRows(db: DbAdapter): void {
|
|
194
325
|
db.exec(`
|
|
195
326
|
DELETE FROM verification_evidence
|
|
@@ -586,6 +717,9 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
|
|
|
586
717
|
|
|
587
718
|
const existing = db.prepare("SELECT count(*) as cnt FROM schema_version").get();
|
|
588
719
|
if (existing && (existing["cnt"] as number) === 0) {
|
|
720
|
+
createCoordinationTablesV24(db);
|
|
721
|
+
createRuntimeKvTableV25(db);
|
|
722
|
+
|
|
589
723
|
// Fresh install — all tables are created above with the full current schema,
|
|
590
724
|
// so it is safe to create all migration-specific indexes here. For existing
|
|
591
725
|
// databases these indexes are created inside the individual migration guards
|
|
@@ -1246,6 +1380,28 @@ function migrateSchema(db: DbAdapter): void {
|
|
|
1246
1380
|
});
|
|
1247
1381
|
}
|
|
1248
1382
|
|
|
1383
|
+
if (currentVersion < 24) {
|
|
1384
|
+
// v24: auto-mode coordination tables. See createCoordinationTablesV24
|
|
1385
|
+
// for full schema + invariants. No-op for fresh installs (the same
|
|
1386
|
+
// helper runs in the fresh-install path); for upgraded DBs this is
|
|
1387
|
+
// the only place these tables get created.
|
|
1388
|
+
createCoordinationTablesV24(db);
|
|
1389
|
+
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
|
|
1390
|
+
":version": 24,
|
|
1391
|
+
":applied_at": new Date().toISOString(),
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (currentVersion < 25) {
|
|
1396
|
+
// v25: runtime_kv non-correctness-critical key-value storage. See
|
|
1397
|
+
// createRuntimeKvTableV25 for the full schema + invariants.
|
|
1398
|
+
createRuntimeKvTableV25(db);
|
|
1399
|
+
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
|
|
1400
|
+
":version": 25,
|
|
1401
|
+
":applied_at": new Date().toISOString(),
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1249
1405
|
db.exec("COMMIT");
|
|
1250
1406
|
} catch (err) {
|
|
1251
1407
|
db.exec("ROLLBACK");
|
|
@@ -3301,6 +3457,9 @@ export function deleteMilestone(milestoneId: string): void {
|
|
|
3301
3457
|
currentDb!.prepare(
|
|
3302
3458
|
`DELETE FROM artifacts WHERE milestone_id = :mid`,
|
|
3303
3459
|
).run({ ":mid": milestoneId });
|
|
3460
|
+
currentDb!.prepare(
|
|
3461
|
+
`DELETE FROM milestone_leases WHERE milestone_id = :mid`,
|
|
3462
|
+
).run({ ":mid": milestoneId });
|
|
3304
3463
|
currentDb!.prepare(
|
|
3305
3464
|
`DELETE FROM milestones WHERE id = :mid`,
|
|
3306
3465
|
).run({ ":mid": milestoneId });
|
|
@@ -3723,14 +3882,20 @@ export function deleteArtifactByPath(path: string): void {
|
|
|
3723
3882
|
}
|
|
3724
3883
|
|
|
3725
3884
|
/**
|
|
3726
|
-
* Drop
|
|
3727
|
-
*
|
|
3885
|
+
* Drop hierarchy rows in dependency order inside a transaction. Used by
|
|
3886
|
+
* `gsd recover` to rebuild engine state from markdown.
|
|
3728
3887
|
*/
|
|
3729
3888
|
export function clearEngineHierarchy(): void {
|
|
3730
3889
|
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
3731
3890
|
transaction(() => {
|
|
3891
|
+
currentDb!.exec("DELETE FROM verification_evidence");
|
|
3892
|
+
currentDb!.exec("DELETE FROM quality_gates");
|
|
3893
|
+
currentDb!.exec("DELETE FROM slice_dependencies");
|
|
3894
|
+
currentDb!.exec("DELETE FROM assessments");
|
|
3895
|
+
currentDb!.exec("DELETE FROM replan_history");
|
|
3732
3896
|
currentDb!.exec("DELETE FROM tasks");
|
|
3733
3897
|
currentDb!.exec("DELETE FROM slices");
|
|
3898
|
+
currentDb!.exec("DELETE FROM milestone_leases");
|
|
3734
3899
|
currentDb!.exec("DELETE FROM milestones");
|
|
3735
3900
|
});
|
|
3736
3901
|
}
|
|
@@ -3844,6 +4009,7 @@ export function restoreManifest(manifest: StateManifest): void {
|
|
|
3844
4009
|
db.exec("DELETE FROM verification_evidence");
|
|
3845
4010
|
db.exec("DELETE FROM tasks");
|
|
3846
4011
|
db.exec("DELETE FROM slices");
|
|
4012
|
+
db.exec("DELETE FROM milestone_leases");
|
|
3847
4013
|
db.exec("DELETE FROM milestones");
|
|
3848
4014
|
db.exec("DELETE FROM decisions WHERE 1=1");
|
|
3849
4015
|
|
|
@@ -3982,6 +4148,7 @@ export function bulkInsertLegacyHierarchy(payload: {
|
|
|
3982
4148
|
transaction(() => {
|
|
3983
4149
|
db.prepare(`DELETE FROM tasks WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
|
|
3984
4150
|
db.prepare(`DELETE FROM slices WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
|
|
4151
|
+
db.prepare(`DELETE FROM milestone_leases WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
|
|
3985
4152
|
db.prepare(`DELETE FROM milestones WHERE id IN (${placeholders})`).run(...clearMilestoneIds);
|
|
3986
4153
|
|
|
3987
4154
|
const insertMilestone = db.prepare(
|
|
@@ -83,6 +83,8 @@ export {
|
|
|
83
83
|
buildExistingMilestonesContext,
|
|
84
84
|
} from "./guided-flow-queue.js";
|
|
85
85
|
import { logWarning } from "./workflow-logger.js";
|
|
86
|
+
import { deleteRuntimeKv } from "./db/runtime-kv.js";
|
|
87
|
+
import { PAUSED_SESSION_KV_KEY } from "./interrupted-session.js";
|
|
86
88
|
|
|
87
89
|
// ─── Scope-based validator wrappers ──────────────────────────────────────────
|
|
88
90
|
// These thin wrappers accept a MilestoneScope so callers that already hold a
|
|
@@ -1963,10 +1965,12 @@ export async function showSmartEntry(
|
|
|
1963
1965
|
if (interrupted.classification === "stale") {
|
|
1964
1966
|
clearLock(basePath);
|
|
1965
1967
|
if (interrupted.pausedSession) {
|
|
1968
|
+
// Phase C pt 2: paused-session.json migrated to runtime_kv
|
|
1969
|
+
// (global scope, key PAUSED_SESSION_KV_KEY).
|
|
1966
1970
|
try {
|
|
1967
|
-
|
|
1971
|
+
deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
|
|
1968
1972
|
} catch (e) {
|
|
1969
|
-
logWarning("guided", `stale
|
|
1973
|
+
logWarning("guided", `stale paused-session DB cleanup failed: ${(e as Error).message}`, { file: "guided-flow.ts" });
|
|
1970
1974
|
}
|
|
1971
1975
|
}
|
|
1972
1976
|
} else if (interrupted.classification === "recoverable") {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { verifyExpectedArtifact } from "./auto-recovery.js";
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./session-forensics.js";
|
|
17
17
|
import { deriveState } from "./state.js";
|
|
18
18
|
import type { GSDState } from "./types.js";
|
|
19
|
+
import { getRuntimeKv, deleteRuntimeKv } from "./db/runtime-kv.js";
|
|
19
20
|
|
|
20
21
|
export type InterruptedSessionClassification =
|
|
21
22
|
| "none"
|
|
@@ -82,22 +83,28 @@ function isStalePseudoMilestonePause(meta: PausedSessionMetadata): boolean {
|
|
|
82
83
|
&& LEGACY_DEEP_SETUP_UNITS.has(`${meta.unitType}:${meta.unitId}`);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* runtime_kv key (global scope) that stores the most recent paused-session
|
|
88
|
+
* metadata. Phase C pt 2: replaces runtime/paused-session.json. The key is
|
|
89
|
+
* project-wide (not worker-scoped) because the paused state represents the
|
|
90
|
+
* last time auto-mode paused on this project — there is at most one paused
|
|
91
|
+
* session per project at a time.
|
|
92
|
+
*/
|
|
93
|
+
export const PAUSED_SESSION_KV_KEY = "paused_session";
|
|
94
|
+
|
|
85
95
|
export function readPausedSessionMetadata(
|
|
86
96
|
basePath: string,
|
|
87
97
|
): PausedSessionMetadata | null {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
return meta;
|
|
98
|
-
} catch {
|
|
98
|
+
// basePath is unused now (the DB is workspace-scoped via the connection
|
|
99
|
+
// openDatabase opened on it) but kept in the signature for callers.
|
|
100
|
+
void basePath;
|
|
101
|
+
const meta = getRuntimeKv<PausedSessionMetadata>("global", "", PAUSED_SESSION_KV_KEY);
|
|
102
|
+
if (!meta) return null;
|
|
103
|
+
if (isStalePseudoMilestonePause(meta)) {
|
|
104
|
+
deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
|
|
99
105
|
return null;
|
|
100
106
|
}
|
|
107
|
+
return meta;
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
export function isBootstrapCrashLock(lock: LockData | null): boolean {
|
|
@@ -270,6 +270,21 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|
|
270
270
|
return null;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Options for deriveState read-path routing.
|
|
275
|
+
*
|
|
276
|
+
* `projectRootForReads`: canonical project root (e.g. from
|
|
277
|
+
* `s.canonicalProjectRoot`) used for both the cache key and the artifact-read
|
|
278
|
+
* root in `_deriveStateImpl`. When omitted, behavior is identical to the
|
|
279
|
+
* single-arg signature (back-compat for all existing callers).
|
|
280
|
+
*
|
|
281
|
+
* Typed as an object literal (not `string | DeriveStateOptions`) so accidental
|
|
282
|
+
* `deriveState(path, "string")` is rejected at compile time.
|
|
283
|
+
*/
|
|
284
|
+
export interface DeriveStateOptions {
|
|
285
|
+
projectRootForReads?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
273
288
|
/**
|
|
274
289
|
* Reconstruct GSD state from the authoritative DB.
|
|
275
290
|
* STATE.md is a rendered cache of this output.
|
|
@@ -278,11 +293,22 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|
|
278
293
|
* Legacy filesystem parsing is available only through an explicit opt-in for
|
|
279
294
|
* tests/recovery flows; runtime must not silently infer state from markdown.
|
|
280
295
|
*/
|
|
281
|
-
export async function deriveState(
|
|
282
|
-
|
|
296
|
+
export async function deriveState(
|
|
297
|
+
basePath: string,
|
|
298
|
+
opts?: DeriveStateOptions,
|
|
299
|
+
): Promise<GSDState> {
|
|
300
|
+
// Use the canonical project root (when provided) as the cache key so that
|
|
301
|
+
// two calls with different basePath strings (e.g. worktree path vs project
|
|
302
|
+
// root) but the same canonical .gsd/ share a single cache entry. The same
|
|
303
|
+
// key is used for both the lookup AND the write below — keying lookup on
|
|
304
|
+
// canonical-root while writing on basePath would silently return stale
|
|
305
|
+
// results across path-form alternation.
|
|
306
|
+
const cacheKey = opts?.projectRootForReads ?? basePath;
|
|
307
|
+
|
|
308
|
+
// Return cached result if within the TTL window for the same cacheKey
|
|
283
309
|
if (
|
|
284
310
|
_stateCache &&
|
|
285
|
-
_stateCache.basePath ===
|
|
311
|
+
_stateCache.basePath === cacheKey &&
|
|
286
312
|
Date.now() - _stateCache.timestamp < CACHE_TTL_MS
|
|
287
313
|
) {
|
|
288
314
|
return _stateCache.result;
|
|
@@ -303,7 +329,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
303
329
|
if (wasDbOpenAttempted()) {
|
|
304
330
|
logWarning("state", "DB unavailable — using explicit legacy filesystem state derivation");
|
|
305
331
|
}
|
|
306
|
-
result = await _deriveStateImpl(basePath);
|
|
332
|
+
result = await _deriveStateImpl(basePath, opts);
|
|
307
333
|
_telemetry.markdownDeriveCount++;
|
|
308
334
|
} else {
|
|
309
335
|
if (wasDbOpenAttempted()) {
|
|
@@ -325,7 +351,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
|
|
|
325
351
|
|
|
326
352
|
stopTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
|
|
327
353
|
debugCount("deriveStateCalls");
|
|
328
|
-
_stateCache = { basePath, result, timestamp: Date.now() };
|
|
354
|
+
_stateCache = { basePath: cacheKey, result, timestamp: Date.now() };
|
|
329
355
|
return result;
|
|
330
356
|
}
|
|
331
357
|
|
|
@@ -838,7 +864,19 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|
|
838
864
|
// LEGACY: Filesystem-based state derivation for unmigrated projects.
|
|
839
865
|
// DB-backed projects use deriveStateFromDb() above. Target: extract to
|
|
840
866
|
// state-legacy.ts when all projects are DB-backed.
|
|
841
|
-
export async function _deriveStateImpl(
|
|
867
|
+
export async function _deriveStateImpl(
|
|
868
|
+
basePath: string,
|
|
869
|
+
opts?: DeriveStateOptions,
|
|
870
|
+
): Promise<GSDState> {
|
|
871
|
+
// When the caller supplies a canonical project root for reads (e.g.
|
|
872
|
+
// s.canonicalProjectRoot from auto-mode), route all artifact reads through
|
|
873
|
+
// it. This prevents the worktree-local empty `.gsd/` from being consulted
|
|
874
|
+
// when the canonical state lives at the project root (or via a `.gsd`
|
|
875
|
+
// symlink into the external state dir).
|
|
876
|
+
if (opts?.projectRootForReads) {
|
|
877
|
+
basePath = opts.projectRootForReads;
|
|
878
|
+
}
|
|
879
|
+
|
|
842
880
|
const diskIds = findMilestoneIds(basePath);
|
|
843
881
|
const customOrder = loadQueueOrder(basePath);
|
|
844
882
|
const milestoneIds = sortByQueueOrder(diskIds, customOrder);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// gsd-2 + Phase C deletion regression: createAutoWorktree no longer copies .gsd/
|
|
2
|
+
//
|
|
3
|
+
// Verifies that createAutoWorktree on a project with a real (non-symlinked)
|
|
4
|
+
// .gsd/ does NOT populate .gsd/milestones/ inside the worktree. Pre-Phase-C,
|
|
5
|
+
// copyPlanningArtifacts would mirror the project-root .gsd/ into the
|
|
6
|
+
// worktree-local .gsd/. Phase C deleted that helper because writers in
|
|
7
|
+
// auto-mode now route through s.canonicalProjectRoot, so the worktree never
|
|
8
|
+
// needs a parallel .gsd/ projection.
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, realpathSync, rmSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
import { createAutoWorktree, teardownAutoWorktree } from "../auto-worktree.ts";
|
|
18
|
+
|
|
19
|
+
function git(args: string[], cwd: string): void {
|
|
20
|
+
execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("createAutoWorktree does NOT copy project-root .gsd/milestones into the worktree", (t) => {
|
|
24
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-no-copy-")));
|
|
25
|
+
|
|
26
|
+
// Initialize a real git repo with a real .gsd/ directory containing some
|
|
27
|
+
// planning artifacts that the deleted copyPlanningArtifacts would have
|
|
28
|
+
// mirrored.
|
|
29
|
+
git(["init", "-b", "main"], base);
|
|
30
|
+
git(["config", "user.name", "Pi Test"], base);
|
|
31
|
+
git(["config", "user.email", "pi@example.com"], base);
|
|
32
|
+
writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
|
|
33
|
+
git(["add", "README.md"], base);
|
|
34
|
+
git(["commit", "-m", "chore: init"], base);
|
|
35
|
+
|
|
36
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
|
|
39
|
+
"# M001 Context\n",
|
|
40
|
+
"utf-8",
|
|
41
|
+
);
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
|
44
|
+
"# M001 Roadmap\n",
|
|
45
|
+
"utf-8",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const wtPath = createAutoWorktree(base, "M001");
|
|
49
|
+
t.after(() => {
|
|
50
|
+
try { teardownAutoWorktree(base, "M001"); } catch { /* noop */ }
|
|
51
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Phase C invariant: the worktree's .gsd/milestones/M001 must NOT exist
|
|
55
|
+
// (no copyPlanningArtifacts), and the project-root version must still be
|
|
56
|
+
// intact (it was the source, not destination).
|
|
57
|
+
assert.equal(
|
|
58
|
+
existsSync(join(wtPath, ".gsd", "milestones", "M001", "M001-CONTEXT.md")),
|
|
59
|
+
false,
|
|
60
|
+
"worktree should NOT have a copy of M001-CONTEXT.md (copyPlanningArtifacts deleted)",
|
|
61
|
+
);
|
|
62
|
+
assert.equal(
|
|
63
|
+
existsSync(join(wtPath, ".gsd", "milestones", "M001", "M001-ROADMAP.md")),
|
|
64
|
+
false,
|
|
65
|
+
"worktree should NOT have a copy of M001-ROADMAP.md",
|
|
66
|
+
);
|
|
67
|
+
assert.equal(
|
|
68
|
+
existsSync(join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md")),
|
|
69
|
+
true,
|
|
70
|
+
"project-root .gsd/ retains the canonical CONTEXT.md",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// gsd-2 + Symlinked .gsd worktree-loop reproduction (Phase A pt 2 follow-up to PR #5236)
|
|
2
|
+
//
|
|
3
|
+
// Regression coverage for the auto-mode loop bug observed on projects whose
|
|
4
|
+
// .gsd/ is a symlink into ~/.gsd/projects/<hash>/ (the external-state layout).
|
|
5
|
+
//
|
|
6
|
+
// Two assertions:
|
|
7
|
+
// 1. deriveState's cache key is the canonical project root when callers
|
|
8
|
+
// opt into projectRootForReads — so two derive calls that should refer
|
|
9
|
+
// to the same canonical state share a single cache entry, regardless of
|
|
10
|
+
// whether the caller passed the worktree path or the project-root path.
|
|
11
|
+
// 2. _deriveStateImpl's projectRootForReads option routes legacy markdown
|
|
12
|
+
// reads through the canonical project root, finding files that live in
|
|
13
|
+
// the symlink target rather than the worktree-local empty .gsd/.
|
|
14
|
+
//
|
|
15
|
+
// Per project rule #11: regression test using node:test + node:assert/strict,
|
|
16
|
+
// no source-grep assertions. The first test would fail on main without the
|
|
17
|
+
// cache-key fix in state.ts (lookup vs write keys would diverge across
|
|
18
|
+
// path-form alternation, producing cache misses). The second test would
|
|
19
|
+
// fail on main because _deriveStateImpl doesn't accept the option at all.
|
|
20
|
+
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync, symlinkSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
deriveState,
|
|
29
|
+
_deriveStateImpl,
|
|
30
|
+
invalidateStateCache,
|
|
31
|
+
type DeriveStateOptions,
|
|
32
|
+
} from "../state.ts";
|
|
33
|
+
import {
|
|
34
|
+
openDatabase,
|
|
35
|
+
closeDatabase,
|
|
36
|
+
insertMilestone,
|
|
37
|
+
insertSlice,
|
|
38
|
+
insertTask,
|
|
39
|
+
} from "../gsd-db.ts";
|
|
40
|
+
|
|
41
|
+
// ─── Fixture helpers ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface SymlinkedFixture {
|
|
44
|
+
/** Project root containing .gsd as a symlink. */
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
/** External state dir that .gsd points at (acts as the canonical .gsd/). */
|
|
47
|
+
externalState: string;
|
|
48
|
+
/** Worktree path under the external state's worktrees/ dir. */
|
|
49
|
+
worktreePath: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeSymlinkedFixture(prefix: string): SymlinkedFixture {
|
|
53
|
+
// Use realpathSync on tmpdir so that subsequent realpath comparisons are stable
|
|
54
|
+
// — macOS /var symlinks to /private/var, which would otherwise pollute the
|
|
55
|
+
// canonical-root assertions below.
|
|
56
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), `gsd-${prefix}-`)));
|
|
57
|
+
const projectRoot = join(root, "project");
|
|
58
|
+
const externalState = join(root, "external-state", "projects", "abc123");
|
|
59
|
+
|
|
60
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
61
|
+
mkdirSync(externalState, { recursive: true });
|
|
62
|
+
|
|
63
|
+
// .gsd → externalState (the layout that triggered the original bug)
|
|
64
|
+
symlinkSync(externalState, join(projectRoot, ".gsd"), "junction");
|
|
65
|
+
|
|
66
|
+
// Worktree path lives under the external state's worktrees/ dir, mirroring
|
|
67
|
+
// the canonicalProjectRoot resolution that resolveGsdPathContract performs
|
|
68
|
+
// for the external-state layout.
|
|
69
|
+
const worktreePath = join(externalState, "worktrees", "M001");
|
|
70
|
+
mkdirSync(worktreePath, { recursive: true });
|
|
71
|
+
|
|
72
|
+
return { projectRoot, externalState, worktreePath };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cleanupFixture(fx: SymlinkedFixture): void {
|
|
76
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
77
|
+
// The mkdtemp root is two levels above projectRoot.
|
|
78
|
+
try {
|
|
79
|
+
const root = join(fx.projectRoot, "..");
|
|
80
|
+
rmSync(root, { recursive: true, force: true });
|
|
81
|
+
} catch { /* noop */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
// Test 1: cache-key invariance under projectRootForReads
|
|
86
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
87
|
+
|
|
88
|
+
test("deriveState: cache key is canonical when projectRootForReads is supplied", async (t) => {
|
|
89
|
+
const fx = makeSymlinkedFixture("symlink-cache");
|
|
90
|
+
t.after(() => cleanupFixture(fx));
|
|
91
|
+
|
|
92
|
+
// Open the DB at the canonical .gsd location (externalState).
|
|
93
|
+
openDatabase(join(fx.externalState, "gsd.db"));
|
|
94
|
+
insertMilestone({ id: "M001", title: "Symlinked", status: "active" });
|
|
95
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Slice" });
|
|
96
|
+
// No tasks → DB-derived state is "planning".
|
|
97
|
+
|
|
98
|
+
invalidateStateCache();
|
|
99
|
+
|
|
100
|
+
const optsCanonical: DeriveStateOptions = { projectRootForReads: fx.projectRoot };
|
|
101
|
+
|
|
102
|
+
// First call: seed the cache through the worktree-path form.
|
|
103
|
+
const stateA = await deriveState(fx.worktreePath, optsCanonical);
|
|
104
|
+
assert.equal(stateA.activeMilestone?.id, "M001");
|
|
105
|
+
assert.equal(stateA.activeSlice?.id, "S01");
|
|
106
|
+
assert.equal(stateA.phase, "planning");
|
|
107
|
+
|
|
108
|
+
// Second call: canonical project-root form must hit the same cache entry.
|
|
109
|
+
const stateB = await deriveState(fx.projectRoot);
|
|
110
|
+
assert.equal(stateB, stateA, "second call with same canonical key must return the cached object");
|
|
111
|
+
|
|
112
|
+
// Third call: worktree-path form with projectRootForReads must also hit the
|
|
113
|
+
// same cache entry, proving the cache key is symmetric across both call
|
|
114
|
+
// orders.
|
|
115
|
+
const stateC = await deriveState(fx.worktreePath, optsCanonical);
|
|
116
|
+
assert.equal(stateC, stateA, "third call with worktree path plus canonical reads must hit the same cache entry");
|
|
117
|
+
|
|
118
|
+
// Mutation invalidates: insert a task, clear cache, re-derive — must
|
|
119
|
+
// observe the new state via the canonical key path.
|
|
120
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Task", status: "active" });
|
|
121
|
+
invalidateStateCache();
|
|
122
|
+
|
|
123
|
+
const stateD = await deriveState(fx.worktreePath, optsCanonical);
|
|
124
|
+
assert.notEqual(stateD, stateA, "post-mutation derive must re-compute, not reuse the prior cached object");
|
|
125
|
+
assert.equal(stateD.activeTask?.id, "T01", "mutation must surface in the re-derived state");
|
|
126
|
+
assert.equal(stateD.phase, "executing");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
130
|
+
// Test 2: _deriveStateImpl reads from canonical root via projectRootForReads
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
132
|
+
|
|
133
|
+
test("_deriveStateImpl: projectRootForReads routes legacy markdown reads to the canonical .gsd/", async (t) => {
|
|
134
|
+
const fx = makeSymlinkedFixture("symlink-md");
|
|
135
|
+
t.after(() => cleanupFixture(fx));
|
|
136
|
+
// No DB opened — exercise the markdown fallback.
|
|
137
|
+
|
|
138
|
+
// Seed the external state dir (the symlink target) with a roadmap so the
|
|
139
|
+
// legacy filesystem state derivation has a milestone to find.
|
|
140
|
+
const m1Dir = join(fx.externalState, "milestones", "M001");
|
|
141
|
+
mkdirSync(m1Dir, { recursive: true });
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(m1Dir, "M001-CONTEXT.md"),
|
|
144
|
+
"# M001: Symlinked legacy md test\n\nTest project.\n",
|
|
145
|
+
"utf-8",
|
|
146
|
+
);
|
|
147
|
+
writeFileSync(
|
|
148
|
+
join(m1Dir, "M001-ROADMAP.md"),
|
|
149
|
+
[
|
|
150
|
+
"# M001 Roadmap",
|
|
151
|
+
"",
|
|
152
|
+
"## Slices",
|
|
153
|
+
"",
|
|
154
|
+
"- [ ] **S01: First slice** — depends:",
|
|
155
|
+
"",
|
|
156
|
+
].join("\n"),
|
|
157
|
+
"utf-8",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
invalidateStateCache();
|
|
161
|
+
|
|
162
|
+
// Calling _deriveStateImpl with the worktree path AND projectRootForReads
|
|
163
|
+
// pointing at the project root must consult the canonical .gsd/ (via the
|
|
164
|
+
// symlink target externalState), find M001/S01, and report planning phase
|
|
165
|
+
// because no slice plan file exists yet.
|
|
166
|
+
const state = await _deriveStateImpl(fx.worktreePath, { projectRootForReads: fx.projectRoot });
|
|
167
|
+
assert.equal(state.activeMilestone?.id, "M001", "must find M001 via canonical .gsd/ reads");
|
|
168
|
+
assert.equal(state.activeSlice?.id, "S01", "must find S01 from the roadmap");
|
|
169
|
+
assert.equal(state.phase, "planning", "no slice PLAN.md yet → planning phase");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
173
|
+
// Test 3: type-safety guard for the deriveState opts overload (compile-time)
|
|
174
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
|
+
//
|
|
176
|
+
// The DeriveStateOptions parameter is typed as an object literal so accidental
|
|
177
|
+
// `deriveState(path, "string")` is a TypeScript compile error. The
|
|
178
|
+
// expect-error directive verifies that this guard is in place — if the
|
|
179
|
+
// overload were widened to `string | DeriveStateOptions`, the directive would
|
|
180
|
+
// trigger TS2578 ("Unused '@ts-expect-error' directive") at build time.
|
|
181
|
+
|
|
182
|
+
test("deriveState: opts param rejects non-object values at compile time", () => {
|
|
183
|
+
// The actual assertion is the TypeScript compile-time check below; the
|
|
184
|
+
// runtime body just confirms the test ran.
|
|
185
|
+
if (false) {
|
|
186
|
+
// @ts-expect-error — projectRootForReads must be a string
|
|
187
|
+
void deriveState("/nonexistent", { projectRootForReads: 123 });
|
|
188
|
+
}
|
|
189
|
+
assert.ok(true);
|
|
190
|
+
});
|