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.
- package/.agents/skills/trekoon/SKILL.md +97 -765
- package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
- package/.agents/skills/trekoon/reference/execution.md +188 -159
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +213 -213
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +82 -0
- package/README.md +29 -8
- package/docs/ai-agents.md +65 -6
- package/docs/commands.md +149 -5
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +55 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +47 -13
- package/src/board/assets/components/Component.js +20 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +45 -4
- package/src/board/assets/state/api.js +304 -17
- package/src/board/assets/state/store.js +82 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +81 -0
- package/src/board/routes.ts +430 -40
- package/src/board/server.ts +86 -10
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +313 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +75 -10
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/skills.ts +17 -5
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +842 -187
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +167 -693
- package/src/domain/types.ts +56 -2
- package/src/export/render-markdown.ts +1 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +700 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +268 -4
- package/src/storage/migrations.ts +441 -22
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +679 -156
package/src/board/server.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
106
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
},
|
package/src/board/snapshot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/board.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/epic.ts
CHANGED
|
@@ -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
|
-
|
|
1361
|
-
status
|
|
1362
|
-
|
|
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
|
|
1390
|
-
append
|
|
1391
|
-
?
|
|
1392
|
-
:
|
|
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",
|