trekoon 0.4.1 → 0.4.3

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 (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. package/src/sync/service.ts +679 -156
@@ -2,12 +2,12 @@ import { randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, extname, resolve } from "node:path";
4
4
 
5
+ import { createBoardEventBus, type BoardEventBus } from "./event-bus";
5
6
  import { createBoardApiHandler } from "./routes";
6
- import { buildBoardSnapshot } from "./snapshot";
7
+ import { startWalWatcher, type WalWatcher } from "./wal-watcher";
7
8
 
8
9
  import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
9
10
  import { resolveStoragePaths } from "../storage/path";
10
- import { TrackerDomain } from "../domain/tracker-domain";
11
11
 
12
12
  const CONTENT_TYPES: Record<string, string> = {
13
13
  ".css": "text/css; charset=utf-8",
@@ -90,7 +90,43 @@ function isUnavailablePortError(error: unknown): boolean {
90
90
  }
91
91
 
92
92
  function buildBoardSessionCookie(token: string): string {
93
- return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
93
+ return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly`;
94
+ }
95
+
96
+ function readBoardSessionCookie(request: Request): string | null {
97
+ const rawCookie = request.headers.get("cookie");
98
+ if (!rawCookie) {
99
+ return null;
100
+ }
101
+
102
+ for (const part of rawCookie.split(";")) {
103
+ const [name, ...valueParts] = part.split("=");
104
+ if (name?.trim() !== "trekoon_board_session") {
105
+ continue;
106
+ }
107
+
108
+ const value = valueParts.join("=").trim();
109
+ return value.length > 0 ? decodeURIComponent(value) : null;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ function isAuthenticatedBoardRequest(request: Request, url: URL, token: string): boolean {
116
+ const queryToken = url.searchParams.get("token");
117
+ if (queryToken && queryToken === token) {
118
+ return true;
119
+ }
120
+
121
+ const cookieToken = readBoardSessionCookie(request);
122
+ return cookieToken !== null && cookieToken === token;
123
+ }
124
+
125
+ function buildTokenStrippedLocation(url: URL): string {
126
+ const redirectUrl = new URL(url);
127
+ redirectUrl.searchParams.delete("token");
128
+ const pathname = `/${redirectUrl.pathname.replace(/^\/+/u, "")}`;
129
+ return `${pathname}${redirectUrl.search}${redirectUrl.hash}`;
94
130
  }
95
131
 
96
132
  function serializeInlineJson(value: unknown): string {
@@ -102,11 +138,12 @@ function serializeInlineJson(value: unknown): string {
102
138
  .replace(/\u2029/g, "\\u2029");
103
139
  }
104
140
 
105
- function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
106
- const domain = new TrackerDomain(database.db);
141
+ function buildBoardBootstrapPayload(_database: TrekoonDatabase, token: string): string {
142
+ // Only the auth token is inlined; the snapshot is fetched client-side via
143
+ // /api/snapshot to keep index.html small and avoid disclosing data even
144
+ // briefly through the HTML response cache.
107
145
  return serializeInlineJson({
108
146
  token,
109
- snapshot: buildBoardSnapshot(domain),
110
147
  });
111
148
  }
112
149
 
@@ -126,28 +163,57 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
126
163
  const paths = resolveStoragePaths(cwd);
127
164
  const boardRoot: string = paths.boardDir;
128
165
  const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
129
- const token: string = options.token ?? randomBytes(18).toString("hex");
166
+ const token: string = options.token ?? randomBytes(32).toString("hex");
167
+ const eventBus: BoardEventBus = createBoardEventBus();
168
+ const walWatcher: WalWatcher = startWalWatcher({
169
+ db: database.db,
170
+ databaseFile: database.paths.databaseFile,
171
+ eventBus,
172
+ });
130
173
  const apiHandler = createBoardApiHandler({
131
174
  db: database.db,
132
175
  cwd,
133
176
  token,
177
+ eventBus,
134
178
  });
135
179
 
136
180
  const serveBoard = (port: number) =>
137
181
  Bun.serve({
138
182
  hostname: "127.0.0.1",
139
183
  port,
184
+ idleTimeout: 0,
140
185
  fetch(request: Request): Promise<Response> | Response {
141
186
  const url = new URL(request.url);
142
187
  if (url.pathname.startsWith("/api/")) {
143
188
  return apiHandler(request);
144
189
  }
145
190
 
191
+ const isAuthenticated = isAuthenticatedBoardRequest(request, url, token);
146
192
  const responseHeaders: Record<string, string> = {
147
193
  "cache-control": "no-store",
148
194
  };
149
- if ((url.searchParams.get("token") ?? "") === token) {
195
+ const queryTokenMatched = (url.searchParams.get("token") ?? "") === token;
196
+ if (isAuthenticated && queryTokenMatched) {
150
197
  responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
198
+
199
+ // Token revoke on rotation (P1 finding 8): once we've installed the
200
+ // session cookie, redirect to the same URL with the `token=` query
201
+ // param stripped. This keeps the browser's address bar, history,
202
+ // and Referer headers free of the secret on the very first
203
+ // navigation, severing the leakage surface that an open URL bar
204
+ // would otherwise expose. The cookie carries auth from here on.
205
+ // Preserve a single-slash relative location so the redirect works
206
+ // regardless of how the client reached us, without creating a
207
+ // scheme-relative `//host` Location for odd incoming paths.
208
+ const location = buildTokenStrippedLocation(url);
209
+ return new Response(null, {
210
+ status: 302,
211
+ headers: {
212
+ ...responseHeaders,
213
+ "referrer-policy": "no-referrer",
214
+ location: location.length > 0 ? location : "/",
215
+ },
216
+ });
151
217
  }
152
218
 
153
219
  const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
@@ -157,9 +223,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
157
223
  return new Response("Board assets are not installed", { status: 500 });
158
224
  }
159
225
 
160
- const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
226
+ const rawHtml = readFileSync(fallbackPath, "utf8");
227
+ const html = isAuthenticated
228
+ ? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
229
+ : rawHtml;
161
230
 
162
231
  return new Response(html, {
232
+ status: isAuthenticated ? 200 : 401,
163
233
  headers: {
164
234
  ...responseHeaders,
165
235
  "content-type": "text/html; charset=utf-8",
@@ -168,8 +238,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
168
238
  }
169
239
 
170
240
  if (assetPath.endsWith("/index.html")) {
171
- const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
241
+ const rawHtml = readFileSync(assetPath, "utf8");
242
+ const html = isAuthenticated
243
+ ? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
244
+ : rawHtml;
172
245
  return new Response(html, {
246
+ status: isAuthenticated ? 200 : 401,
173
247
  headers: {
174
248
  ...responseHeaders,
175
249
  "content-type": "text/html; charset=utf-8",
@@ -231,6 +305,8 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
231
305
  hostname: "127.0.0.1",
232
306
  port,
233
307
  stop(): void {
308
+ walWatcher.close();
309
+ eventBus.close();
234
310
  server.stop(true);
235
311
  database.close();
236
312
  },
@@ -34,6 +34,7 @@ interface BoardSnapshotSubtask {
34
34
  readonly owner: string | null;
35
35
  readonly createdAt: number;
36
36
  readonly updatedAt: number;
37
+ readonly version: number;
37
38
  readonly blockedBy: readonly string[];
38
39
  readonly blocks: readonly string[];
39
40
  readonly dependencyIds: readonly string[];
@@ -51,6 +52,7 @@ interface BoardSnapshotTask {
51
52
  readonly owner: string | null;
52
53
  readonly createdAt: number;
53
54
  readonly updatedAt: number;
55
+ readonly version: number;
54
56
  readonly blockedBy: readonly string[];
55
57
  readonly blocks: readonly string[];
56
58
  readonly dependencyIds: readonly string[];
@@ -66,6 +68,7 @@ interface BoardSnapshotEpic {
66
68
  readonly status: string;
67
69
  readonly createdAt: number;
68
70
  readonly updatedAt: number;
71
+ readonly version: number;
69
72
  readonly taskIds: readonly string[];
70
73
  readonly counts: FlatCounts;
71
74
  readonly searchText: string;
@@ -169,6 +172,7 @@ function mapSnapshotSubtask(subtask: SubtaskRecord, indexes: ReturnType<typeof b
169
172
  owner: subtask.owner ?? null,
170
173
  createdAt: subtask.createdAt,
171
174
  updatedAt: subtask.updatedAt,
175
+ version: subtask.version,
172
176
  blockedBy: indexes.blockedByIdsBySource.get(subtask.id) ?? [],
173
177
  blocks: indexes.blocksByTarget.get(subtask.id) ?? [],
174
178
  dependencyIds: indexes.dependencyIdsBySource.get(subtask.id) ?? [],
@@ -188,6 +192,7 @@ function mapSnapshotTask(task: TaskRecord, taskSubtasks: readonly BoardSnapshotS
188
192
  owner: task.owner ?? null,
189
193
  createdAt: task.createdAt,
190
194
  updatedAt: task.updatedAt,
195
+ version: task.version,
191
196
  blockedBy: indexes.blockedByIdsBySource.get(task.id) ?? [],
192
197
  blocks: indexes.blocksByTarget.get(task.id) ?? [],
193
198
  dependencyIds: indexes.dependencyIdsBySource.get(task.id) ?? [],
@@ -205,6 +210,7 @@ function mapSnapshotEpic(epic: EpicRecord, epicTasks: readonly BoardSnapshotTask
205
210
  status: epic.status,
206
211
  createdAt: epic.createdAt,
207
212
  updatedAt: epic.updatedAt,
213
+ version: epic.version,
208
214
  taskIds: epicTasks.map((task) => task.id),
209
215
  counts: deriveFlatCounts(epicTasks),
210
216
  searchText: [epic.title, epic.description, ...epicTasks.map((task) => task.searchText)].join(" ").toLowerCase(),
@@ -0,0 +1,313 @@
1
+ /**
2
+ * WAL watcher.
3
+ *
4
+ * Watches the SQLite WAL sidecar (`<dbfile>-wal`) for mtime changes so
5
+ * mutations issued by another process (e.g. `trekoon task update` running in
6
+ * a different shell) are picked up and pushed to SSE subscribers.
7
+ *
8
+ * The watcher is intentionally _decoupled_ from the in-process MutationService
9
+ * event path: in-process writes already publish their own deltas via the
10
+ * route handler, so we treat WAL mtime changes as a hint that "something
11
+ * changed somewhere" and reconcile by comparing a fresh snapshot against the
12
+ * last snapshot we broadcast. Only entities that actually differ end up in
13
+ * the published delta.
14
+ */
15
+
16
+ import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
17
+ import { dirname, basename } from "node:path";
18
+
19
+ import { type Database } from "bun:sqlite";
20
+
21
+ import { TrackerDomain } from "../domain/tracker-domain";
22
+ import { type BoardEventBus } from "./event-bus";
23
+ import { buildBoardSnapshot, type BoardSnapshot } from "./snapshot";
24
+
25
+ const IN_PROCESS_WAL_SUPPRESS_MS = 500;
26
+
27
+ interface CollectionDiff {
28
+ readonly upserted: unknown[];
29
+ readonly deletedIds: string[];
30
+ }
31
+
32
+ function recordId(value: unknown): string | null {
33
+ if (!value || typeof value !== "object") {
34
+ return null;
35
+ }
36
+ const id = (value as { id?: unknown }).id;
37
+ return typeof id === "string" && id.length > 0 ? id : null;
38
+ }
39
+
40
+ /**
41
+ * Extract the (version, updatedAt) tuple used to detect content changes.
42
+ *
43
+ * `updatedAt` is bumped on every domain write; `version` is incremented in
44
+ * lockstep at the SQLite layer. Comparing the tuple lets us bail out cheaply
45
+ * when neither has moved, avoiding the JSON.stringify hot path that fires on
46
+ * every WAL tick — including ticks where only non-content shape changed
47
+ * (e.g. dependency reordering of an unrelated record produced an array
48
+ * identity change but no semantic delta for the entity in question).
49
+ */
50
+ function recordChangeKey(value: unknown): { version: number | null; updatedAt: number | null } {
51
+ if (!value || typeof value !== "object") {
52
+ return { version: null, updatedAt: null };
53
+ }
54
+ const versionRaw = (value as { version?: unknown }).version;
55
+ const updatedAtRaw = (value as { updatedAt?: unknown }).updatedAt;
56
+ return {
57
+ version: typeof versionRaw === "number" ? versionRaw : null,
58
+ updatedAt: typeof updatedAtRaw === "number" ? updatedAtRaw : null,
59
+ };
60
+ }
61
+
62
+ function changeKeyEqual(
63
+ a: { version: number | null; updatedAt: number | null },
64
+ b: { version: number | null; updatedAt: number | null },
65
+ ): boolean {
66
+ return a.version === b.version && a.updatedAt === b.updatedAt;
67
+ }
68
+
69
+ function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
70
+ const previousIndex = new Map<string, unknown>();
71
+ for (const record of previous ?? []) {
72
+ const id = recordId(record);
73
+ if (id !== null) {
74
+ previousIndex.set(id, record);
75
+ }
76
+ }
77
+
78
+ const upserted: unknown[] = [];
79
+ const seen = new Set<string>();
80
+ for (const record of current ?? []) {
81
+ const id = recordId(record);
82
+ if (id === null) {
83
+ continue;
84
+ }
85
+
86
+ seen.add(id);
87
+ const previousRecord = previousIndex.get(id);
88
+ if (!previousRecord) {
89
+ upserted.push(record);
90
+ continue;
91
+ }
92
+ // Tuple compare on (version, updatedAt) — both move in lockstep on every
93
+ // domain write. Equal tuple → no content change → skip the upsert.
94
+ if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
95
+ upserted.push(record);
96
+ }
97
+ }
98
+
99
+ const deletedIds: string[] = [];
100
+ for (const id of previousIndex.keys()) {
101
+ if (!seen.has(id)) {
102
+ deletedIds.push(id);
103
+ }
104
+ }
105
+
106
+ return { upserted, deletedIds };
107
+ }
108
+
109
+ function readMtime(path: string): number {
110
+ if (!existsSync(path)) {
111
+ return 0;
112
+ }
113
+ try {
114
+ return statSync(path).mtimeMs;
115
+ } catch {
116
+ return 0;
117
+ }
118
+ }
119
+
120
+ export interface WalWatcherOptions {
121
+ readonly db: Database;
122
+ readonly databaseFile: string;
123
+ readonly eventBus: BoardEventBus;
124
+ /**
125
+ * How long to coalesce successive mtime change events. Defaults to 150ms
126
+ * which is small enough to feel real-time and large enough to absorb the
127
+ * burst of write events that SQLite emits within a single transaction.
128
+ */
129
+ readonly debounceMs?: number;
130
+ /**
131
+ * Log every Nth reconcile failure at warn level. Defaults to 5.
132
+ */
133
+ readonly logEveryNthFailure?: number;
134
+ /**
135
+ * Optional logger override; defaults to `console.warn`. Used by tests to
136
+ * assert failure-counter behavior without polluting stderr.
137
+ */
138
+ readonly logger?: (message: string, error: unknown) => void;
139
+ /**
140
+ * Optional snapshot builder override. Defaults to {@link buildBoardSnapshot}.
141
+ * Tests inject a throwing or stubbed builder to exercise failure paths.
142
+ */
143
+ readonly buildSnapshot?: (domain: TrackerDomain) => BoardSnapshot;
144
+ }
145
+
146
+ export interface WalWatcher {
147
+ /**
148
+ * Force a reconciliation outside the normal mtime-driven path. Useful for
149
+ * tests and for kicking the watcher after a manual external change.
150
+ */
151
+ reconcile(): void;
152
+ /**
153
+ * Total number of reconcile failures since the watcher started. Exposed for
154
+ * tests and operators; the watcher itself never throws.
155
+ */
156
+ readonly failureCount: () => number;
157
+ close(): void;
158
+ }
159
+
160
+ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
161
+ const debounceMs = options.debounceMs ?? 150;
162
+ const logEveryNthFailure = Math.max(1, options.logEveryNthFailure ?? 5);
163
+ const logger = options.logger ?? ((message: string, error: unknown): void => {
164
+ // eslint-disable-next-line no-console
165
+ console.warn(message, error);
166
+ });
167
+ const walFile = `${options.databaseFile}-wal`;
168
+ const watchDir = dirname(options.databaseFile);
169
+ const dbBaseName = basename(options.databaseFile);
170
+
171
+ // Hoist TrackerDomain construction out of reconcile: build once and reuse
172
+ // across ticks. The domain is a thin wrapper over the bun:sqlite Database
173
+ // handle and holds prepared-statement caches — recreating it per tick burns
174
+ // CPU on large boards. The handle stays valid for the lifetime of the
175
+ // server, so re-binding is unnecessary.
176
+ const domain = new TrackerDomain(options.db);
177
+ const buildSnapshot = options.buildSnapshot ?? buildBoardSnapshot;
178
+
179
+ let lastSnapshot = buildSnapshot(domain);
180
+
181
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
182
+ let closed = false;
183
+ let failures = 0;
184
+ let lastSuppressedInProcessWriteAt = 0;
185
+
186
+ function reconcile(): void {
187
+ if (closed) {
188
+ return;
189
+ }
190
+ const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
191
+ if (
192
+ inProcessWriteAt > lastSuppressedInProcessWriteAt &&
193
+ Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS
194
+ ) {
195
+ lastSuppressedInProcessWriteAt = inProcessWriteAt;
196
+ return;
197
+ }
198
+
199
+ try {
200
+ const fresh = buildSnapshot(domain);
201
+
202
+ const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
203
+ const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
204
+ const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
205
+ const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
206
+
207
+ const hasChanges =
208
+ epicsDiff.upserted.length > 0 || epicsDiff.deletedIds.length > 0 ||
209
+ tasksDiff.upserted.length > 0 || tasksDiff.deletedIds.length > 0 ||
210
+ subtasksDiff.upserted.length > 0 || subtasksDiff.deletedIds.length > 0 ||
211
+ dependenciesDiff.upserted.length > 0 || dependenciesDiff.deletedIds.length > 0;
212
+
213
+ lastSnapshot = fresh;
214
+
215
+ if (!hasChanges) {
216
+ return;
217
+ }
218
+
219
+ options.eventBus.publishSnapshotDelta({
220
+ generatedAt: Date.now(),
221
+ source: "wal-watcher",
222
+ epics: epicsDiff.upserted,
223
+ tasks: tasksDiff.upserted,
224
+ subtasks: subtasksDiff.upserted,
225
+ dependencies: dependenciesDiff.upserted,
226
+ deletedEpicIds: epicsDiff.deletedIds,
227
+ deletedTaskIds: tasksDiff.deletedIds,
228
+ deletedSubtaskIds: subtasksDiff.deletedIds,
229
+ deletedDependencyIds: dependenciesDiff.deletedIds,
230
+ });
231
+ } catch (error) {
232
+ // Reconciliation must never crash the server. Errors here usually mean
233
+ // the database is mid-write or a downstream snapshot builder threw; the
234
+ // next mtime tick will retry. Log every Nth failure to keep operators
235
+ // informed without flooding stderr on persistent faults.
236
+ failures += 1;
237
+ if (failures % logEveryNthFailure === 0) {
238
+ logger(`wal-watcher: reconcile failed (${failures} total failures)`, error);
239
+ }
240
+ }
241
+ }
242
+
243
+ function scheduleReconcile(): void {
244
+ if (closed) {
245
+ return;
246
+ }
247
+ if (debounceTimer) {
248
+ clearTimeout(debounceTimer);
249
+ }
250
+ debounceTimer = setTimeout(() => {
251
+ debounceTimer = null;
252
+ reconcile();
253
+ }, debounceMs);
254
+ }
255
+
256
+ // Track WAL mtime when available, so we only react to actual changes rather
257
+ // than spurious watcher fires (e.g. atime-only updates on some filesystems).
258
+ let lastWalMtime: number = readMtime(walFile);
259
+
260
+ function maybeScheduleReconcile(): void {
261
+ const currentMtime = readMtime(walFile);
262
+ // mtime can equal 0 when the WAL was just checkpointed and removed; treat
263
+ // any change (including transitions to/from 0) as worth reconciling.
264
+ if (currentMtime !== lastWalMtime) {
265
+ lastWalMtime = currentMtime;
266
+ scheduleReconcile();
267
+ }
268
+ }
269
+
270
+ // We watch the directory rather than the WAL file directly because the WAL
271
+ // file can be unlinked (e.g. on checkpoint) which invalidates a direct
272
+ // file watch on some platforms.
273
+ let watcher: FSWatcher | null = null;
274
+ try {
275
+ watcher = watch(watchDir, (_eventType, filename) => {
276
+ if (closed) {
277
+ return;
278
+ }
279
+ if (typeof filename === "string" && filename !== `${dbBaseName}-wal` && filename !== dbBaseName) {
280
+ return;
281
+ }
282
+ maybeScheduleReconcile();
283
+ });
284
+ watcher.on("error", () => {
285
+ // Best-effort; ignore transient watcher errors.
286
+ });
287
+ } catch {
288
+ // Filesystem watch is best-effort. If it cannot be set up (e.g. read-only
289
+ // filesystem), the watcher silently degrades and only `reconcile()` calls
290
+ // will publish deltas.
291
+ watcher = null;
292
+ }
293
+
294
+ return {
295
+ reconcile,
296
+ failureCount: (): number => failures,
297
+ close(): void {
298
+ closed = true;
299
+ if (debounceTimer) {
300
+ clearTimeout(debounceTimer);
301
+ debounceTimer = null;
302
+ }
303
+ if (watcher) {
304
+ try {
305
+ watcher.close();
306
+ } catch {
307
+ // Already closed.
308
+ }
309
+ watcher = null;
310
+ }
311
+ },
312
+ };
313
+ }
@@ -7,6 +7,17 @@ import { BoardInstallError, type EnsureBoardInstalledOptions } from "../board/ty
7
7
  import { failResult, okResult } from "../io/output";
8
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
9
9
 
10
+ // Per-subcommand allow-lists. Mirrors the pattern used in epic/task/subtask
11
+ // command modules: each subcommand declares the set of flags (and options)
12
+ // it accepts; everything else is rejected up-front.
13
+ const OPEN_FLAGS = ["reveal-token"] as const;
14
+ const UPDATE_FLAGS: readonly string[] = [];
15
+
16
+ const FLAGS_BY_SUBCOMMAND: Readonly<Record<string, readonly string[]>> = {
17
+ open: OPEN_FLAGS,
18
+ update: UPDATE_FLAGS,
19
+ };
20
+
10
21
  type EnsureBoardInstalledFn = (options: EnsureBoardInstalledOptions) => ReturnType<typeof ensureBoardInstalled>;
11
22
  type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
12
23
  type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
@@ -67,7 +78,15 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
67
78
  const parsed = parseArgs(context.args);
68
79
  const subcommand: string | undefined = parsed.positional[0];
69
80
 
70
- if (parsed.options.size > 0 || parsed.flags.size > 0) {
81
+ const revealToken: boolean = parsed.flags.has("reveal-token");
82
+ const allowedFlags = new Set<string>(
83
+ subcommand !== undefined && subcommand in FLAGS_BY_SUBCOMMAND
84
+ ? FLAGS_BY_SUBCOMMAND[subcommand]
85
+ : [],
86
+ );
87
+ const disallowedFlags = [...parsed.flags].filter((flag) => !allowedFlags.has(flag));
88
+
89
+ if (parsed.options.size > 0 || disallowedFlags.length > 0) {
71
90
  return failResult({
72
91
  command: subcommand ? `board.${subcommand}` : "board",
73
92
  human: "Board commands do not accept options yet.",
@@ -118,30 +137,46 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
118
137
  const install = ensureInstalledImpl(boardInstallOptions(context));
119
138
  const server = startBoardServerImpl({ cwd: context.cwd });
120
139
  const launch = await openBoardInBrowserImpl(server.url);
140
+ const humanLines: string[] = [
141
+ `Board ready at ${server.fallbackUrl}`,
142
+ launch.launched
143
+ ? `Browser launched with ${launch.command}`
144
+ : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
145
+ `Open manually if needed: ${server.fallbackUrl}`,
146
+ ];
147
+ if (revealToken) {
148
+ humanLines.push(
149
+ `Tokenized URL (do not share, grants full board access): ${server.url}`,
150
+ );
151
+ }
152
+ const serverData: Record<string, unknown> = {
153
+ origin: server.origin,
154
+ fallbackUrl: server.fallbackUrl,
155
+ hostname: server.hostname,
156
+ port: server.port,
157
+ };
158
+ const launchData: Record<string, unknown> = {
159
+ launched: launch.launched,
160
+ command: launch.command,
161
+ errorMessage: launch.errorMessage,
162
+ };
163
+ if (revealToken) {
164
+ serverData.url = server.url;
165
+ serverData.token = server.token;
166
+ launchData.url = launch.url;
167
+ launchData.args = launch.args;
168
+ }
121
169
  return okResult({
122
170
  command: "board.open",
123
- human: [
124
- `Board ready at ${server.fallbackUrl}`,
125
- launch.launched
126
- ? `Browser launched with ${launch.command}`
127
- : `Browser launch failed: ${launch.errorMessage ?? "unknown failure"}`,
128
- `Open manually if needed: ${server.fallbackUrl}`,
129
- ].join("\n"),
171
+ human: humanLines.join("\n"),
130
172
  data: {
131
173
  install: {
132
174
  action: install.action,
133
175
  paths: install.paths,
134
176
  manifest: install.manifest,
135
177
  },
136
- server: {
137
- origin: server.origin,
138
- url: server.url,
139
- fallbackUrl: server.fallbackUrl,
140
- hostname: server.hostname,
141
- port: server.port,
142
- token: server.token,
143
- },
144
- launch,
178
+ server: serverData,
179
+ launch: launchData,
145
180
  },
146
181
  });
147
182
  }
@@ -1357,10 +1357,9 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1357
1357
 
1358
1358
  const targets = updateAll ? [...domain.listEpics()] : ids.map((id) => domain.getEpicOrThrow(id));
1359
1359
  const epics = targets.map((target) =>
1360
- mutations.updateEpic(target.id, {
1361
- status,
1362
- description: append === undefined ? undefined : appendLine(target.description, append),
1363
- }),
1360
+ append !== undefined
1361
+ ? mutations.appendToEpicDescription({ epicId: target.id, append, status })
1362
+ : mutations.updateEpic(target.id, { status }),
1364
1363
  );
1365
1364
 
1366
1365
  return okResult({
@@ -1386,11 +1385,10 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
1386
1385
  });
1387
1386
  }
1388
1387
 
1389
- const nextDescription =
1390
- append === undefined
1391
- ? description
1392
- : appendLine(domain.getEpicOrThrow(epicId).description, append);
1393
- const epic = mutations.updateEpic(epicId, { title, description: nextDescription, status });
1388
+ const epic =
1389
+ append !== undefined
1390
+ ? mutations.appendToEpicDescription({ epicId, append, status })
1391
+ : mutations.updateEpic(epicId, { title, description, status });
1394
1392
 
1395
1393
  return okResult({
1396
1394
  command: "epic.update",