@nickmeriano/task 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -22
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +108 -23
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +320 -86
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +10 -2
- package/dist/claim.test.d.ts.map +1 -1
- package/dist/claim.test.js +282 -96
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +346 -68
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +96 -24
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +372 -101
- package/src/claim.ts +418 -99
- package/src/cli.ts +370 -67
- package/src/file-store.ts +357 -145
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJmOsOdR.js +0 -229
- package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/server.ts
CHANGED
|
@@ -3,8 +3,11 @@ import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:
|
|
|
3
3
|
import { extname, join, normalize } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
5
|
import { resolveAuthor } from "./author.ts"
|
|
6
|
+
import { addAskRouted, deleteAskRouted, setAskResolvedRouted } from "./claim-io.ts"
|
|
6
7
|
import { openBoard } from "./file-store.ts"
|
|
7
|
-
import {
|
|
8
|
+
import { GitServe, GitServeError } from "./git-serve.ts"
|
|
9
|
+
import { buildInbox } from "./inbox.ts"
|
|
10
|
+
import { boardConfig, findBoards, type BoardRef, type Store } from "./store.ts"
|
|
8
11
|
import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
|
|
9
12
|
|
|
10
13
|
/** The prebuilt SPA, shipped inside the package next to dist/. */
|
|
@@ -48,9 +51,8 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
48
51
|
if (typeof body.goal === "string" || body.goal === null) {
|
|
49
52
|
patch.goal = body.goal === "" ? null : body.goal
|
|
50
53
|
}
|
|
51
|
-
if (
|
|
52
|
-
if (Array.isArray(body.
|
|
53
|
-
if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskNumber)
|
|
54
|
+
if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskKey)
|
|
55
|
+
if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskKey)
|
|
54
56
|
if (Array.isArray(body.prs)) {
|
|
55
57
|
patch.prs = body.prs.map((pr) => {
|
|
56
58
|
const url = String(pr).trim()
|
|
@@ -63,10 +65,32 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
63
65
|
return patch
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
function taskKey(value: unknown): string {
|
|
69
|
+
if (typeof value !== "string" || !/^[a-z0-9]+$/.test(value)) {
|
|
70
|
+
throw new Error(`invalid task key: ${String(value)}`)
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The CSRF gate. `task serve` is an unauthenticated HTTP server, and any
|
|
77
|
+
* webpage in the same browser can aim a POST at it — before this check, a
|
|
78
|
+
* drive-by page could edit the board, and with the git surface it could
|
|
79
|
+
* commit and push. Same-origin is the whole auth model, so a write has to
|
|
80
|
+
* prove it: browsers attach `Origin` to every cross-origin request (and to
|
|
81
|
+
* all fetch POSTs), and the only origin allowed to write is the board's own —
|
|
82
|
+
* whatever host the request itself was addressed to. No Origin header means
|
|
83
|
+
* no browser page is speaking (curl, scripts), which is the local-trust case
|
|
84
|
+
* the server already accepts.
|
|
85
|
+
*/
|
|
86
|
+
function sameOrigin(req: IncomingMessage): boolean {
|
|
87
|
+
const origin = req.headers.origin
|
|
88
|
+
if (origin === undefined) return true
|
|
89
|
+
try {
|
|
90
|
+
return new URL(origin).host === req.headers.host
|
|
91
|
+
} catch {
|
|
92
|
+
return false // "null" and other unparseable origins are nobody we trust
|
|
93
|
+
}
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
/**
|
|
@@ -144,6 +168,28 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
144
168
|
throw new Error(`no .task directory found at or below ${serveRoot} — run \`task init\` first`)
|
|
145
169
|
}
|
|
146
170
|
|
|
171
|
+
const gitServe = new GitServe(serveRoot, () =>
|
|
172
|
+
ordered.map((id) => ({ id, store: boards.get(id)!.store })),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Branch switches don't touch `.task/` files, but they change what every
|
|
176
|
+
// board file *says* — so watch the git dir for HEAD moving (a checkout,
|
|
177
|
+
// from this server or a terminal) and ping every board's SSE listeners.
|
|
178
|
+
// Non-recursive on the resolved git dir (worktrees keep HEAD there), and
|
|
179
|
+
// best-effort: a repo this can't watch simply refreshes on the next edit.
|
|
180
|
+
let gitWatcher: FSWatcher | null = null
|
|
181
|
+
const gitDir = gitServe.gitDir()
|
|
182
|
+
if (gitDir) {
|
|
183
|
+
try {
|
|
184
|
+
gitWatcher = watch(gitDir, (_event, filename) => {
|
|
185
|
+
if (filename === "HEAD") for (const id of ordered) noteChange(id)
|
|
186
|
+
})
|
|
187
|
+
gitWatcher.on("error", () => {})
|
|
188
|
+
} catch {
|
|
189
|
+
gitWatcher = null
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
147
193
|
const heartbeat = setInterval(() => {
|
|
148
194
|
for (const client of clients) client.write(`: ping\n\n`)
|
|
149
195
|
}, 30_000)
|
|
@@ -159,6 +205,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
159
205
|
server.on("close", () => {
|
|
160
206
|
if (pending) clearTimeout(pending)
|
|
161
207
|
clearInterval(heartbeat)
|
|
208
|
+
gitWatcher?.close()
|
|
162
209
|
for (const board of boards.values()) {
|
|
163
210
|
board.watcher.close()
|
|
164
211
|
board.store.close()
|
|
@@ -170,6 +217,19 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
170
217
|
const url = new URL(req.url ?? "/", "http://localhost")
|
|
171
218
|
const path = url.pathname
|
|
172
219
|
|
|
220
|
+
// Every mutation, one gate — the ask routes push commits and the git
|
|
221
|
+
// routes run git, so this cannot be per-endpoint courtesy.
|
|
222
|
+
const method = req.method ?? "GET"
|
|
223
|
+
if (
|
|
224
|
+
path.startsWith("/api/") &&
|
|
225
|
+
method !== "GET" &&
|
|
226
|
+
method !== "HEAD" &&
|
|
227
|
+
!sameOrigin(req)
|
|
228
|
+
) {
|
|
229
|
+
json(res, 403, { error: "cross-origin request refused — the board only writes for its own pages" })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
173
233
|
if (path === "/api/events") {
|
|
174
234
|
res.writeHead(200, {
|
|
175
235
|
"Content-Type": "text/event-stream",
|
|
@@ -193,6 +253,94 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
193
253
|
return
|
|
194
254
|
}
|
|
195
255
|
|
|
256
|
+
if (path === "/api/inbox") {
|
|
257
|
+
// Deliberately cross-board — the header badge is repo-wide attention.
|
|
258
|
+
// Scope follows the CLI's selection rule: the root board's `boards`
|
|
259
|
+
// list when it has one (matched by prefix), every served board
|
|
260
|
+
// otherwise — so a vendored board can't put asks in your face unless
|
|
261
|
+
// the root board opted it in.
|
|
262
|
+
syncBoards()
|
|
263
|
+
const all = ordered.map((id) => ({ id, store: boards.get(id)!.store }))
|
|
264
|
+
let scoped = all
|
|
265
|
+
try {
|
|
266
|
+
const wanted = boardConfig(all[0].store.root).boards
|
|
267
|
+
if (Array.isArray(wanted) && wanted.length > 0) {
|
|
268
|
+
const prefixes = new Set(wanted.map((p) => String(p).toUpperCase()))
|
|
269
|
+
const matched = all.filter((b) => prefixes.has(b.store.config.prefix.toUpperCase()))
|
|
270
|
+
if (matched.length > 0) scoped = matched
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Unreadable root config — serve everything rather than nothing.
|
|
274
|
+
}
|
|
275
|
+
json(res, 200, { inbox: buildInbox(scoped) })
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The git surface (TAS-42). Repo-level, not board-level — one working
|
|
280
|
+
// tree holds every served board — so it lives beside /api/inbox rather
|
|
281
|
+
// than inside routeApi. Every handler 404s ("no git surface here") when
|
|
282
|
+
// the capability is off, which is also what the prebuilt UI probes.
|
|
283
|
+
if (path.startsWith("/api/git/")) {
|
|
284
|
+
const fail = (error: unknown): void => {
|
|
285
|
+
const status = error instanceof GitServeError ? error.status : 400
|
|
286
|
+
json(res, status, { error: error instanceof Error ? error.message : String(error) })
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
if (path === "/api/git/status" && req.method === "GET") {
|
|
290
|
+
json(res, 200, gitServe.status())
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (path === "/api/git/branches" && req.method === "GET") {
|
|
294
|
+
json(res, 200, { branches: gitServe.branches() })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (path === "/api/git/pr" && req.method === "GET") {
|
|
298
|
+
const branch = url.searchParams.get("branch")
|
|
299
|
+
if (!branch) {
|
|
300
|
+
json(res, 400, { error: "branch is required" })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
json(res, 200, gitServe.prFor(branch))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if (path === "/api/git/commit" && req.method === "POST") {
|
|
307
|
+
void readBody(req)
|
|
308
|
+
.then((body) => json(res, 200, gitServe.commit(body)))
|
|
309
|
+
.catch(fail)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (path === "/api/git/checkout" && req.method === "POST") {
|
|
313
|
+
void readBody(req)
|
|
314
|
+
.then((body) => json(res, 200, gitServe.checkout(body)))
|
|
315
|
+
.catch(fail)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
if (path === "/api/git/pr" && req.method === "POST") {
|
|
319
|
+
void readBody(req)
|
|
320
|
+
.then((body) => json(res, 200, gitServe.pr(body)))
|
|
321
|
+
.catch(fail)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (path === "/api/git/release" && req.method === "POST") {
|
|
325
|
+
const id = url.searchParams.get("board") ?? ordered[0]
|
|
326
|
+
const board = boards.get(id)
|
|
327
|
+
if (!board) {
|
|
328
|
+
json(res, 404, { error: `no such board: ${id}` })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
void readBody(req)
|
|
332
|
+
.then((body) => json(res, 200, gitServe.releaseClaim(board.store, body, author)))
|
|
333
|
+
.catch(fail)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
fail(error)
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
json(res, 404, { error: "not found" })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
196
344
|
if (path.startsWith("/api/")) {
|
|
197
345
|
const id = url.searchParams.get("board") ?? ordered[0]
|
|
198
346
|
const board = boards.get(id)
|
|
@@ -219,6 +367,10 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
219
367
|
prefix: store.config.prefix,
|
|
220
368
|
statuses: STATUSES,
|
|
221
369
|
author,
|
|
370
|
+
// The git capability, or nothing: its absence is how the hosted and
|
|
371
|
+
// snapshot deployments (whose /project never carries it) and a serve
|
|
372
|
+
// with no origin all tell the UI to draw no git surface at all.
|
|
373
|
+
git: gitServe.capability() ?? undefined,
|
|
222
374
|
})
|
|
223
375
|
return
|
|
224
376
|
}
|
|
@@ -236,7 +388,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
236
388
|
const counts = store.commentCounts()
|
|
237
389
|
const tasks = store
|
|
238
390
|
.list()
|
|
239
|
-
.map((t) => ({ ...t, commentCount: counts.get(t.
|
|
391
|
+
.map((t) => ({ ...t, commentCount: counts.get(t.key) ?? 0 }))
|
|
240
392
|
json(res, 200, { tasks })
|
|
241
393
|
return
|
|
242
394
|
}
|
|
@@ -248,19 +400,69 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
248
400
|
throw new Error("title is required")
|
|
249
401
|
}
|
|
250
402
|
const patch = parsePatch(body)
|
|
251
|
-
|
|
403
|
+
let task = store.create({ title: body.title.trim(), ...patch })
|
|
404
|
+
// Asks at create time — the dialog's chip. Author is the server's
|
|
405
|
+
// resolved identity, same trust rule as comments.
|
|
406
|
+
if (Array.isArray(body.asks)) {
|
|
407
|
+
for (const text of body.asks) {
|
|
408
|
+
if (typeof text === "string" && text.trim()) {
|
|
409
|
+
store.addAsk(task.key, text.trim(), author)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
task = store.get(task.key)!
|
|
413
|
+
}
|
|
252
414
|
json(res, 201, { task })
|
|
253
415
|
})
|
|
254
416
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
255
417
|
return
|
|
256
418
|
}
|
|
257
419
|
|
|
420
|
+
// Routed, not raw store calls: on a claimed ticket the write lands on the
|
|
421
|
+
// claim branch as a pushed commit (claim-io.ts) — the branch owns a
|
|
422
|
+
// claimed ticket's asks, checkbox and CLI alike.
|
|
423
|
+
const askMatch = /^\/api\/tasks\/([^/]+)\/asks(?:\/([^/]+))?$/.exec(path)
|
|
424
|
+
if (askMatch) {
|
|
425
|
+
const key = store.parseId(decodeURIComponent(askMatch[1]))
|
|
426
|
+
if (!askMatch[2] && req.method === "POST") {
|
|
427
|
+
void readBody(req)
|
|
428
|
+
.then((body) => {
|
|
429
|
+
if (typeof body.text !== "string" || !body.text.trim()) {
|
|
430
|
+
throw new Error("text is required")
|
|
431
|
+
}
|
|
432
|
+
const { ask, branch } = addAskRouted(store, key, body.text.trim(), author)
|
|
433
|
+
json(res, 201, { ask, branch })
|
|
434
|
+
})
|
|
435
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
if (askMatch[2] && req.method === "PATCH") {
|
|
439
|
+
const ref = decodeURIComponent(askMatch[2])
|
|
440
|
+
void readBody(req)
|
|
441
|
+
.then((body) => {
|
|
442
|
+
if (typeof body.resolved !== "boolean") {
|
|
443
|
+
throw new Error("resolved must be true or false")
|
|
444
|
+
}
|
|
445
|
+
const { ask, branch } = setAskResolvedRouted(store, key, ref, body.resolved, author)
|
|
446
|
+
json(res, 200, { ask, branch })
|
|
447
|
+
})
|
|
448
|
+
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (askMatch[2] && req.method === "DELETE") {
|
|
452
|
+
const { branch } = deleteAskRouted(store, key, decodeURIComponent(askMatch[2]))
|
|
453
|
+
json(res, 200, { ok: true, branch })
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
json(res, 404, { error: "not found" })
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
258
460
|
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
|
|
259
461
|
if (taskMatch) {
|
|
260
|
-
const
|
|
462
|
+
const key = store.parseId(decodeURIComponent(taskMatch[1]))
|
|
261
463
|
if (taskMatch[3] && req.method === "DELETE") {
|
|
262
464
|
const commentId = decodeURIComponent(taskMatch[3])
|
|
263
|
-
const existing = store.comments(
|
|
465
|
+
const existing = store.comments(key).find((c) => c.id === commentId)
|
|
264
466
|
if (!existing) {
|
|
265
467
|
json(res, 404, { error: `no such comment: ${commentId}` })
|
|
266
468
|
return
|
|
@@ -271,7 +473,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
271
473
|
json(res, 403, { error: "only your own comments can be deleted" })
|
|
272
474
|
return
|
|
273
475
|
}
|
|
274
|
-
store.deleteComment(
|
|
476
|
+
store.deleteComment(key, commentId)
|
|
275
477
|
json(res, 200, { ok: true })
|
|
276
478
|
return
|
|
277
479
|
}
|
|
@@ -282,7 +484,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
282
484
|
throw new Error("body is required")
|
|
283
485
|
}
|
|
284
486
|
// `body.author` is ignored on purpose — unauthenticated noise.
|
|
285
|
-
const comment = store.addComment(
|
|
487
|
+
const comment = store.addComment(key, body.body.trim(), author)
|
|
286
488
|
json(res, 201, { comment })
|
|
287
489
|
})
|
|
288
490
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
@@ -290,25 +492,25 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
290
492
|
}
|
|
291
493
|
if (!taskMatch[2]) {
|
|
292
494
|
if (req.method === "GET") {
|
|
293
|
-
const task = store.get(
|
|
495
|
+
const task = store.get(key)
|
|
294
496
|
if (!task) {
|
|
295
|
-
json(res, 404, { error: `no such task: ${store.displayId(
|
|
497
|
+
json(res, 404, { error: `no such task: ${store.displayId(key)}` })
|
|
296
498
|
return
|
|
297
499
|
}
|
|
298
|
-
json(res, 200, { task, comments: store.comments(
|
|
500
|
+
json(res, 200, { task, comments: store.comments(key) })
|
|
299
501
|
return
|
|
300
502
|
}
|
|
301
503
|
if (req.method === "PATCH") {
|
|
302
504
|
void readBody(req)
|
|
303
505
|
.then((body) => {
|
|
304
|
-
const task = store.update(
|
|
506
|
+
const task = store.update(key, parsePatch(body))
|
|
305
507
|
json(res, 200, { task })
|
|
306
508
|
})
|
|
307
509
|
.catch((error: Error) => json(res, 400, { error: error.message }))
|
|
308
510
|
return
|
|
309
511
|
}
|
|
310
512
|
if (req.method === "DELETE") {
|
|
311
|
-
store.delete(
|
|
513
|
+
store.delete(key)
|
|
312
514
|
json(res, 200, { ok: true })
|
|
313
515
|
return
|
|
314
516
|
}
|
package/src/store.test.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "nod
|
|
|
14
14
|
import { tmpdir } from "node:os"
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
import { FileStore, initProject, openBoard } from "./file-store.ts"
|
|
17
|
-
import {
|
|
17
|
+
import { KEY_PATTERN } from "./id.ts"
|
|
18
|
+
import { parseTicket, parseTicketLenient, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
18
19
|
import { findBoardsByPrefix, findScopeRoot } from "./store.ts"
|
|
19
20
|
|
|
20
21
|
function tempRoot(): string {
|
|
@@ -31,7 +32,6 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
31
32
|
status: "todo",
|
|
32
33
|
tags: [],
|
|
33
34
|
goal: null,
|
|
34
|
-
needsHuman: false,
|
|
35
35
|
blockedBy: [],
|
|
36
36
|
prs: [],
|
|
37
37
|
position: 1024,
|
|
@@ -46,8 +46,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
46
46
|
status: "in_progress",
|
|
47
47
|
tags: ["storage", "architecture"],
|
|
48
48
|
goal: "v2-storage",
|
|
49
|
-
|
|
50
|
-
blockedBy: [3, 7],
|
|
49
|
+
blockedBy: ["k3mfp", "x7c2d"],
|
|
51
50
|
prs: ["https://github.com/x/y/pull/1"],
|
|
52
51
|
position: -512.5,
|
|
53
52
|
createdAt: "2026-08-15T16:00:33.758Z",
|
|
@@ -59,7 +58,6 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
59
58
|
status: "backlog",
|
|
60
59
|
tags: [],
|
|
61
60
|
goal: null,
|
|
62
|
-
needsHuman: false,
|
|
63
61
|
blockedBy: [],
|
|
64
62
|
prs: [],
|
|
65
63
|
position: 0,
|
|
@@ -90,12 +88,15 @@ test("hand-edits are forgiven where they're unambiguous", () => {
|
|
|
90
88
|
assert.equal(doc.goal, "launch")
|
|
91
89
|
assert.equal(doc.position, 8)
|
|
92
90
|
|
|
93
|
-
// The pre-0.8 spelling
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
assert.
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
// The pre-0.8 `milestone:` spelling is refused by name (TAS-41: no compat)
|
|
92
|
+
// — reading past it would silently drop the goal, so the error carries the
|
|
93
|
+
// one edit that fixes the file. The lenient parser just ignores it.
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => parseTicket(text.replace("goal: launch", "milestone: launch"), "test"),
|
|
96
|
+
/`milestone:` is the pre-0\.8 spelling.*rename the key to `goal:`/,
|
|
97
|
+
)
|
|
98
|
+
const degraded = parseTicketLenient(text.replace("goal: launch", "milestone: launch"))
|
|
99
|
+
assert.equal(degraded?.goal, null)
|
|
99
100
|
|
|
100
101
|
assert.throws(
|
|
101
102
|
() => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
|
|
@@ -113,50 +114,61 @@ test("FileStore keeps the store's contract", () => {
|
|
|
113
114
|
const a = store.create({ title: "First", description: "alpha", tags: ["api"], status: "todo" })
|
|
114
115
|
const b = store.create({ title: "Second", status: "todo" })
|
|
115
116
|
const c = store.create({ title: "Third", status: "backlog" })
|
|
116
|
-
|
|
117
|
+
// Keys are random, well-formed, and distinct — never sequential (TAS-42).
|
|
118
|
+
for (const t of [a, b, c]) assert.match(t.key, KEY_PATTERN)
|
|
119
|
+
assert.equal(new Set([a.key, b.key, c.key]).size, 3)
|
|
120
|
+
assert.equal(a.id, `SAM-${a.key}`)
|
|
117
121
|
// New tasks land at the bottom of their column.
|
|
118
122
|
assert.ok(b.position > a.position)
|
|
119
123
|
|
|
120
124
|
// Status move without an explicit slot lands on top of the new column.
|
|
121
|
-
const moved = store.update(c.
|
|
125
|
+
const moved = store.update(c.key, { status: "todo" })
|
|
122
126
|
assert.ok(moved.position < a.position)
|
|
123
|
-
assert.equal(store.list({ statuses: ["todo"] })[0].
|
|
127
|
+
assert.equal(store.list({ statuses: ["todo"] })[0].key, c.key)
|
|
124
128
|
|
|
125
129
|
// One relation, two views, stored on one side only.
|
|
126
|
-
store.link(a.
|
|
127
|
-
assert.deepEqual(store.get(b.
|
|
128
|
-
const onDisk = readFileSync(join(root, ".task", "tickets",
|
|
129
|
-
assert.
|
|
130
|
+
store.link(a.key, "blocked_by", b.key)
|
|
131
|
+
assert.deepEqual(store.get(b.key)!.blocks, [a.key])
|
|
132
|
+
const onDisk = readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8")
|
|
133
|
+
assert.ok(onDisk.includes(`blocked_by: ["${b.key}"]`))
|
|
130
134
|
assert.doesNotMatch(
|
|
131
|
-
readFileSync(join(root, ".task", "tickets",
|
|
135
|
+
readFileSync(join(root, ".task", "tickets", b.key, "ticket.md"), "utf8"),
|
|
132
136
|
/blocked_by/,
|
|
133
137
|
)
|
|
134
|
-
store.unlink(b.
|
|
135
|
-
assert.deepEqual(store.get(a.
|
|
136
|
-
|
|
137
|
-
assert.throws(() => store.link(a.
|
|
138
|
-
assert.throws(() => store.update(a.
|
|
138
|
+
store.unlink(b.key, "blocks", a.key)
|
|
139
|
+
assert.deepEqual(store.get(a.key)!.blockedBy, [])
|
|
140
|
+
|
|
141
|
+
assert.throws(() => store.link(a.key, "blocks", a.key), /can't block itself/)
|
|
142
|
+
assert.throws(() => store.update(a.key, { blockedBy: ["zz9zz"] }), /no such task: SAM-zz9zz/)
|
|
143
|
+
|
|
144
|
+
// parseId: exact keys, PREFIX-key, and unique git-style prefixes all land.
|
|
145
|
+
assert.equal(store.parseId(a.key), a.key)
|
|
146
|
+
assert.equal(store.parseId(`SAM-${a.key}`), a.key)
|
|
147
|
+
assert.equal(store.parseId(a.key.toUpperCase()), a.key)
|
|
148
|
+
const uniquePrefix = [a, b, c].every((t) => t.key[0] !== a.key[0] || t.key === a.key)
|
|
149
|
+
? a.key[0]
|
|
150
|
+
: a.key.slice(0, 4)
|
|
151
|
+
if ([a, b, c].filter((t) => t.key.startsWith(uniquePrefix)).length === 1) {
|
|
152
|
+
assert.equal(store.parseId(uniquePrefix), a.key)
|
|
153
|
+
}
|
|
154
|
+
assert.throws(() => store.parseId("not an id!"), /invalid task id/)
|
|
139
155
|
|
|
140
156
|
// Comments: one file each, stable ids, author preserved.
|
|
141
|
-
const comment = store.addComment(a.
|
|
157
|
+
const comment = store.addComment(a.key, "first!\n\nwith **markdown**", "claude")
|
|
142
158
|
assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
|
|
143
|
-
assert.equal(store.comments(a.
|
|
144
|
-
assert.equal(store.commentCounts().get(a.
|
|
145
|
-
const second = store.addComment(a.
|
|
159
|
+
assert.equal(store.comments(a.key)[0].body, "first!\n\nwith **markdown**")
|
|
160
|
+
assert.equal(store.commentCounts().get(a.key), 1)
|
|
161
|
+
const second = store.addComment(a.key, "again", "claude")
|
|
146
162
|
assert.notEqual(second.id, comment.id)
|
|
147
|
-
store.deleteComment(a.
|
|
148
|
-
assert.equal(store.comments(a.
|
|
149
|
-
assert.throws(() => store.deleteComment(a.
|
|
163
|
+
store.deleteComment(a.key, second.id)
|
|
164
|
+
assert.equal(store.comments(a.key).length, 1)
|
|
165
|
+
assert.throws(() => store.deleteComment(a.key, "../../escape"), /no such comment/)
|
|
150
166
|
|
|
151
167
|
// Delete cascades the relation away, like the FK did.
|
|
152
|
-
store.link(b.
|
|
153
|
-
store.delete(a.
|
|
154
|
-
assert.equal(store.get(a.
|
|
155
|
-
assert.deepEqual(store.get(b.
|
|
156
|
-
|
|
157
|
-
// Numbers never move backwards past an existing ticket.
|
|
158
|
-
const d = store.create({ title: "Fourth" })
|
|
159
|
-
assert.equal(d.number, 4)
|
|
168
|
+
store.link(b.key, "blocked_by", a.key)
|
|
169
|
+
store.delete(a.key)
|
|
170
|
+
assert.equal(store.get(a.key), null)
|
|
171
|
+
assert.deepEqual(store.get(b.key)!.blockedBy, [])
|
|
160
172
|
})
|
|
161
173
|
|
|
162
174
|
test("openBoard reads text boards, and refuses a pre-0.6 SQLite board loudly", () => {
|
|
@@ -192,45 +204,48 @@ test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
|
192
204
|
const store = initProject(root, { name: "archive test", prefix: "ARC" })
|
|
193
205
|
const keep = store.create({ title: "Still open", status: "todo" })
|
|
194
206
|
const old = store.create({ title: "Finished work", status: "todo" })
|
|
195
|
-
store.link(keep.
|
|
196
|
-
store.addComment(old.
|
|
207
|
+
store.link(keep.key, "blocked_by", old.key)
|
|
208
|
+
store.addComment(old.key, "how it went", "nick")
|
|
197
209
|
|
|
198
210
|
// Only finished work qualifies — archive is history, not a hiding place.
|
|
199
|
-
assert.throws(() => store.archive(old.
|
|
200
|
-
store.update(old.
|
|
201
|
-
const archived = store.archive(old.
|
|
211
|
+
assert.throws(() => store.archive(old.key), /is todo/)
|
|
212
|
+
store.update(old.key, { status: "done" })
|
|
213
|
+
const archived = store.archive(old.key)
|
|
202
214
|
assert.equal(archived.archived, true)
|
|
203
215
|
|
|
204
216
|
// Off the board and out of every default read…
|
|
205
|
-
assert.deepEqual(store.list({}).map((t) => t.
|
|
206
|
-
assert.equal(store.commentCounts().get(old.
|
|
217
|
+
assert.deepEqual(store.list({}).map((t) => t.key), [keep.key])
|
|
218
|
+
assert.equal(store.commentCounts().get(old.key), undefined)
|
|
207
219
|
// …but reads still reach it, comments included, and its links still show.
|
|
208
|
-
const shown = store.get(old.
|
|
220
|
+
const shown = store.get(old.key)!
|
|
209
221
|
assert.equal(shown.archived, true)
|
|
210
|
-
assert.deepEqual(shown.blocks, [keep.
|
|
211
|
-
assert.equal(store.comments(old.
|
|
222
|
+
assert.deepEqual(shown.blocks, [keep.key])
|
|
223
|
+
assert.equal(store.comments(old.key)[0].body, "how it went")
|
|
212
224
|
const listed = store.list({ archived: true })
|
|
213
|
-
assert.deepEqual(listed.map((t) => [t.
|
|
225
|
+
assert.deepEqual(listed.map((t) => [t.key, t.archived]), [[old.key, true]])
|
|
214
226
|
|
|
215
227
|
// Writes refuse until unarchived, with a message that says what to do.
|
|
216
|
-
assert.throws(() => store.update(old.
|
|
217
|
-
assert.throws(() => store.addComment(old.
|
|
218
|
-
assert.throws(() => store.delete(old.
|
|
219
|
-
assert.throws(() => store.archive(old.
|
|
228
|
+
assert.throws(() => store.update(old.key, { title: "x" }), /archived — run/)
|
|
229
|
+
assert.throws(() => store.addComment(old.key, "x", "y"), /archived — run/)
|
|
230
|
+
assert.throws(() => store.delete(old.key), /archived — run/)
|
|
231
|
+
assert.throws(() => store.archive(old.key), /already archived/)
|
|
220
232
|
// Linking *to* an archived target refuses; pruning a stale link succeeds.
|
|
221
|
-
assert.throws(() => store.link(keep.
|
|
222
|
-
assert.deepEqual(store.update(keep.
|
|
233
|
+
assert.throws(() => store.link(keep.key, "blocked_by", old.key), /archived — run/)
|
|
234
|
+
assert.deepEqual(store.update(keep.key, { blockedBy: [] }).blockedBy, [])
|
|
223
235
|
|
|
224
|
-
//
|
|
225
|
-
|
|
236
|
+
// Keys stay reserved — a new mint can never take an archived ticket's name.
|
|
237
|
+
const next = store.create({ title: "Next" })
|
|
238
|
+
assert.notEqual(next.key, old.key)
|
|
239
|
+
// An archived key still resolves through parseId (exact match).
|
|
240
|
+
assert.equal(store.parseId(`ARC-${old.key}`), old.key)
|
|
226
241
|
|
|
227
242
|
// Round trip: unarchive puts it back exactly as it was.
|
|
228
|
-
const back = store.unarchive(old.
|
|
243
|
+
const back = store.unarchive(old.key)
|
|
229
244
|
assert.equal(back.archived, undefined)
|
|
230
245
|
assert.equal(back.status, "done")
|
|
231
246
|
assert.deepEqual(store.list({ archived: true }), [])
|
|
232
|
-
assert.equal(store.comments(old.
|
|
233
|
-
assert.throws(() => store.unarchive(old.
|
|
247
|
+
assert.equal(store.comments(old.key).length, 1)
|
|
248
|
+
assert.throws(() => store.unarchive(old.key), /isn't archived/)
|
|
234
249
|
})
|
|
235
250
|
|
|
236
251
|
test("goals: titled and described, one per task, progress always derived", () => {
|
|
@@ -251,13 +266,13 @@ test("goals: titled and described, one per task, progress always derived", () =>
|
|
|
251
266
|
assert.throws(() => store.create({ title: "T", goal: "nope" }), /no such goal/)
|
|
252
267
|
const a = store.create({ title: "First", goal: goal.slug })
|
|
253
268
|
const b = store.create({ title: "Second" })
|
|
254
|
-
store.update(b.
|
|
255
|
-
assert.equal(store.get(b.
|
|
256
|
-
assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.
|
|
269
|
+
store.update(b.key, { goal: goal.slug })
|
|
270
|
+
assert.equal(store.get(b.key)!.goal, goal.slug)
|
|
271
|
+
assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.key), [a.key, b.key])
|
|
257
272
|
|
|
258
273
|
// The file is the state: goal ref serialized on the ticket, goal file on disk.
|
|
259
274
|
assert.match(
|
|
260
|
-
readFileSync(join(root, ".task", "tickets",
|
|
275
|
+
readFileSync(join(root, ".task", "tickets", a.key, "ticket.md"), "utf8"),
|
|
261
276
|
/goal: "trust-guardrail-layer"/,
|
|
262
277
|
)
|
|
263
278
|
const goalFile = readFileSync(join(root, ".task", "goals", "trust-guardrail-layer.md"), "utf8")
|
|
@@ -266,26 +281,29 @@ test("goals: titled and described, one per task, progress always derived", () =>
|
|
|
266
281
|
// Update touches the file; clearing a task's goal is `goal: null`.
|
|
267
282
|
store.updateGoal(goal.slug, { description: "Sharper why." })
|
|
268
283
|
assert.equal(store.getGoal(goal.slug)!.description, "Sharper why.")
|
|
269
|
-
store.update(b.
|
|
270
|
-
assert.equal(store.get(b.
|
|
284
|
+
store.update(b.key, { goal: null })
|
|
285
|
+
assert.equal(store.get(b.key)!.goal, null)
|
|
271
286
|
|
|
272
287
|
// Archive refuses while open work references the goal, allows after.
|
|
273
|
-
assert.throws(
|
|
274
|
-
|
|
288
|
+
assert.throws(
|
|
289
|
+
() => store.archiveGoal(goal.slug),
|
|
290
|
+
new RegExp(`still has open tasks \\(GOA-${a.key}\\)`),
|
|
291
|
+
)
|
|
292
|
+
store.update(a.key, { status: "done" })
|
|
275
293
|
const shelved = store.archiveGoal(goal.slug)
|
|
276
294
|
assert.equal(shelved.archived, true)
|
|
277
295
|
assert.deepEqual(store.goals().map((g) => g.slug), [])
|
|
278
296
|
assert.deepEqual(store.goals(true).map((g) => g.slug), [goal.slug])
|
|
279
297
|
// Still resolvable (done tickets keep their ref readable), not assignable.
|
|
280
298
|
assert.equal(store.getGoal(goal.slug)!.title, "Trust & guardrail layer")
|
|
281
|
-
assert.throws(() => store.update(b.
|
|
299
|
+
assert.throws(() => store.update(b.key, { goal: goal.slug }), /is archived — run/)
|
|
282
300
|
assert.throws(() => store.updateGoal(goal.slug, { title: "x" }), /is archived — run/)
|
|
283
301
|
|
|
284
302
|
// Delete needs every live ref cleared first — pruning organization is a
|
|
285
303
|
// human call, so the human makes it.
|
|
286
304
|
store.unarchiveGoal(goal.slug)
|
|
287
|
-
assert.throws(() => store.deleteGoal(goal.slug),
|
|
288
|
-
store.update(a.
|
|
305
|
+
assert.throws(() => store.deleteGoal(goal.slug), new RegExp(`referenced by GOA-${a.key}`))
|
|
306
|
+
store.update(a.key, { goal: null })
|
|
289
307
|
store.deleteGoal(goal.slug)
|
|
290
308
|
assert.equal(store.getGoal(goal.slug), null)
|
|
291
309
|
})
|
|
@@ -312,22 +330,22 @@ test("concurrent-writer shapes merge by construction", () => {
|
|
|
312
330
|
|
|
313
331
|
// Two agents commenting at once = two files; simulate the second writer by
|
|
314
332
|
// dropping its file in directly, the way a git merge would.
|
|
315
|
-
store.addComment(task.
|
|
316
|
-
const commentsDir = join(root, ".task", "tickets",
|
|
333
|
+
store.addComment(task.key, "from session one", "claude")
|
|
334
|
+
const commentsDir = join(root, ".task", "tickets", task.key, "comments")
|
|
317
335
|
writeFileSync(
|
|
318
336
|
join(commentsDir, "2026-08-15T170000-other.md"),
|
|
319
337
|
"---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
|
|
320
338
|
)
|
|
321
|
-
assert.equal(store.comments(task.
|
|
339
|
+
assert.equal(store.comments(task.key).length, 2)
|
|
322
340
|
|
|
323
341
|
// A claim is a one-line diff: exactly one meaningful frontmatter line
|
|
324
342
|
// changes. `updated:` is excluded because it only changes when the update
|
|
325
343
|
// lands in a different millisecond than the create — true except when this
|
|
326
344
|
// test runs fast enough, which is exactly the kind of coin-flip a test must
|
|
327
345
|
// not depend on.
|
|
328
|
-
const path = join(root, ".task", "tickets",
|
|
346
|
+
const path = join(root, ".task", "tickets", task.key, "ticket.md")
|
|
329
347
|
const before = readFileSync(path, "utf8").split("\n")
|
|
330
|
-
store.update(task.
|
|
348
|
+
store.update(task.key, { status: "in_progress", position: task.position })
|
|
331
349
|
const after = readFileSync(path, "utf8").split("\n")
|
|
332
350
|
const changed = before.filter((line, i) => after[i] !== line)
|
|
333
351
|
assert.deepEqual(
|