@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
package/lib/board/rules.ts
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
cardRef,
|
|
32
32
|
DEFAULT_PRIORITY,
|
|
33
33
|
dependsOf,
|
|
34
|
+
MAX_LOG,
|
|
34
35
|
MAX_NOTES,
|
|
36
|
+
normalizeBlockedBy,
|
|
35
37
|
normalizeNote,
|
|
36
38
|
normalizePathHints,
|
|
37
39
|
normalizePlanPath,
|
|
@@ -57,7 +59,13 @@ export type CardOp =
|
|
|
57
59
|
*/
|
|
58
60
|
| { kind: "assign"; to: Actor; note?: unknown }
|
|
59
61
|
| { kind: "release" }
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Change lane. `blockedBy` says what a block waits on, and `landed` says which commit
|
|
64
|
+
* closed a card: both are read only by the lane they belong to, and both are optional,
|
|
65
|
+
* because a card may be blocked with nothing recorded and a `done` card may predate the
|
|
66
|
+
* field or live outside a repository.
|
|
67
|
+
*/
|
|
68
|
+
| { kind: "move"; status: Exclude<CardStatus, "todo">; blockedBy?: unknown; landed?: { commit: string } }
|
|
61
69
|
// `text` and `path` are whatever the model sent: `card.ts` validates them, and the
|
|
62
70
|
// refusal it produces is the one the caller shows.
|
|
63
71
|
| { kind: "note"; text: unknown }
|
|
@@ -66,6 +74,16 @@ export type CardOp =
|
|
|
66
74
|
// sends the three it can show, and omitting a link must not clear it.
|
|
67
75
|
| { kind: "edit"; title: unknown; paths: unknown; priority: unknown; dependsOn: unknown }
|
|
68
76
|
| { kind: "reclaim" }
|
|
77
|
+
/**
|
|
78
|
+
* Record that the card was offered to the team: `team offer` broadcasts it on the bus
|
|
79
|
+
* and then writes this, because an event nobody answers otherwise leaves no trace.
|
|
80
|
+
*/
|
|
81
|
+
| { kind: "offer" }
|
|
82
|
+
/**
|
|
83
|
+
* Set a finished card aside: the store moves its file out of the board, and the record
|
|
84
|
+
* itself is unchanged, because the pile its file lies in is the whole of the state.
|
|
85
|
+
*/
|
|
86
|
+
| { kind: "archive" }
|
|
69
87
|
| { kind: "drop" };
|
|
70
88
|
|
|
71
89
|
/** What an operation did: the new card, or the reason it was refused. */
|
|
@@ -89,6 +107,10 @@ export function createCard(
|
|
|
89
107
|
priority: input.priority ?? DEFAULT_PRIORITY,
|
|
90
108
|
notes: input.note === undefined ? [] : [{ by: actor.label, at: now, text: input.note }],
|
|
91
109
|
notesDropped: 0,
|
|
110
|
+
// A card is born in `todo`, and that is a transition like any other: a reader asking
|
|
111
|
+
// "how long has this been waiting" gets an answer rather than an absence.
|
|
112
|
+
log: [{ status: "todo", at: now, by: actor.label }],
|
|
113
|
+
logDropped: 0,
|
|
92
114
|
rev: 1,
|
|
93
115
|
createdAt: now,
|
|
94
116
|
updatedAt: now,
|
|
@@ -125,7 +147,7 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
|
|
|
125
147
|
case "release":
|
|
126
148
|
return release(card, actor, now);
|
|
127
149
|
case "move":
|
|
128
|
-
return move(card, op
|
|
150
|
+
return move(card, op, actor, now, board);
|
|
129
151
|
case "note":
|
|
130
152
|
return note(card, op.text, actor, now);
|
|
131
153
|
case "plan":
|
|
@@ -134,6 +156,10 @@ export function applyOp(card: Card, op: CardOp, actor: Actor, now: number, board
|
|
|
134
156
|
return edit(card, op, actor, now, board);
|
|
135
157
|
case "reclaim":
|
|
136
158
|
return reclaim(card, actor, now);
|
|
159
|
+
case "offer":
|
|
160
|
+
return offer(card, actor, now, board);
|
|
161
|
+
case "archive":
|
|
162
|
+
return archive(card);
|
|
137
163
|
case "drop":
|
|
138
164
|
return drop(card, actor, board);
|
|
139
165
|
}
|
|
@@ -145,7 +171,11 @@ function claim(card: Card, actor: Actor, now: number, board: BoardIndex): OpResu
|
|
|
145
171
|
if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
|
|
146
172
|
const waiting = waitingOn(card, board);
|
|
147
173
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
148
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
card: write(card, actor, now, { status: "doing", owner: ownerOf(actor), offeredAt: undefined }),
|
|
177
|
+
changed: true,
|
|
178
|
+
};
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
@@ -154,7 +184,7 @@ function assign(card: Card, to: Actor, rawNote: unknown, actor: Actor, now: numb
|
|
|
154
184
|
if (holds(card, to) && card.status === "doing") return { ok: true, card, changed: false };
|
|
155
185
|
const waiting = waitingOn(card, board);
|
|
156
186
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
157
|
-
const fields: Partial<Card> = { status: "doing", owner: ownerOf(to) };
|
|
187
|
+
const fields: Partial<Card> = { status: "doing", owner: ownerOf(to), offeredAt: undefined };
|
|
158
188
|
if (rawNote !== undefined) {
|
|
159
189
|
const parsed = normalizeNote(rawNote);
|
|
160
190
|
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
@@ -172,23 +202,66 @@ function release(card: Card, actor: Actor, now: number): OpResult {
|
|
|
172
202
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be released.` };
|
|
173
203
|
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} is not claimed.` };
|
|
174
204
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Release is the owner's decision.` };
|
|
175
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
|
|
208
|
+
changed: true,
|
|
209
|
+
};
|
|
176
210
|
}
|
|
177
211
|
|
|
178
|
-
function move(
|
|
212
|
+
function move(
|
|
213
|
+
card: Card,
|
|
214
|
+
op: Extract<CardOp, { kind: "move" }>,
|
|
215
|
+
actor: Actor,
|
|
216
|
+
now: number,
|
|
217
|
+
board: BoardIndex,
|
|
218
|
+
): OpResult {
|
|
219
|
+
const status = op.status;
|
|
179
220
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be moved.` };
|
|
180
221
|
if (card.owner === undefined) {
|
|
181
222
|
return { ok: false, error: `${cardRef(card.id)} is unclaimed; claim it before moving it to ${status}.` };
|
|
182
223
|
}
|
|
183
224
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner can move it.` };
|
|
184
|
-
|
|
225
|
+
// A block's reason is checked before the no-op: a caller that repeats a move with a new
|
|
226
|
+
// sentence meant to change it, and a refusal is clearer than a silent nothing.
|
|
227
|
+
let blockedBy: string | undefined;
|
|
228
|
+
if (status === "blocked" && op.blockedBy !== undefined) {
|
|
229
|
+
const parsed = normalizeBlockedBy(op.blockedBy);
|
|
230
|
+
if ("error" in parsed) return { ok: false, error: parsed.error };
|
|
231
|
+
blockedBy = parsed.blockedBy;
|
|
232
|
+
}
|
|
233
|
+
if (card.status === status && blockedBy === undefined) return { ok: true, card, changed: false };
|
|
185
234
|
// Parking is what a link is for, so only the two lanes that put a card back in flight
|
|
186
235
|
// are gated: a card that waits may be blocked, it may not be taken or finished.
|
|
187
236
|
if (status !== "blocked") {
|
|
188
237
|
const waiting = waitingOn(card, board);
|
|
189
238
|
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
190
239
|
}
|
|
191
|
-
return { ok: true, card: write(card, actor, now,
|
|
240
|
+
return { ok: true, card: write(card, actor, now, laneFields(status, blockedBy, op, now)), changed: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The fields one lane change writes.
|
|
245
|
+
*
|
|
246
|
+
* `blockedBy` belongs to `blocked` alone: a card that leaves for `doing` or `done` has
|
|
247
|
+
* had its decision or stopped needing one, and a stale sentence would be read as a
|
|
248
|
+
* current one. `landed` belongs to `done` alone, and only when the caller could name a
|
|
249
|
+
* commit — a board outside git still finishes cards, it just cannot say with what.
|
|
250
|
+
*/
|
|
251
|
+
function laneFields(
|
|
252
|
+
status: Exclude<CardStatus, "todo">,
|
|
253
|
+
blockedBy: string | undefined,
|
|
254
|
+
op: Extract<CardOp, { kind: "move" }>,
|
|
255
|
+
now: number,
|
|
256
|
+
): Partial<Card> {
|
|
257
|
+
const fields: Partial<Card> = { status };
|
|
258
|
+
if (status === "blocked") {
|
|
259
|
+
if (blockedBy !== undefined) fields.blockedBy = blockedBy;
|
|
260
|
+
} else {
|
|
261
|
+
fields.blockedBy = undefined;
|
|
262
|
+
}
|
|
263
|
+
if (status === "done" && op.landed !== undefined) fields.landed = { commit: op.landed.commit, at: now };
|
|
264
|
+
return fields;
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
function note(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
@@ -207,6 +280,16 @@ function appendNote(card: Card, actor: Actor, now: number, text: string): Pick<C
|
|
|
207
280
|
};
|
|
208
281
|
}
|
|
209
282
|
|
|
283
|
+
/** A card's log after one more transition, with the oldest dropped and counted. */
|
|
284
|
+
function appendTransition(card: Card, status: CardStatus, actor: Actor, now: number): Pick<Card, "log" | "logDropped"> {
|
|
285
|
+
const log = [...(card.log ?? []), { status, at: now, by: actor.label }];
|
|
286
|
+
const overflow = Math.max(0, log.length - MAX_LOG);
|
|
287
|
+
return {
|
|
288
|
+
log: overflow === 0 ? log : log.slice(overflow),
|
|
289
|
+
logDropped: (card.logDropped ?? 0) + overflow,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
210
293
|
function plan(card: Card, raw: unknown, actor: Actor, now: number): OpResult {
|
|
211
294
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; its plan cannot change.` };
|
|
212
295
|
if (!holds(card, actor)) return { ok: false, error: `${heldBy(card)} Only its owner records a plan.` };
|
|
@@ -262,10 +345,45 @@ function edit(
|
|
|
262
345
|
return { ok: true, card: write(card, actor, now, fields), changed: true };
|
|
263
346
|
}
|
|
264
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Record that the card was offered to the team, and nobody has taken it.
|
|
350
|
+
*
|
|
351
|
+
* The same preconditions a claim would meet, because the offer asks a peer to claim: a
|
|
352
|
+
* held card, a finished one, or one waiting on unfinished work is refused before anything
|
|
353
|
+
* is broadcast. Re-offering refreshes the age rather than refusing as a no-op — a lead
|
|
354
|
+
* who offers again is asking again, and the age is the record of when they last did.
|
|
355
|
+
*/
|
|
356
|
+
function offer(card: Card, actor: Actor, now: number, board: BoardIndex): OpResult {
|
|
357
|
+
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; it cannot be offered.` };
|
|
358
|
+
if (card.owner !== undefined) return { ok: false, error: heldBy(card) };
|
|
359
|
+
const waiting = waitingOn(card, board);
|
|
360
|
+
if (waiting !== undefined) return { ok: false, error: waiting };
|
|
361
|
+
return { ok: true, card: write(card, actor, now, { offeredAt: now }), changed: true };
|
|
362
|
+
}
|
|
363
|
+
|
|
265
364
|
function reclaim(card: Card, actor: Actor, now: number): OpResult {
|
|
266
365
|
if (card.status === "done") return { ok: false, error: `${cardRef(card.id)} is done; there is nothing to reclaim.` };
|
|
267
366
|
if (card.owner === undefined) return { ok: false, error: `${cardRef(card.id)} has no owner to reclaim.` };
|
|
268
|
-
return {
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
card: write(card, actor, now, { status: "todo", owner: undefined, offeredAt: undefined }),
|
|
370
|
+
changed: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Set a finished card aside, out of the board and into the archive beside it.
|
|
376
|
+
*
|
|
377
|
+
* Only a finished card leaves this way: `done` is terminal, and a card still in flight
|
|
378
|
+
* has an owner, a plan, and a decision in it. No field moves and no transition is
|
|
379
|
+
* appended — the record is unchanged, and what changed is which pile its file lies in,
|
|
380
|
+
* which is the store's write. `changed` is still true, because the board lost a card.
|
|
381
|
+
*/
|
|
382
|
+
function archive(card: Card): OpResult {
|
|
383
|
+
if (card.status !== "done") {
|
|
384
|
+
return { ok: false, error: `${cardRef(card.id)} is in ${card.status}; only a finished card can be archived.` };
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, card, changed: true };
|
|
269
387
|
}
|
|
270
388
|
|
|
271
389
|
function drop(card: Card, actor: Actor, board: BoardIndex): OpResult {
|
|
@@ -283,13 +401,28 @@ function sameList(left: readonly string[], right: readonly string[]): boolean {
|
|
|
283
401
|
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
|
284
402
|
}
|
|
285
403
|
|
|
286
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* A card with `fields` applied and its write marks moved.
|
|
406
|
+
*
|
|
407
|
+
* This is the one place a lane change is observed, because it is the one place every
|
|
408
|
+
* transition ends: a note, a plan, or an edit writes fields and leaves the lane alone,
|
|
409
|
+
* while a claim, an assign, a release, a reclaim, and a move all name the new one. So
|
|
410
|
+
* the log is appended from here rather than from each operation, and an operation added
|
|
411
|
+
* later cannot forget it.
|
|
412
|
+
*/
|
|
287
413
|
function write(card: Card, actor: Actor, now: number, fields: Partial<Card>): Card {
|
|
288
|
-
const
|
|
414
|
+
const status = fields.status;
|
|
415
|
+
const transition =
|
|
416
|
+
status !== undefined && status !== card.status ? appendTransition(card, status, actor, now) : undefined;
|
|
417
|
+
const next: Card = { ...card, ...fields, ...transition, rev: card.rev + 1, updatedAt: now, by: actor.label };
|
|
289
418
|
// `owner: undefined` must clear the field rather than store a key holding it, so a
|
|
290
419
|
// released card reads back exactly like one that was never claimed. Only an
|
|
291
420
|
// explicit `owner` in `fields` may delete it: every other write keeps the holder.
|
|
292
421
|
if ("owner" in fields && fields.owner === undefined) delete next.owner;
|
|
422
|
+
// The same for the block: a card that left `blocked` has no reason, and a key holding
|
|
423
|
+
// undefined would be one more shape for a reader to know about.
|
|
424
|
+
if ("blockedBy" in fields && fields.blockedBy === undefined) delete next.blockedBy;
|
|
425
|
+
if ("offeredAt" in fields && fields.offeredAt === undefined) delete next.offeredAt;
|
|
293
426
|
return next;
|
|
294
427
|
}
|
|
295
428
|
|
package/lib/board/store.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* a board is tens of files, so the read is cheaper than the second source of truth a
|
|
21
21
|
* cached index would be.
|
|
22
22
|
*
|
|
23
|
-
* Nothing prunes.
|
|
24
|
-
* `
|
|
23
|
+
* Nothing prunes by itself. `cards/` is the board and `archive/` is where a finished card
|
|
24
|
+
* is set aside by `archive` — its file moved rather than rewritten, so the record is
|
|
25
|
+
* unchanged and the board stops paying for it — and `ended/` keeps the record of a `drop`
|
|
26
|
+
* alone. The page shows the store's size, and `drop` is the only deletion.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
@@ -31,7 +33,16 @@ import { resolveEffectiveCwd } from "../env.ts";
|
|
|
31
33
|
import { hasErrorCode, messageOf } from "../errors.ts";
|
|
32
34
|
import { shortenPath } from "../format.ts";
|
|
33
35
|
import { repoKeyFor } from "../repo-key.ts";
|
|
34
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type Card,
|
|
38
|
+
CARD_STATUSES,
|
|
39
|
+
CARD_VERSION,
|
|
40
|
+
cardRef,
|
|
41
|
+
type EndedCard,
|
|
42
|
+
isCard,
|
|
43
|
+
type Priority,
|
|
44
|
+
priorityRank,
|
|
45
|
+
} from "./card.ts";
|
|
35
46
|
import { indexOf } from "./links.ts";
|
|
36
47
|
import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
|
|
37
48
|
|
|
@@ -41,6 +52,17 @@ export const BOARD_DIR_NAME = "board";
|
|
|
41
52
|
/** The subdirectory one board's cards live in. */
|
|
42
53
|
export const CARDS_DIR_NAME = "cards";
|
|
43
54
|
|
|
55
|
+
/** The subdirectory a dropped card leaves its record in. */
|
|
56
|
+
export const ENDED_DIR_NAME = "ended";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The subdirectory a finished card is set aside in.
|
|
60
|
+
*
|
|
61
|
+
* A second pile, not a second state: the file is moved here unchanged, so its notes, plan,
|
|
62
|
+
* and landed commit stay readable, and the board's own reads stop opening it.
|
|
63
|
+
*/
|
|
64
|
+
export const ARCHIVE_DIR_NAME = "archive";
|
|
65
|
+
|
|
44
66
|
/** Card files are the user's work; only their owner reads them. */
|
|
45
67
|
const FILE_MODE = 0o600;
|
|
46
68
|
|
|
@@ -64,25 +86,52 @@ export interface CreateInput {
|
|
|
64
86
|
|
|
65
87
|
/** What a mutation did, or why it was refused. */
|
|
66
88
|
export type MutateResult =
|
|
67
|
-
| { ok: true; card: Card; changed: boolean; dropped: boolean }
|
|
89
|
+
| { ok: true; card: Card; changed: boolean; dropped: boolean; archived: boolean }
|
|
68
90
|
| { ok: false; error: string; card?: Card };
|
|
69
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A card file the reader refused, and why.
|
|
94
|
+
*
|
|
95
|
+
* The lanes skip these, so without this the board reads as if they were never there:
|
|
96
|
+
* "nobody has planned anything" instead of "this build cannot read your work".
|
|
97
|
+
*/
|
|
98
|
+
export interface Skip {
|
|
99
|
+
/** The file name, e.g. `0042.json`. */
|
|
100
|
+
file: string;
|
|
101
|
+
/** The id the file name spells, when it spells one, so a caller can match a refusal. */
|
|
102
|
+
id?: string;
|
|
103
|
+
/** What is wrong, phrased for a reader: `bad JSON`, `version 2`. */
|
|
104
|
+
why: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
/** One repository's board, as the runtime, the router, and the tool use it. */
|
|
71
108
|
export interface BoardStore {
|
|
72
109
|
/** The board's own directory, for `where` and for a refusal that names it. */
|
|
73
110
|
readonly dir: string;
|
|
74
111
|
/** Create the directories and sweep a temp file a dead process left. */
|
|
75
112
|
ensure(): Promise<void>;
|
|
76
|
-
/** Every card, in lane order: todo, doing, blocked, done. */
|
|
113
|
+
/** Every card on the board, in lane order: todo, doing, blocked, done. */
|
|
77
114
|
list(): Promise<Card[]>;
|
|
78
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* The cards set aside in the archive, in the same order, and the files there this build
|
|
117
|
+
* refused. Read only when a caller asks: no other read opens the pile.
|
|
118
|
+
*/
|
|
119
|
+
archived(): Promise<{ cards: Card[]; skipped: Skip[] }>;
|
|
120
|
+
/** How many card files the archive holds, without opening one: the count a listing prints. */
|
|
121
|
+
archivedCount(): Promise<number>;
|
|
122
|
+
/** One card by its unpadded id, from whichever pile its file lies in. */
|
|
79
123
|
read(id: string): Promise<Card | undefined>;
|
|
80
124
|
/** Add a card, allocating the next id. */
|
|
81
125
|
create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
|
|
82
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Apply one operation, re-checking it if the card changed while it was read. A card is
|
|
128
|
+
* found in whichever pile it lies in, and written back to that one.
|
|
129
|
+
*/
|
|
83
130
|
mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
|
|
84
|
-
/** Bytes the
|
|
131
|
+
/** Bytes the board's cards occupy on disk; the archive is not counted. */
|
|
85
132
|
bytes(): Promise<number>;
|
|
133
|
+
/** Card files this build refused to read, and why. */
|
|
134
|
+
skipped(): Promise<Skip[]>;
|
|
86
135
|
/** A short, user-facing name for the directory. */
|
|
87
136
|
where(): string;
|
|
88
137
|
}
|
|
@@ -109,31 +158,64 @@ export async function openBoard(
|
|
|
109
158
|
/** Build a store over one directory. Exported for tests and for a second backend. */
|
|
110
159
|
export function createStore(dir: string): BoardStore {
|
|
111
160
|
const cardsDir = join(dir, CARDS_DIR_NAME);
|
|
161
|
+
const endedDir = join(dir, ENDED_DIR_NAME);
|
|
162
|
+
const archiveDir = join(dir, ARCHIVE_DIR_NAME);
|
|
112
163
|
|
|
113
164
|
const ensure = async (): Promise<void> => {
|
|
114
165
|
await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
|
|
115
166
|
await sweepTemps(cardsDir);
|
|
167
|
+
// An archived card that was edited leaves a temp file in its own pile, and a process
|
|
168
|
+
// that died mid-write there is the same mess as one on the board.
|
|
169
|
+
await sweepTemps(archiveDir);
|
|
116
170
|
};
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
/** Every card file of one pile, read once: what it shows and what it refused. */
|
|
173
|
+
const scan = async (pileDir: string): Promise<{ cards: Card[]; skipped: Skip[] }> => {
|
|
174
|
+
const entries = await readdirSafe(pileDir);
|
|
120
175
|
const cards: Card[] = [];
|
|
176
|
+
const skipped: Skip[] = [];
|
|
121
177
|
for (const entry of entries) {
|
|
122
|
-
const
|
|
123
|
-
if (card) cards.push(card);
|
|
178
|
+
const read = await readCardFile(pileDir, entry);
|
|
179
|
+
if ("card" in read) cards.push(read.card);
|
|
180
|
+
// Anything that is not a card-named file is not the board's to explain.
|
|
181
|
+
else if (entry.endsWith(".json")) skipped.push(read.skip);
|
|
124
182
|
}
|
|
125
|
-
|
|
183
|
+
// Sorted by file name so two reads of one board say the same thing in the same order.
|
|
184
|
+
return { cards: sortForLane(cards), skipped: skipped.sort((a, b) => a.file.localeCompare(b.file)) };
|
|
126
185
|
};
|
|
127
186
|
|
|
128
|
-
const
|
|
129
|
-
|
|
187
|
+
const list = async (): Promise<Card[]> => (await scan(cardsDir)).cards;
|
|
188
|
+
|
|
189
|
+
const archived = async (): Promise<{ cards: Card[]; skipped: Skip[] }> => scan(archiveDir);
|
|
190
|
+
|
|
191
|
+
// A directory listing and a name pattern, with no file opened: a plain `list` prints this
|
|
192
|
+
// count so the archive it left out cannot read as history that was deleted.
|
|
193
|
+
const archivedCount = async (): Promise<number> =>
|
|
194
|
+
(await readdirSafe(archiveDir)).filter((entry) => CARD_FILE.test(entry)).length;
|
|
195
|
+
|
|
196
|
+
const skipped = async (): Promise<Skip[]> => (await scan(cardsDir)).skipped;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One card and the pile its file is in, the board first.
|
|
200
|
+
*
|
|
201
|
+
* A card is where it lies: the archive is not a state a record carries, so every read by
|
|
202
|
+
* id has to look in both places, and every write goes back to the one it was read from.
|
|
203
|
+
*/
|
|
204
|
+
const locate = async (id: string): Promise<{ card: Card; pile: string } | undefined> => {
|
|
205
|
+
for (const pile of [cardsDir, archiveDir]) {
|
|
206
|
+
const result = await readCardFile(pile, fileNameFor(id));
|
|
207
|
+
if ("card" in result) return { card: result.card, pile };
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
130
210
|
};
|
|
131
211
|
|
|
212
|
+
const read = async (id: string): Promise<Card | undefined> => (await locate(id))?.card;
|
|
213
|
+
|
|
132
214
|
const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
|
|
133
215
|
await ensure();
|
|
134
216
|
let lastError: unknown;
|
|
135
217
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
136
|
-
const id = await nextCardId(cardsDir);
|
|
218
|
+
const id = await nextCardId(cardsDir, endedDir, archiveDir);
|
|
137
219
|
const card = createCard(id, input, actor, now);
|
|
138
220
|
const temp = tempPath(cardsDir);
|
|
139
221
|
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
@@ -155,22 +237,44 @@ export function createStore(dir: string): BoardStore {
|
|
|
155
237
|
const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
|
|
156
238
|
let changedUnderneath = false;
|
|
157
239
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
240
|
+
const found = await locate(id);
|
|
241
|
+
if (!found) {
|
|
242
|
+
if (changedUnderneath) return { ok: false, error: `${cardRef(id)} was dropped.` };
|
|
243
|
+
// A file that is there and refused is not a card that does not exist: the
|
|
244
|
+
// reader is told which file to look at instead of being sent to add one.
|
|
245
|
+
const skip = (await scan(cardsDir)).skipped.find((entry) => entry.id === id);
|
|
160
246
|
return {
|
|
161
247
|
ok: false,
|
|
162
|
-
error:
|
|
248
|
+
error:
|
|
249
|
+
skip === undefined
|
|
250
|
+
? `${cardRef(id)} does not exist.`
|
|
251
|
+
: `${cardRef(id)} is there and this build cannot read it (${skip.file}: ${skip.why}).`,
|
|
163
252
|
};
|
|
164
253
|
}
|
|
254
|
+
const { card, pile } = found;
|
|
165
255
|
// The preconditions are checked against the card as it is *now*, and against the
|
|
166
256
|
// board as it is now, so an operation re-applied to a card that changed is refused
|
|
167
257
|
// rather than forced.
|
|
168
258
|
const result = applyOp(card, op, actor, now, indexOf(await list()));
|
|
169
259
|
if (!result.ok) return { ok: false, error: result.error, card };
|
|
170
|
-
if (!result.changed)
|
|
260
|
+
if (!result.changed) {
|
|
261
|
+
return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
|
|
262
|
+
}
|
|
171
263
|
if (op.kind === "drop") {
|
|
172
|
-
|
|
173
|
-
|
|
264
|
+
// The record lands first: a crash between the two leaves a card that still
|
|
265
|
+
// exists with a note explaining it, never a card that is gone unexplained.
|
|
266
|
+
await writeEnded(endedDir, result.card, actor, now);
|
|
267
|
+
await rm(join(pile, fileNameFor(id)), { force: true });
|
|
268
|
+
return { ok: true, card: result.card, changed: true, dropped: true, archived: false };
|
|
269
|
+
}
|
|
270
|
+
if (op.kind === "archive") {
|
|
271
|
+
// A card the board does not hold is already aside, and two sessions asking at
|
|
272
|
+
// once are one move rather than a second one.
|
|
273
|
+
if (pile === archiveDir) {
|
|
274
|
+
return { ok: true, card: result.card, changed: false, dropped: false, archived: false };
|
|
275
|
+
}
|
|
276
|
+
await moveCard(cardsDir, archiveDir, id);
|
|
277
|
+
return { ok: true, card: result.card, changed: true, dropped: false, archived: true };
|
|
174
278
|
}
|
|
175
279
|
// Last look before the rename: a writer that landed in between is the
|
|
176
280
|
// re-read the next pass applies against.
|
|
@@ -179,8 +283,8 @@ export function createStore(dir: string): BoardStore {
|
|
|
179
283
|
changedUnderneath = true;
|
|
180
284
|
continue;
|
|
181
285
|
}
|
|
182
|
-
await writeCardFile(
|
|
183
|
-
return { ok: true, card: result.card, changed: true, dropped: false };
|
|
286
|
+
await writeCardFile(pile, id, result.card);
|
|
287
|
+
return { ok: true, card: result.card, changed: true, dropped: false, archived: false };
|
|
184
288
|
}
|
|
185
289
|
return {
|
|
186
290
|
ok: false,
|
|
@@ -200,7 +304,19 @@ export function createStore(dir: string): BoardStore {
|
|
|
200
304
|
return total;
|
|
201
305
|
};
|
|
202
306
|
|
|
203
|
-
return {
|
|
307
|
+
return {
|
|
308
|
+
dir,
|
|
309
|
+
ensure,
|
|
310
|
+
list,
|
|
311
|
+
archived,
|
|
312
|
+
archivedCount,
|
|
313
|
+
read,
|
|
314
|
+
create,
|
|
315
|
+
mutate,
|
|
316
|
+
bytes,
|
|
317
|
+
skipped,
|
|
318
|
+
where: () => shortenPath(dir),
|
|
319
|
+
};
|
|
204
320
|
}
|
|
205
321
|
|
|
206
322
|
/** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
|
|
@@ -211,58 +327,129 @@ function fileNameFor(id: string): string {
|
|
|
211
327
|
/** The card files the reader accepts. */
|
|
212
328
|
const CARD_FILE = /^(\d{4,})\.json$/;
|
|
213
329
|
|
|
214
|
-
/** One card file, or
|
|
215
|
-
async function readCardFile(
|
|
330
|
+
/** One card file: the card, or the reason this build refused it. */
|
|
331
|
+
async function readCardFile(pileDir: string, entry: string): Promise<{ card: Card } | { skip: Skip }> {
|
|
216
332
|
const match = CARD_FILE.exec(entry);
|
|
217
|
-
if (!match) return
|
|
218
|
-
const
|
|
219
|
-
|
|
333
|
+
if (!match) return { skip: { file: entry, why: "not a card id" } };
|
|
334
|
+
const id = String(Number(match[1]));
|
|
335
|
+
const text = await readFile(join(pileDir, entry), "utf-8").catch(() => undefined);
|
|
336
|
+
if (text === undefined) return { skip: { file: entry, id, why: "cannot be read" } };
|
|
220
337
|
let parsed: unknown;
|
|
221
338
|
try {
|
|
222
339
|
parsed = JSON.parse(text);
|
|
223
340
|
} catch {
|
|
224
|
-
return
|
|
341
|
+
return { skip: { file: entry, id, why: "bad JSON" } };
|
|
342
|
+
}
|
|
343
|
+
// A version above this build's is the one refusal a reader can act on: the file was
|
|
344
|
+
// written by a newer build, so the board is not empty, it is not readable here.
|
|
345
|
+
const version = typeof parsed === "object" && parsed !== null ? (parsed as { v?: unknown }).v : undefined;
|
|
346
|
+
if (typeof version === "number" && version !== CARD_VERSION) {
|
|
347
|
+
return { skip: { file: entry, id, why: `version ${version}` } };
|
|
225
348
|
}
|
|
226
|
-
if (!isCard(parsed)) return
|
|
349
|
+
if (!isCard(parsed)) return { skip: { file: entry, id, why: "not a card record" } };
|
|
227
350
|
// The file name is the id's canonical spelling: a padded id inside the record
|
|
228
351
|
// (or a record moved under another name) is a disagreement, not a card.
|
|
229
|
-
if (parsed.id !== String(Number(match[1])))
|
|
230
|
-
|
|
352
|
+
if (parsed.id !== String(Number(match[1]))) {
|
|
353
|
+
return { skip: { file: entry, id, why: `names card ${parsed.id}` } };
|
|
354
|
+
}
|
|
355
|
+
return { card: parsed };
|
|
231
356
|
}
|
|
232
357
|
|
|
233
|
-
/**
|
|
234
|
-
async function
|
|
235
|
-
const entries = await readdirSafe(
|
|
358
|
+
/** The highest id a directory holds, or 0 when it holds none. */
|
|
359
|
+
async function highestId(dir: string): Promise<number> {
|
|
360
|
+
const entries = await readdirSafe(dir);
|
|
236
361
|
let highest = 0;
|
|
237
362
|
for (const entry of entries) {
|
|
238
363
|
const match = CARD_FILE.exec(entry);
|
|
239
364
|
if (match) highest = Math.max(highest, Number(match[1]));
|
|
240
365
|
}
|
|
241
|
-
return
|
|
366
|
+
return highest;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* `1`, `2`, `3` … as the highest id the board has ever used plus one.
|
|
371
|
+
*
|
|
372
|
+
* Ended and archived cards count: a dropped card's record keeps its id spent, and an
|
|
373
|
+
* archived card's file is still named by it, so a note or a handoff that names `#29`
|
|
374
|
+
* cannot come to mean a different feature tomorrow.
|
|
375
|
+
*/
|
|
376
|
+
async function nextCardId(cardsDir: string, endedDir: string, archiveDir: string): Promise<string> {
|
|
377
|
+
return String(Math.max(await highestId(cardsDir), await highestId(endedDir), await highestId(archiveDir)) + 1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Write the record of a card that has ended.
|
|
382
|
+
*
|
|
383
|
+
* Exclusive like a create, so the id ends once: a retry that finds the record already
|
|
384
|
+
* there is the same end, not a second one.
|
|
385
|
+
*/
|
|
386
|
+
async function writeEnded(endedDir: string, card: Card, actor: Actor, now: number): Promise<void> {
|
|
387
|
+
await mkdir(endedDir, { recursive: true, mode: DIR_MODE });
|
|
388
|
+
const ended: EndedCard = {
|
|
389
|
+
v: 1,
|
|
390
|
+
id: card.id,
|
|
391
|
+
title: card.title,
|
|
392
|
+
status: card.status,
|
|
393
|
+
by: actor.label,
|
|
394
|
+
...(card.owner === undefined ? {} : { owner: card.owner }),
|
|
395
|
+
at: now,
|
|
396
|
+
why: "dropped",
|
|
397
|
+
};
|
|
398
|
+
const temp = tempPath(endedDir);
|
|
399
|
+
await writeFile(temp, serialize(ended), { mode: FILE_MODE });
|
|
400
|
+
try {
|
|
401
|
+
await link(temp, join(endedDir, fileNameFor(card.id)));
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (!hasErrorCode(error, "EEXIST")) throw error;
|
|
404
|
+
} finally {
|
|
405
|
+
await rm(temp, { force: true });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Move one card's file into the archive.
|
|
411
|
+
*
|
|
412
|
+
* A rename, not a write: the record does not change, and the pile it lies in is the whole
|
|
413
|
+
* of what an archive means.
|
|
414
|
+
*/
|
|
415
|
+
async function moveCard(from: string, to: string, id: string): Promise<void> {
|
|
416
|
+
await mkdir(to, { recursive: true, mode: DIR_MODE });
|
|
417
|
+
await renameInto(join(from, fileNameFor(id)), join(to, fileNameFor(id)));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Rename one file over or onto another, with the fallback this platform needs.
|
|
422
|
+
*
|
|
423
|
+
* Windows refuses a rename onto an open file, so removing the target first is the only
|
|
424
|
+
* way through there; every caller has already read what it is replacing, which is what
|
|
425
|
+
* bounds what the removal can lose.
|
|
426
|
+
*/
|
|
427
|
+
async function renameInto(source: string, target: string): Promise<void> {
|
|
428
|
+
try {
|
|
429
|
+
await rename(source, target);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) throw error;
|
|
432
|
+
await rm(target, { force: true });
|
|
433
|
+
await rename(source, target);
|
|
434
|
+
}
|
|
242
435
|
}
|
|
243
436
|
|
|
244
437
|
/** Write one card: a temp file in the same directory, then a rename over the old one. */
|
|
245
|
-
async function writeCardFile(
|
|
246
|
-
const temp = tempPath(
|
|
438
|
+
async function writeCardFile(dir: string, id: string, card: Card): Promise<void> {
|
|
439
|
+
const temp = tempPath(dir);
|
|
247
440
|
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
248
|
-
const target = join(cardsDir, fileNameFor(id));
|
|
249
441
|
try {
|
|
250
|
-
await
|
|
442
|
+
await renameInto(temp, join(dir, fileNameFor(id)));
|
|
251
443
|
} catch (error) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) {
|
|
255
|
-
await rm(temp, { force: true });
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
await rm(target, { force: true });
|
|
259
|
-
await rename(temp, target);
|
|
444
|
+
await rm(temp, { force: true });
|
|
445
|
+
throw error;
|
|
260
446
|
}
|
|
261
447
|
}
|
|
262
448
|
|
|
263
449
|
/** One card's bytes, ending in a newline so the file reads well in an editor. */
|
|
264
|
-
|
|
265
|
-
|
|
450
|
+
/** One record as the bytes a file holds: tab-indented JSON with a closing newline. */
|
|
451
|
+
function serialize(record: Card | EndedCard): string {
|
|
452
|
+
return `${JSON.stringify(record, null, "\t")}\n`;
|
|
266
453
|
}
|
|
267
454
|
|
|
268
455
|
/** A temp path in the cards directory: never matched by the reader's own pattern. */
|