trekoon 0.4.0 → 0.4.2
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 +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- 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 +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -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 +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -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 +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- 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 +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- 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 +650 -147
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",
|
|
@@ -93,6 +93,35 @@ function buildBoardSessionCookie(token: string): string {
|
|
|
93
93
|
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
|
|
94
94
|
}
|
|
95
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
|
+
|
|
96
125
|
function serializeInlineJson(value: unknown): string {
|
|
97
126
|
return JSON.stringify(value)
|
|
98
127
|
.replace(/</g, "\\u003c")
|
|
@@ -102,11 +131,12 @@ function serializeInlineJson(value: unknown): string {
|
|
|
102
131
|
.replace(/\u2029/g, "\\u2029");
|
|
103
132
|
}
|
|
104
133
|
|
|
105
|
-
function buildBoardBootstrapPayload(
|
|
106
|
-
|
|
134
|
+
function buildBoardBootstrapPayload(_database: TrekoonDatabase, token: string): string {
|
|
135
|
+
// Only the auth token is inlined; the snapshot is fetched client-side via
|
|
136
|
+
// /api/snapshot to keep index.html small and avoid disclosing data even
|
|
137
|
+
// briefly through the HTML response cache.
|
|
107
138
|
return serializeInlineJson({
|
|
108
139
|
token,
|
|
109
|
-
snapshot: buildBoardSnapshot(domain),
|
|
110
140
|
});
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -127,27 +157,56 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
127
157
|
const boardRoot: string = paths.boardDir;
|
|
128
158
|
const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
|
|
129
159
|
const token: string = options.token ?? randomBytes(18).toString("hex");
|
|
160
|
+
const eventBus: BoardEventBus = createBoardEventBus();
|
|
161
|
+
const walWatcher: WalWatcher = startWalWatcher({
|
|
162
|
+
db: database.db,
|
|
163
|
+
databaseFile: database.paths.databaseFile,
|
|
164
|
+
eventBus,
|
|
165
|
+
});
|
|
130
166
|
const apiHandler = createBoardApiHandler({
|
|
131
167
|
db: database.db,
|
|
132
168
|
cwd,
|
|
133
169
|
token,
|
|
170
|
+
eventBus,
|
|
134
171
|
});
|
|
135
172
|
|
|
136
173
|
const serveBoard = (port: number) =>
|
|
137
174
|
Bun.serve({
|
|
138
175
|
hostname: "127.0.0.1",
|
|
139
176
|
port,
|
|
177
|
+
idleTimeout: 0,
|
|
140
178
|
fetch(request: Request): Promise<Response> | Response {
|
|
141
179
|
const url = new URL(request.url);
|
|
142
180
|
if (url.pathname.startsWith("/api/")) {
|
|
143
181
|
return apiHandler(request);
|
|
144
182
|
}
|
|
145
183
|
|
|
184
|
+
const isAuthenticated = isAuthenticatedBoardRequest(request, url, token);
|
|
146
185
|
const responseHeaders: Record<string, string> = {
|
|
147
186
|
"cache-control": "no-store",
|
|
148
187
|
};
|
|
149
|
-
|
|
188
|
+
const queryTokenMatched = (url.searchParams.get("token") ?? "") === token;
|
|
189
|
+
if (isAuthenticated && queryTokenMatched) {
|
|
150
190
|
responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
|
|
191
|
+
|
|
192
|
+
// Token revoke on rotation (P1 finding 8): once we've installed the
|
|
193
|
+
// session cookie, redirect to the same URL with the `token=` query
|
|
194
|
+
// param stripped. This keeps the browser's address bar, history,
|
|
195
|
+
// and Referer headers free of the secret on the very first
|
|
196
|
+
// navigation, severing the leakage surface that an open URL bar
|
|
197
|
+
// would otherwise expose. The cookie carries auth from here on.
|
|
198
|
+
const redirectUrl = new URL(url);
|
|
199
|
+
redirectUrl.searchParams.delete("token");
|
|
200
|
+
// Preserve the relative location so the redirect works regardless
|
|
201
|
+
// of how the client reached us (loopback IP vs. localhost name).
|
|
202
|
+
const location = `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
|
203
|
+
return new Response(null, {
|
|
204
|
+
status: 302,
|
|
205
|
+
headers: {
|
|
206
|
+
...responseHeaders,
|
|
207
|
+
location: location.length > 0 ? location : "/",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
151
210
|
}
|
|
152
211
|
|
|
153
212
|
const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
|
|
@@ -157,9 +216,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
157
216
|
return new Response("Board assets are not installed", { status: 500 });
|
|
158
217
|
}
|
|
159
218
|
|
|
160
|
-
const
|
|
219
|
+
const rawHtml = readFileSync(fallbackPath, "utf8");
|
|
220
|
+
const html = isAuthenticated
|
|
221
|
+
? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
|
|
222
|
+
: rawHtml;
|
|
161
223
|
|
|
162
224
|
return new Response(html, {
|
|
225
|
+
status: isAuthenticated ? 200 : 401,
|
|
163
226
|
headers: {
|
|
164
227
|
...responseHeaders,
|
|
165
228
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -168,8 +231,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
if (assetPath.endsWith("/index.html")) {
|
|
171
|
-
const
|
|
234
|
+
const rawHtml = readFileSync(assetPath, "utf8");
|
|
235
|
+
const html = isAuthenticated
|
|
236
|
+
? injectBoardBootstrap(rawHtml, buildBoardBootstrapPayload(database, token))
|
|
237
|
+
: rawHtml;
|
|
172
238
|
return new Response(html, {
|
|
239
|
+
status: isAuthenticated ? 200 : 401,
|
|
173
240
|
headers: {
|
|
174
241
|
...responseHeaders,
|
|
175
242
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -231,6 +298,8 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
231
298
|
hostname: "127.0.0.1",
|
|
232
299
|
port,
|
|
233
300
|
stop(): void {
|
|
301
|
+
walWatcher.close();
|
|
302
|
+
eventBus.close();
|
|
234
303
|
server.stop(true);
|
|
235
304
|
database.close();
|
|
236
305
|
},
|
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
interface CollectionDiff {
|
|
26
|
+
readonly upserted: unknown[];
|
|
27
|
+
readonly deletedIds: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function recordId(value: unknown): string | null {
|
|
31
|
+
if (!value || typeof value !== "object") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const id = (value as { id?: unknown }).id;
|
|
35
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the (version, updatedAt) tuple used to detect content changes.
|
|
40
|
+
*
|
|
41
|
+
* `updatedAt` is bumped on every domain write; `version` is incremented in
|
|
42
|
+
* lockstep at the SQLite layer. Comparing the tuple lets us bail out cheaply
|
|
43
|
+
* when neither has moved, avoiding the JSON.stringify hot path that fires on
|
|
44
|
+
* every WAL tick — including ticks where only non-content shape changed
|
|
45
|
+
* (e.g. dependency reordering of an unrelated record produced an array
|
|
46
|
+
* identity change but no semantic delta for the entity in question).
|
|
47
|
+
*/
|
|
48
|
+
function recordChangeKey(value: unknown): { version: number | null; updatedAt: number | null } {
|
|
49
|
+
if (!value || typeof value !== "object") {
|
|
50
|
+
return { version: null, updatedAt: null };
|
|
51
|
+
}
|
|
52
|
+
const versionRaw = (value as { version?: unknown }).version;
|
|
53
|
+
const updatedAtRaw = (value as { updatedAt?: unknown }).updatedAt;
|
|
54
|
+
return {
|
|
55
|
+
version: typeof versionRaw === "number" ? versionRaw : null,
|
|
56
|
+
updatedAt: typeof updatedAtRaw === "number" ? updatedAtRaw : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function changeKeyEqual(
|
|
61
|
+
a: { version: number | null; updatedAt: number | null },
|
|
62
|
+
b: { version: number | null; updatedAt: number | null },
|
|
63
|
+
): boolean {
|
|
64
|
+
return a.version === b.version && a.updatedAt === b.updatedAt;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
|
|
68
|
+
const previousIndex = new Map<string, unknown>();
|
|
69
|
+
for (const record of previous ?? []) {
|
|
70
|
+
const id = recordId(record);
|
|
71
|
+
if (id !== null) {
|
|
72
|
+
previousIndex.set(id, record);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const upserted: unknown[] = [];
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const record of current ?? []) {
|
|
79
|
+
const id = recordId(record);
|
|
80
|
+
if (id === null) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
seen.add(id);
|
|
85
|
+
const previousRecord = previousIndex.get(id);
|
|
86
|
+
if (!previousRecord) {
|
|
87
|
+
upserted.push(record);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Tuple compare on (version, updatedAt) — both move in lockstep on every
|
|
91
|
+
// domain write. Equal tuple → no content change → skip the upsert.
|
|
92
|
+
if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
|
|
93
|
+
upserted.push(record);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const deletedIds: string[] = [];
|
|
98
|
+
for (const id of previousIndex.keys()) {
|
|
99
|
+
if (!seen.has(id)) {
|
|
100
|
+
deletedIds.push(id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { upserted, deletedIds };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface WalWatcherOptions {
|
|
108
|
+
readonly db: Database;
|
|
109
|
+
readonly databaseFile: string;
|
|
110
|
+
readonly eventBus: BoardEventBus;
|
|
111
|
+
/**
|
|
112
|
+
* How long to coalesce successive mtime change events. Defaults to 150ms
|
|
113
|
+
* which is small enough to feel real-time and large enough to absorb the
|
|
114
|
+
* burst of write events that SQLite emits within a single transaction.
|
|
115
|
+
*/
|
|
116
|
+
readonly debounceMs?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Log every Nth reconcile failure at warn level. Defaults to 5.
|
|
119
|
+
*/
|
|
120
|
+
readonly logEveryNthFailure?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Optional logger override; defaults to `console.warn`. Used by tests to
|
|
123
|
+
* assert failure-counter behavior without polluting stderr.
|
|
124
|
+
*/
|
|
125
|
+
readonly logger?: (message: string, error: unknown) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Optional snapshot builder override. Defaults to {@link buildBoardSnapshot}.
|
|
128
|
+
* Tests inject a throwing or stubbed builder to exercise failure paths.
|
|
129
|
+
*/
|
|
130
|
+
readonly buildSnapshot?: (domain: TrackerDomain) => BoardSnapshot;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface WalWatcher {
|
|
134
|
+
/**
|
|
135
|
+
* Force a reconciliation outside the normal mtime-driven path. Useful for
|
|
136
|
+
* tests and for kicking the watcher after a manual external change.
|
|
137
|
+
*/
|
|
138
|
+
reconcile(): void;
|
|
139
|
+
/**
|
|
140
|
+
* Total number of reconcile failures since the watcher started. Exposed for
|
|
141
|
+
* tests and operators; the watcher itself never throws.
|
|
142
|
+
*/
|
|
143
|
+
readonly failureCount: () => number;
|
|
144
|
+
close(): void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
148
|
+
const debounceMs = options.debounceMs ?? 150;
|
|
149
|
+
const logEveryNthFailure = Math.max(1, options.logEveryNthFailure ?? 5);
|
|
150
|
+
const logger = options.logger ?? ((message: string, error: unknown): void => {
|
|
151
|
+
// eslint-disable-next-line no-console
|
|
152
|
+
console.warn(message, error);
|
|
153
|
+
});
|
|
154
|
+
const walFile = `${options.databaseFile}-wal`;
|
|
155
|
+
const watchDir = dirname(options.databaseFile);
|
|
156
|
+
const dbBaseName = basename(options.databaseFile);
|
|
157
|
+
|
|
158
|
+
// Hoist TrackerDomain construction out of reconcile: build once and reuse
|
|
159
|
+
// across ticks. The domain is a thin wrapper over the bun:sqlite Database
|
|
160
|
+
// handle and holds prepared-statement caches — recreating it per tick burns
|
|
161
|
+
// CPU on large boards. The handle stays valid for the lifetime of the
|
|
162
|
+
// server, so re-binding is unnecessary.
|
|
163
|
+
const domain = new TrackerDomain(options.db);
|
|
164
|
+
const buildSnapshot = options.buildSnapshot ?? buildBoardSnapshot;
|
|
165
|
+
|
|
166
|
+
let lastSnapshot = buildSnapshot(domain);
|
|
167
|
+
|
|
168
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
169
|
+
let closed = false;
|
|
170
|
+
let failures = 0;
|
|
171
|
+
|
|
172
|
+
function reconcile(): void {
|
|
173
|
+
if (closed) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const fresh = buildSnapshot(domain);
|
|
179
|
+
|
|
180
|
+
const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
|
|
181
|
+
const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
|
|
182
|
+
const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
|
|
183
|
+
const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
|
|
184
|
+
|
|
185
|
+
const hasChanges =
|
|
186
|
+
epicsDiff.upserted.length > 0 || epicsDiff.deletedIds.length > 0 ||
|
|
187
|
+
tasksDiff.upserted.length > 0 || tasksDiff.deletedIds.length > 0 ||
|
|
188
|
+
subtasksDiff.upserted.length > 0 || subtasksDiff.deletedIds.length > 0 ||
|
|
189
|
+
dependenciesDiff.upserted.length > 0 || dependenciesDiff.deletedIds.length > 0;
|
|
190
|
+
|
|
191
|
+
lastSnapshot = fresh;
|
|
192
|
+
|
|
193
|
+
if (!hasChanges) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
options.eventBus.publishSnapshotDelta({
|
|
198
|
+
generatedAt: Date.now(),
|
|
199
|
+
source: "wal-watcher",
|
|
200
|
+
epics: epicsDiff.upserted,
|
|
201
|
+
tasks: tasksDiff.upserted,
|
|
202
|
+
subtasks: subtasksDiff.upserted,
|
|
203
|
+
dependencies: dependenciesDiff.upserted,
|
|
204
|
+
deletedEpicIds: epicsDiff.deletedIds,
|
|
205
|
+
deletedTaskIds: tasksDiff.deletedIds,
|
|
206
|
+
deletedSubtaskIds: subtasksDiff.deletedIds,
|
|
207
|
+
deletedDependencyIds: dependenciesDiff.deletedIds,
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Reconciliation must never crash the server. Errors here usually mean
|
|
211
|
+
// the database is mid-write or a downstream snapshot builder threw; the
|
|
212
|
+
// next mtime tick will retry. Log every Nth failure to keep operators
|
|
213
|
+
// informed without flooding stderr on persistent faults.
|
|
214
|
+
failures += 1;
|
|
215
|
+
if (failures % logEveryNthFailure === 0) {
|
|
216
|
+
logger(`wal-watcher: reconcile failed (${failures} total failures)`, error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scheduleReconcile(): void {
|
|
222
|
+
if (closed) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (debounceTimer) {
|
|
226
|
+
clearTimeout(debounceTimer);
|
|
227
|
+
}
|
|
228
|
+
debounceTimer = setTimeout(() => {
|
|
229
|
+
debounceTimer = null;
|
|
230
|
+
reconcile();
|
|
231
|
+
}, debounceMs);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Track WAL mtime when available, so we only react to actual changes rather
|
|
235
|
+
// than spurious watcher fires (e.g. atime-only updates on some filesystems).
|
|
236
|
+
let lastWalMtime: number = readMtime(walFile);
|
|
237
|
+
|
|
238
|
+
function readMtime(path: string): number {
|
|
239
|
+
if (!existsSync(path)) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
return statSync(path).mtimeMs;
|
|
244
|
+
} catch {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function maybeScheduleReconcile(): void {
|
|
250
|
+
const currentMtime = readMtime(walFile);
|
|
251
|
+
// mtime can equal 0 when the WAL was just checkpointed and removed; treat
|
|
252
|
+
// any change (including transitions to/from 0) as worth reconciling.
|
|
253
|
+
if (currentMtime !== lastWalMtime) {
|
|
254
|
+
lastWalMtime = currentMtime;
|
|
255
|
+
scheduleReconcile();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// We watch the directory rather than the WAL file directly because the WAL
|
|
260
|
+
// file can be unlinked (e.g. on checkpoint) which invalidates a direct
|
|
261
|
+
// file watch on some platforms.
|
|
262
|
+
let watcher: FSWatcher | null = null;
|
|
263
|
+
try {
|
|
264
|
+
watcher = watch(watchDir, (_eventType, filename) => {
|
|
265
|
+
if (closed) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (typeof filename === "string" && filename !== `${dbBaseName}-wal` && filename !== dbBaseName) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
maybeScheduleReconcile();
|
|
272
|
+
});
|
|
273
|
+
watcher.on("error", () => {
|
|
274
|
+
// Best-effort; ignore transient watcher errors.
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
// Filesystem watch is best-effort. If it cannot be set up (e.g. read-only
|
|
278
|
+
// filesystem), the watcher silently degrades and only `reconcile()` calls
|
|
279
|
+
// will publish deltas.
|
|
280
|
+
watcher = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
reconcile,
|
|
285
|
+
failureCount: (): number => failures,
|
|
286
|
+
close(): void {
|
|
287
|
+
closed = true;
|
|
288
|
+
if (debounceTimer) {
|
|
289
|
+
clearTimeout(debounceTimer);
|
|
290
|
+
debounceTimer = null;
|
|
291
|
+
}
|
|
292
|
+
if (watcher) {
|
|
293
|
+
try {
|
|
294
|
+
watcher.close();
|
|
295
|
+
} catch {
|
|
296
|
+
// Already closed.
|
|
297
|
+
}
|
|
298
|
+
watcher = null;
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
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",
|
|
@@ -29,8 +29,46 @@ function readErrorMessage(error: unknown): string | null {
|
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Keys whose values must never appear in surfaced error output.
|
|
33
|
+
// Handles formats: key=val, key: val, key="val", 'key':'val', "key":"val",
|
|
34
|
+
// Authorization: Bearer val, Authorization: Basic val.
|
|
35
|
+
const SENSITIVE_KEY_PATTERN =
|
|
36
|
+
/(["']?)(token|secret|password|bearer|authorization|api[_-]?key|client[_-]?secret|private[_-]?key|cookie|session[_-]?id)(["']?\s*[:=]\s*(?:Bearer\s+|Basic\s+)?["']?)([^\s"',;&\]}{)<>]+)/giu;
|
|
37
|
+
|
|
38
|
+
// Tag-style sensitive values: <key>value</key>.
|
|
39
|
+
const SENSITIVE_TAG_PATTERN =
|
|
40
|
+
/(<\s*(token|secret|password|bearer|authorization|api[_-]?key|client[_-]?secret|private[_-]?key|cookie|session[_-]?id)\s*>)([^<]+)/giu;
|
|
41
|
+
|
|
42
|
+
// Standalone "Bearer xyz" / "Basic xyz" anywhere in the message.
|
|
43
|
+
// SENSITIVE_KEY_PATTERN runs first and consumes Authorization: Bearer/Basic forms; this
|
|
44
|
+
// catches bare occurrences that remain (e.g. "got Bearer eyJ..." or "auth: Basic dXNl...").
|
|
45
|
+
const STANDALONE_AUTH_SCHEME_PATTERN = /\b(Bearer|Basic)\s+([A-Za-z0-9._\-+/=]+)/giu;
|
|
46
|
+
|
|
47
|
+
// JWT shape heuristic: three base64url segments separated by dots, each starting
|
|
48
|
+
// with a base64url-encoded JSON header/payload/signature. The first two segments
|
|
49
|
+
// of any JWT begin with "eyJ" because they encode JSON objects (`{"...`).
|
|
50
|
+
// Catches bare JWTs that slip past the keyed and Bearer/Basic patterns above
|
|
51
|
+
// (e.g. raw token pasted into an error message without an "Authorization:" prefix).
|
|
52
|
+
const JWT_PATTERN = /\beyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/gu;
|
|
53
|
+
|
|
54
|
+
export function redactSensitive(input: string): string {
|
|
55
|
+
const keyRedacted = input.replace(
|
|
56
|
+
SENSITIVE_KEY_PATTERN,
|
|
57
|
+
(_match, open, key, sep) => `${open}${key}${sep}REDACTED`,
|
|
58
|
+
);
|
|
59
|
+
const tagRedacted = keyRedacted.replace(
|
|
60
|
+
SENSITIVE_TAG_PATTERN,
|
|
61
|
+
(_match, openTag) => `${openTag}REDACTED`,
|
|
62
|
+
);
|
|
63
|
+
const authRedacted = tagRedacted.replace(
|
|
64
|
+
STANDALONE_AUTH_SCHEME_PATTERN,
|
|
65
|
+
(_match, scheme) => `${scheme} REDACTED`,
|
|
66
|
+
);
|
|
67
|
+
return authRedacted.replace(JWT_PATTERN, "REDACTED");
|
|
68
|
+
}
|
|
69
|
+
|
|
32
70
|
function sanitizeErrorMessage(message: string): string {
|
|
33
|
-
const normalized = message.replace(/\s+/gu, " ").trim();
|
|
71
|
+
const normalized = redactSensitive(message.replace(/\s+/gu, " ").trim());
|
|
34
72
|
if (normalized.length <= 240) {
|
|
35
73
|
return normalized;
|
|
36
74
|
}
|
|
@@ -109,3 +147,18 @@ export function safeErrorMessage(error: unknown, fallback: string): string {
|
|
|
109
147
|
const message = readErrorMessage(error);
|
|
110
148
|
return message === null ? fallback : sanitizeErrorMessage(message);
|
|
111
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Redact a stack trace before logging. Routes the input through
|
|
153
|
+
* `redactSensitive` (the canonical secret-stripping pass) so absolute paths
|
|
154
|
+
* and any inline credentials are scrubbed. The function is intentionally
|
|
155
|
+
* shallow — additional heuristics (e.g. JWT shape detection) live in
|
|
156
|
+
* `redactSensitive` itself so future contributors only need to extend that
|
|
157
|
+
* single regex pipeline.
|
|
158
|
+
*/
|
|
159
|
+
export function redactStack(stack: string | undefined): string {
|
|
160
|
+
if (typeof stack !== "string" || stack.length === 0) {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
return redactSensitive(stack);
|
|
164
|
+
}
|