gsd-pi 2.28.0-dev.b23c118 → 2.28.0-dev.e19bf89

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.
Files changed (25) hide show
  1. package/dist/resources/extensions/gsd/auto-post-unit.ts +3 -8
  2. package/dist/resources/extensions/gsd/auto-start.ts +9 -24
  3. package/dist/resources/extensions/gsd/auto.ts +2 -45
  4. package/dist/resources/extensions/gsd/commands.ts +0 -19
  5. package/dist/resources/extensions/gsd/doctor-types.ts +0 -13
  6. package/dist/resources/extensions/gsd/doctor.ts +6 -2
  7. package/dist/resources/extensions/gsd/export.ts +2 -28
  8. package/dist/resources/extensions/gsd/gsd-db.ts +0 -19
  9. package/package.json +3 -3
  10. package/src/resources/extensions/gsd/auto-post-unit.ts +3 -8
  11. package/src/resources/extensions/gsd/auto-start.ts +9 -24
  12. package/src/resources/extensions/gsd/auto.ts +2 -45
  13. package/src/resources/extensions/gsd/commands.ts +0 -19
  14. package/src/resources/extensions/gsd/doctor-types.ts +0 -13
  15. package/src/resources/extensions/gsd/doctor.ts +6 -2
  16. package/src/resources/extensions/gsd/export.ts +2 -28
  17. package/src/resources/extensions/gsd/gsd-db.ts +0 -19
  18. package/dist/resources/extensions/gsd/commands-logs.ts +0 -537
  19. package/dist/resources/extensions/gsd/session-lock.ts +0 -284
  20. package/dist/resources/extensions/gsd/tests/commands-logs.test.ts +0 -241
  21. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +0 -315
  22. package/src/resources/extensions/gsd/commands-logs.ts +0 -537
  23. package/src/resources/extensions/gsd/session-lock.ts +0 -284
  24. package/src/resources/extensions/gsd/tests/commands-logs.test.ts +0 -241
  25. package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -315
@@ -1,284 +0,0 @@
1
- /**
2
- * GSD Session Lock — OS-level exclusive locking for auto-mode sessions.
3
- *
4
- * Prevents multiple GSD processes from running auto-mode concurrently on
5
- * the same project. Uses proper-lockfile for OS-level file locking (flock/
6
- * lockfile) which eliminates the TOCTOU race condition that existed with
7
- * the old advisory JSON lock approach.
8
- *
9
- * The lock file (.gsd/auto.lock) contains JSON metadata (PID, start time,
10
- * unit info) for diagnostics, but the actual exclusion is enforced by the
11
- * OS-level lock held via proper-lockfile.
12
- *
13
- * Lifecycle:
14
- * acquireSessionLock() — called at the START of bootstrapAutoSession
15
- * validateSessionLock() — called periodically during dispatch to detect takeover
16
- * releaseSessionLock() — called on clean stop/pause
17
- */
18
-
19
- import { createRequire } from "node:module";
20
- import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
21
- import { join, dirname } from "node:path";
22
- import { gsdRoot } from "./paths.js";
23
- import { atomicWriteSync } from "./atomic-write.js";
24
-
25
- const _require = createRequire(import.meta.url);
26
-
27
- // ─── Types ──────────────────────────────────────────────────────────────────
28
-
29
- export interface SessionLockData {
30
- pid: number;
31
- startedAt: string;
32
- unitType: string;
33
- unitId: string;
34
- unitStartedAt: string;
35
- completedUnits: number;
36
- sessionFile?: string;
37
- }
38
-
39
- export type SessionLockResult =
40
- | { acquired: true }
41
- | { acquired: false; reason: string; existingPid?: number };
42
-
43
- // ─── Module State ───────────────────────────────────────────────────────────
44
-
45
- /** Release function from proper-lockfile — calling it releases the OS lock. */
46
- let _releaseFunction: (() => void) | null = null;
47
-
48
- /** The path we currently hold a lock on. */
49
- let _lockedPath: string | null = null;
50
-
51
- /** Our PID at lock acquisition time. */
52
- let _lockPid: number = 0;
53
-
54
- const LOCK_FILE = "auto.lock";
55
-
56
- function lockPath(basePath: string): string {
57
- return join(gsdRoot(basePath), LOCK_FILE);
58
- }
59
-
60
- // ─── Public API ─────────────────────────────────────────────────────────────
61
-
62
- /**
63
- * Attempt to acquire an exclusive session lock for the given project.
64
- *
65
- * This uses proper-lockfile for OS-level file locking. If another process
66
- * already holds the lock, this returns { acquired: false } with details.
67
- *
68
- * The lock file also contains JSON metadata about the session for
69
- * diagnostic purposes (PID, unit info, etc.).
70
- */
71
- export function acquireSessionLock(basePath: string): SessionLockResult {
72
- const lp = lockPath(basePath);
73
-
74
- // Ensure the directory exists
75
- mkdirSync(dirname(lp), { recursive: true });
76
-
77
- // Write our lock data first (the content is informational; the OS lock is the real guard)
78
- const lockData: SessionLockData = {
79
- pid: process.pid,
80
- startedAt: new Date().toISOString(),
81
- unitType: "starting",
82
- unitId: "bootstrap",
83
- unitStartedAt: new Date().toISOString(),
84
- completedUnits: 0,
85
- };
86
-
87
- let lockfile: typeof import("proper-lockfile");
88
- try {
89
- lockfile = _require("proper-lockfile") as typeof import("proper-lockfile");
90
- } catch {
91
- // proper-lockfile not available — fall back to PID-based check
92
- return acquireFallbackLock(basePath, lp, lockData);
93
- }
94
-
95
- try {
96
- // Try to acquire an exclusive OS-level lock on the lock file.
97
- // We lock the directory (gsdRoot) since proper-lockfile works best
98
- // on directories, and the lock file itself may not exist yet.
99
- const gsdDir = gsdRoot(basePath);
100
- mkdirSync(gsdDir, { recursive: true });
101
-
102
- const release = lockfile.lockSync(gsdDir, {
103
- realpath: false,
104
- stale: 300_000, // 5 minutes — consider lock stale if holder hasn't updated
105
- update: 10_000, // Update lock mtime every 10s to prove liveness
106
- });
107
-
108
- _releaseFunction = release;
109
- _lockedPath = basePath;
110
- _lockPid = process.pid;
111
-
112
- // Write the informational lock data
113
- atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
114
-
115
- return { acquired: true };
116
- } catch (err) {
117
- // Lock is held by another process
118
- const existingData = readExistingLockData(lp);
119
- const existingPid = existingData?.pid;
120
- const reason = existingPid
121
- ? `Another auto-mode session (PID ${existingPid}) is already running on this project.`
122
- : `Another auto-mode session is already running on this project.`;
123
-
124
- return { acquired: false, reason, existingPid };
125
- }
126
- }
127
-
128
- /**
129
- * Fallback lock acquisition when proper-lockfile is not available.
130
- * Uses PID-based liveness checking (the old approach, but with the lock
131
- * written BEFORE initialization rather than after).
132
- */
133
- function acquireFallbackLock(
134
- basePath: string,
135
- lp: string,
136
- lockData: SessionLockData,
137
- ): SessionLockResult {
138
- // Check if an existing lock is held by a live process
139
- const existing = readExistingLockData(lp);
140
- if (existing && existing.pid !== process.pid) {
141
- if (isPidAlive(existing.pid)) {
142
- return {
143
- acquired: false,
144
- reason: `Another auto-mode session (PID ${existing.pid}) is already running on this project.`,
145
- existingPid: existing.pid,
146
- };
147
- }
148
- // Stale lock from dead process — we can take over
149
- }
150
-
151
- // Write our lock data
152
- atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
153
- _lockedPath = basePath;
154
- _lockPid = process.pid;
155
-
156
- return { acquired: true };
157
- }
158
-
159
- /**
160
- * Update the lock file metadata (called on each unit dispatch).
161
- * Does NOT re-acquire the OS lock — just updates the JSON content.
162
- */
163
- export function updateSessionLock(
164
- basePath: string,
165
- unitType: string,
166
- unitId: string,
167
- completedUnits: number,
168
- sessionFile?: string,
169
- ): void {
170
- if (_lockedPath !== basePath && _lockedPath !== null) return;
171
-
172
- const lp = lockPath(basePath);
173
- try {
174
- const data: SessionLockData = {
175
- pid: process.pid,
176
- startedAt: new Date().toISOString(),
177
- unitType,
178
- unitId,
179
- unitStartedAt: new Date().toISOString(),
180
- completedUnits,
181
- sessionFile,
182
- };
183
- atomicWriteSync(lp, JSON.stringify(data, null, 2));
184
- } catch {
185
- // Non-fatal: lock update failure
186
- }
187
- }
188
-
189
- /**
190
- * Validate that we still own the session lock.
191
- *
192
- * Returns true if we still hold the lock, false if another process
193
- * has taken over (indicating we should gracefully stop).
194
- *
195
- * This is called periodically during the dispatch loop.
196
- */
197
- export function validateSessionLock(basePath: string): boolean {
198
- // If we have an OS-level lock, we're still the owner
199
- if (_releaseFunction && _lockedPath === basePath) {
200
- return true;
201
- }
202
-
203
- // Fallback: check the lock file PID
204
- const lp = lockPath(basePath);
205
- const existing = readExistingLockData(lp);
206
- if (!existing) {
207
- // Lock file was deleted — we lost ownership
208
- return false;
209
- }
210
-
211
- return existing.pid === process.pid;
212
- }
213
-
214
- /**
215
- * Release the session lock. Called on clean stop/pause.
216
- */
217
- export function releaseSessionLock(basePath: string): void {
218
- // Release the OS-level lock
219
- if (_releaseFunction) {
220
- try {
221
- _releaseFunction();
222
- } catch {
223
- // Lock may already be released
224
- }
225
- _releaseFunction = null;
226
- }
227
-
228
- // Remove the lock file
229
- const lp = lockPath(basePath);
230
- try {
231
- if (existsSync(lp)) unlinkSync(lp);
232
- } catch {
233
- // Non-fatal
234
- }
235
-
236
- _lockedPath = null;
237
- _lockPid = 0;
238
- }
239
-
240
- /**
241
- * Check if a session lock exists and return its data (for crash recovery).
242
- * Does NOT acquire the lock.
243
- */
244
- export function readSessionLockData(basePath: string): SessionLockData | null {
245
- return readExistingLockData(lockPath(basePath));
246
- }
247
-
248
- /**
249
- * Check if the process that wrote the lock is still alive.
250
- */
251
- export function isSessionLockProcessAlive(data: SessionLockData): boolean {
252
- return isPidAlive(data.pid);
253
- }
254
-
255
- /**
256
- * Returns true if we currently hold a session lock for the given path.
257
- */
258
- export function isSessionLockHeld(basePath: string): boolean {
259
- return _lockedPath === basePath && _lockPid === process.pid;
260
- }
261
-
262
- // ─── Internal Helpers ───────────────────────────────────────────────────────
263
-
264
- function readExistingLockData(lp: string): SessionLockData | null {
265
- try {
266
- if (!existsSync(lp)) return null;
267
- const raw = readFileSync(lp, "utf-8");
268
- return JSON.parse(raw) as SessionLockData;
269
- } catch {
270
- return null;
271
- }
272
- }
273
-
274
- function isPidAlive(pid: number): boolean {
275
- if (!Number.isInteger(pid) || pid <= 0) return false;
276
- if (pid === process.pid) return false;
277
- try {
278
- process.kill(pid, 0);
279
- return true;
280
- } catch (err) {
281
- if ((err as NodeJS.ErrnoException).code === "EPERM") return true;
282
- return false;
283
- }
284
- }
@@ -1,241 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, utimesSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
-
7
- import { handleLogs } from "../commands-logs.ts";
8
-
9
- // ─── Test helpers ───────────────────────────────────────────────────────────
10
-
11
- function createTestDir(): string {
12
- const dir = mkdtempSync(join(tmpdir(), "gsd-logs-test-"));
13
- mkdirSync(join(dir, ".gsd", "activity"), { recursive: true });
14
- mkdirSync(join(dir, ".gsd", "debug"), { recursive: true });
15
- return dir;
16
- }
17
-
18
- function createMockCtx(): { notifications: Array<{ msg: string; level: string }>; ui: any } {
19
- const notifications: Array<{ msg: string; level: string }> = [];
20
- return {
21
- notifications,
22
- ui: {
23
- notify(msg: string, level: string) { notifications.push({ msg, level }); },
24
- setStatus() {},
25
- setWidget() {},
26
- setFooter() {},
27
- },
28
- };
29
- }
30
-
31
- function writeActivityLog(dir: string, seq: number, unitType: string, unitId: string, entries: Record<string, unknown>[]): void {
32
- const safeId = unitId.replace(/\//g, "-");
33
- const filename = `${String(seq).padStart(3, "0")}-${unitType}-${safeId}.jsonl`;
34
- const content = entries.map(e => JSON.stringify(e)).join("\n") + "\n";
35
- writeFileSync(join(dir, ".gsd", "activity", filename), content);
36
- }
37
-
38
- function writeDebugLog(dir: string, name: string, entries: Record<string, unknown>[]): void {
39
- const content = entries.map(e => JSON.stringify(e)).join("\n") + "\n";
40
- writeFileSync(join(dir, ".gsd", "debug", name), content);
41
- }
42
-
43
- // ─── Tests ──────────────────────────────────────────────────────────────────
44
-
45
- test("logs shows empty state message when no logs exist", async () => {
46
- const dir = createTestDir();
47
- const ctx = createMockCtx();
48
- const origCwd = process.cwd();
49
- process.chdir(dir);
50
- try {
51
- await handleLogs("", ctx as any);
52
- assert.equal(ctx.notifications.length, 1);
53
- assert.ok(ctx.notifications[0].msg.includes("No logs found"));
54
- } finally {
55
- process.chdir(origCwd);
56
- rmSync(dir, { recursive: true, force: true });
57
- }
58
- });
59
-
60
- test("logs lists activity logs", async () => {
61
- const dir = createTestDir();
62
- const ctx = createMockCtx();
63
- const origCwd = process.cwd();
64
- process.chdir(dir);
65
-
66
- writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
67
- { type: "toolCall", name: "bash", arguments: { command: "npm test" } },
68
- { role: "toolResult", toolCallId: "1", toolName: "bash", isError: false },
69
- ]);
70
- writeActivityLog(dir, 2, "complete-slice", "M001/S01", [
71
- { role: "assistant", content: "Completing slice S01" },
72
- ]);
73
-
74
- try {
75
- await handleLogs("", ctx as any);
76
- assert.equal(ctx.notifications.length, 1);
77
- const msg = ctx.notifications[0].msg;
78
- assert.ok(msg.includes("Activity Logs"), "should show activity logs header");
79
- assert.ok(msg.includes("execute-task"), "should show unit type");
80
- assert.ok(msg.includes("complete-slice"), "should show second log");
81
- assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint");
82
- } finally {
83
- process.chdir(origCwd);
84
- rmSync(dir, { recursive: true, force: true });
85
- }
86
- });
87
-
88
- test("logs <N> shows activity log details", async () => {
89
- const dir = createTestDir();
90
- const ctx = createMockCtx();
91
- const origCwd = process.cwd();
92
- process.chdir(dir);
93
-
94
- writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
95
- { type: "toolCall", name: "bash", arguments: { command: "npm test" } },
96
- { type: "toolCall", name: "write", arguments: { file_path: "/tmp/test.ts" } },
97
- { role: "toolResult", toolCallId: "1", toolName: "bash", isError: false },
98
- { role: "toolResult", toolCallId: "2", toolName: "write", isError: true },
99
- { role: "assistant", content: "I ran the tests and wrote a file" },
100
- ]);
101
-
102
- try {
103
- await handleLogs("1", ctx as any);
104
- assert.equal(ctx.notifications.length, 1);
105
- const msg = ctx.notifications[0].msg;
106
- assert.ok(msg.includes("Activity Log #1"), "should show log number");
107
- assert.ok(msg.includes("execute-task"), "should show unit type");
108
- assert.ok(msg.includes("Tool calls: 2"), "should count tool calls");
109
- assert.ok(msg.includes("Errors: 1"), "should count errors");
110
- assert.ok(msg.includes("/tmp/test.ts"), "should show files written");
111
- assert.ok(msg.includes("npm test"), "should show commands run");
112
- } finally {
113
- process.chdir(origCwd);
114
- rmSync(dir, { recursive: true, force: true });
115
- }
116
- });
117
-
118
- test("logs <N> shows not found for invalid seq", async () => {
119
- const dir = createTestDir();
120
- const ctx = createMockCtx();
121
- const origCwd = process.cwd();
122
- process.chdir(dir);
123
-
124
- try {
125
- await handleLogs("999", ctx as any);
126
- assert.equal(ctx.notifications.length, 1);
127
- assert.ok(ctx.notifications[0].msg.includes("not found"));
128
- assert.equal(ctx.notifications[0].level, "warning");
129
- } finally {
130
- process.chdir(origCwd);
131
- rmSync(dir, { recursive: true, force: true });
132
- }
133
- });
134
-
135
- test("logs debug lists debug logs", async () => {
136
- const dir = createTestDir();
137
- const ctx = createMockCtx();
138
- const origCwd = process.cwd();
139
- process.chdir(dir);
140
-
141
- writeDebugLog(dir, "debug-2026-03-18T10-30-00.log", [
142
- { ts: "2026-03-18T10:30:00Z", event: "debug-start", platform: "darwin" },
143
- { ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
144
- ]);
145
-
146
- try {
147
- await handleLogs("debug", ctx as any);
148
- assert.equal(ctx.notifications.length, 1);
149
- const msg = ctx.notifications[0].msg;
150
- assert.ok(msg.includes("Debug Logs"), "should show debug logs header");
151
- assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename");
152
- } finally {
153
- process.chdir(origCwd);
154
- rmSync(dir, { recursive: true, force: true });
155
- }
156
- });
157
-
158
- test("logs debug <N> shows debug log summary", async () => {
159
- const dir = createTestDir();
160
- const ctx = createMockCtx();
161
- const origCwd = process.cwd();
162
- process.chdir(dir);
163
-
164
- writeDebugLog(dir, "debug-2026-03-18T10-30-00.log", [
165
- { ts: "2026-03-18T10:30:00Z", event: "debug-start", platform: "darwin" },
166
- { ts: "2026-03-18T10:30:05Z", event: "dispatch-error", error: "missing plan" },
167
- { ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 },
168
- ]);
169
-
170
- try {
171
- await handleLogs("debug 1", ctx as any);
172
- assert.equal(ctx.notifications.length, 1);
173
- const msg = ctx.notifications[0].msg;
174
- assert.ok(msg.includes("Debug Log:"), "should show debug log header");
175
- assert.ok(msg.includes("Events: 3"), "should count events");
176
- assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count");
177
- assert.ok(msg.includes("dispatch-error"), "should show errors");
178
- } finally {
179
- process.chdir(origCwd);
180
- rmSync(dir, { recursive: true, force: true });
181
- }
182
- });
183
-
184
- test("logs tail shows recent activity summaries", async () => {
185
- const dir = createTestDir();
186
- const ctx = createMockCtx();
187
- const origCwd = process.cwd();
188
- process.chdir(dir);
189
-
190
- writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [
191
- { type: "toolCall", name: "bash", arguments: { command: "npm test" } },
192
- ]);
193
- writeActivityLog(dir, 2, "execute-task", "M001/S01/T02", [
194
- { type: "toolCall", name: "bash", arguments: { command: "npm build" } },
195
- { role: "toolResult", toolCallId: "1", toolName: "bash", isError: true },
196
- ]);
197
-
198
- try {
199
- await handleLogs("tail 2", ctx as any);
200
- assert.equal(ctx.notifications.length, 1);
201
- const msg = ctx.notifications[0].msg;
202
- assert.ok(msg.includes("Last 2 activity log(s)"), "should show count");
203
- assert.ok(msg.includes("#1"), "should show first log");
204
- assert.ok(msg.includes("#2"), "should show second log");
205
- } finally {
206
- process.chdir(origCwd);
207
- rmSync(dir, { recursive: true, force: true });
208
- }
209
- });
210
-
211
- test("logs clear removes old logs", async () => {
212
- const dir = createTestDir();
213
- const ctx = createMockCtx();
214
- const origCwd = process.cwd();
215
- process.chdir(dir);
216
-
217
- // Create an old activity log (modify mtime to 10 days ago)
218
- writeActivityLog(dir, 1, "execute-task", "M001/S01/T01", [{ type: "toolCall" }]);
219
- const oldFile = join(dir, ".gsd", "activity", "001-execute-task-M001-S01-T01.jsonl");
220
- const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
221
- utimesSync(oldFile, oldTime, oldTime);
222
-
223
- // Create 6 recent activity logs so the old one is outside the "keep 5" window
224
- for (let i = 2; i <= 7; i++) {
225
- writeActivityLog(dir, i, "execute-task", `M001/S01/T0${i}`, [{ type: "toolCall" }]);
226
- }
227
-
228
- try {
229
- await handleLogs("clear", ctx as any);
230
- assert.equal(ctx.notifications.length, 1);
231
- // Old log should be removed, recent ones kept
232
- assert.ok(!existsSync(oldFile), "old log should be removed");
233
- assert.ok(
234
- existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")),
235
- "most recent log should be kept",
236
- );
237
- } finally {
238
- process.chdir(origCwd);
239
- rmSync(dir, { recursive: true, force: true });
240
- }
241
- });