@dev.fast/whiteboard 0.2.4 → 0.2.5-preview.20261013.109
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/dist/{account-alias-_1_7vROx.js → account-alias-D0hF0sKk.js} +2 -3
- package/dist/{agent-cli-BrFqhnzM.js → agent-cli-BBPxwUOd.js} +6 -6
- package/dist/agent-cli-YSuhE5X8.js +2 -0
- package/dist/{agent-client-B_b1GtUc.js → agent-client-P3J4y8Zn.js} +3 -3
- package/dist/{authoring-tools-DvIO906l.js → authoring-tools-D8WXsm_g.js} +11 -10
- package/dist/build-info.json +1 -1
- package/dist/{cli-gRGxkwc0.js → cli-C0ziP6hg.js} +1 -1
- package/dist/{cli-runner-Cjl8h376.js → cli-runner-C1gY-HAI.js} +16 -17
- package/dist/cli.js +1 -1
- package/dist/{client-BFClfQLO.js → client-DzcSt6pV.js} +63 -13
- package/dist/{desktop-discovery-C-Qz54wO.js → desktop-discovery-Ba3dD5Cn.js} +1 -1
- package/dist/{headless-host-DcWaxx9P.js → headless-host-BHD_Vg5F.js} +4 -4
- package/dist/{local-data-Bm84Xdck.js → local-data-BlERtVm2.js} +281 -75
- package/dist/{mcp-BDy9Aybd.js → mcp-t7VN9R3k.js} +4 -4
- package/dist/{package-paths-B6-zxvIO.js → package-paths-B9PDoAl6.js} +1 -1
- package/dist/{process-error-telemetry-Dzh5a3s_.js → process-error-telemetry-C0swByfp.js} +158 -57
- package/dist/{profile-RDEioF8b.js → profile-BsYDBr7t.js} +2 -2
- package/dist/profile-j97aRTS7.js +2 -0
- package/dist/{review-agent-traces-DNJFYzc8.js → review-home-paths-Y9mYWkCI.js} +2600 -62
- package/dist/{review-telemetry-DBy3eVCo.js → review-telemetry-CmM6tCbj.js} +2 -2
- package/dist/server/desktop-host.js +35 -46
- package/dist/{server-discovery-Dbzn3w6Z.js → server-discovery-ZVi8Ay8J.js} +1 -1
- package/dist/sharing/index.d.ts +37 -49
- package/dist/sharing/index.js +1 -1
- package/dist/{src-CmdiBL20.js → src-DiHP11l3.js} +11 -1
- package/dist/{stored-document-migration-BxZWQQjv.js → stored-document-migration-u4L_1VZa.js} +15 -36
- package/dist/{tool-failure-DJmTieh5.js → tool-failure-CJHBCHMh.js} +2 -2
- package/instructions/scratchpad.md +1 -1
- package/package.json +5 -4
- package/src/ask/agents.ts +6 -0
- package/src/ask/thread-state.ts +17 -1
- package/src/ask/thread.ts +116 -22
- package/src/ask/threads.ts +8 -2
- package/src/error-telemetry.ts +13 -9
- package/src/fixtures/blocks/tutorial.json +3 -40
- package/src/review-api/README.md +10 -7
- package/src/review-api/ask-history.ts +37 -0
- package/src/review-api/authoring-tools.ts +8 -5
- package/src/review-api/blocks/database_lens.ts +5 -0
- package/src/review-api/blocks/tutorial.ts +2 -18
- package/src/review-api/comparison-coverage.ts +16 -9
- package/src/review-api/component-reference.ts +1 -1
- package/src/review-api/document.ts +24 -10
- package/src/review-api/http.ts +267 -189
- package/src/review-api/local-data.ts +126 -37
- package/src/review-api/store-schema.ts +25 -1
- package/src/review-api/store.ts +165 -33
- package/src/review-api/workspaces.ts +14 -14
- package/src/review-api/worktree-events.ts +46 -0
- package/src/review-app.ts +1 -1
- package/src/review-info.ts +1 -1
- package/src/review-stack.ts +2 -2
- package/src/server/review-server-core.ts +25 -1
- package/src/server/tutorial-service.ts +22 -46
- package/src/sharing/export.ts +1 -0
- package/src/sharing/import.ts +111 -26
- package/tutorial/document.json +27 -86
- package/tutorial/lenses.json +10 -0
- package/tutorial/runtime-manifest.json +2 -3
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +16 -0
- package/tutorial/sample-service/src/database/schema.ts +9 -10
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +4 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +18 -1
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +3 -2
- package/tutorial/sample-service/src/orders/order.ts +27 -5
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +4 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +19 -2
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +9 -0
- package/dist/agent-cli-BR2j-2n5.js +0 -2
- package/dist/input-error-OLgB_h31.js +0 -11
- package/dist/profile-BQGaX8aX.js +0 -2
- package/dist/review-home-paths-6zZH-1c9.js +0 -2560
- package/dist/tutorial-trace-DrBvkaeL.js +0 -43
- package/src/tutorial-conversation.ts +0 -18
- package/tutorial/software-map.json +0 -139
- package/tutorial/trace.json +0 -20
|
@@ -103,27 +103,27 @@ export class ReviewWorkspaces {
|
|
|
103
103
|
|
|
104
104
|
this.stop = store.subscribeCatalog(() => {
|
|
105
105
|
this.collect();
|
|
106
|
-
this.
|
|
106
|
+
this.releaseTrashed();
|
|
107
107
|
});
|
|
108
108
|
this.collect();
|
|
109
|
-
this.
|
|
109
|
+
this.releaseTrashed();
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Unset until the first scan, so startup also frees reviews
|
|
112
|
+
// Unset until the first scan, so startup also frees reviews trashed
|
|
113
113
|
// while no Desktop was running.
|
|
114
|
-
private
|
|
114
|
+
private trashed?: Set<string>;
|
|
115
115
|
private readonly releasing = new Map<string, Promise<void>>();
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Frees a newly
|
|
118
|
+
* Frees a newly trashed review's checkouts to save disk. The review itself
|
|
119
119
|
* stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
|
|
120
|
-
*
|
|
120
|
+
* trashed review are kept until the next startup or move to Trash, since
|
|
121
121
|
* the Desktop's canvas tabs and source windows may still show them.
|
|
122
122
|
*/
|
|
123
|
-
private
|
|
124
|
-
const
|
|
125
|
-
const released = [...
|
|
126
|
-
this.
|
|
123
|
+
private releaseTrashed() {
|
|
124
|
+
const trashed = new Set(this.store.trashedIds());
|
|
125
|
+
const released = [...trashed].filter((id) => !this.trashed?.has(id));
|
|
126
|
+
this.trashed = trashed;
|
|
127
127
|
|
|
128
128
|
if (!released.length) return;
|
|
129
129
|
|
|
@@ -162,7 +162,7 @@ export class ReviewWorkspaces {
|
|
|
162
162
|
})
|
|
163
163
|
.catch((error) => {
|
|
164
164
|
console.error(
|
|
165
|
-
`Could not free the pinned checkouts of
|
|
165
|
+
`Could not free the pinned checkouts of trashed review ${reviewId}:`,
|
|
166
166
|
error,
|
|
167
167
|
);
|
|
168
168
|
})
|
|
@@ -176,8 +176,8 @@ export class ReviewWorkspaces {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
private async releaseCheckouts(reviewId: string, repositories: string[]) {
|
|
179
|
-
// Restored since the
|
|
180
|
-
if (!this.store.
|
|
179
|
+
// Restored since the move to Trash: its checkouts may be in use.
|
|
180
|
+
if (!this.store.trashedIds().includes(reviewId)) return;
|
|
181
181
|
|
|
182
182
|
const environments = this.all().filter(
|
|
183
183
|
(item) => item.reviewId === reviewId,
|
|
@@ -363,7 +363,7 @@ export class ReviewWorkspaces {
|
|
|
363
363
|
if (!environment || environment.state !== "cleanup-failed")
|
|
364
364
|
throw new ReviewInputError("Cleanup failure not found.", 404);
|
|
365
365
|
|
|
366
|
-
// A
|
|
366
|
+
// A trashed review keeps its record, so collection would skip it.
|
|
367
367
|
if (this.store.has(environment.reviewId))
|
|
368
368
|
this.release(environment.reviewId);
|
|
369
369
|
else this.collect(id);
|
|
@@ -37,10 +37,56 @@ export function rootEventMatters(
|
|
|
37
37
|
return !tracked || tracked.has(parent(file));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** A directory appearing at the edge of the tracked tree may be newly ignored. */
|
|
41
|
+
export function atTrackedEdge(
|
|
42
|
+
name: string,
|
|
43
|
+
tracked: Set<string> | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
const file = normalize(name);
|
|
46
|
+
|
|
47
|
+
return !!tracked && tracked.has(parent(file)) && !tracked.has(file);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wholly ignored directories, so watchers can skip them. */
|
|
51
|
+
export async function ignoredDirectories(
|
|
52
|
+
rootPath: string,
|
|
53
|
+
gitDirectory?: string,
|
|
54
|
+
): Promise<string[]> {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await exec(
|
|
57
|
+
"git",
|
|
58
|
+
[
|
|
59
|
+
"-C",
|
|
60
|
+
rootPath,
|
|
61
|
+
...(gitDirectory
|
|
62
|
+
? ["--git-dir", gitDirectory, "--work-tree", rootPath]
|
|
63
|
+
: []),
|
|
64
|
+
"ls-files",
|
|
65
|
+
"-z",
|
|
66
|
+
"--others",
|
|
67
|
+
"--ignored",
|
|
68
|
+
"--exclude-standard",
|
|
69
|
+
"--directory",
|
|
70
|
+
],
|
|
71
|
+
{ maxBuffer: 32 * 1024 * 1024 },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return stdout
|
|
75
|
+
.split("\0")
|
|
76
|
+
.filter((entry) => entry.endsWith("/") && entry !== ".jj/")
|
|
77
|
+
.map((entry) => entry.slice(0, -1));
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
// A checkout's own state, per gitrepository-layout(5).
|
|
41
84
|
const CHECKOUT_STATE =
|
|
42
85
|
/^(?:index|[A-Z_]+|(?:rebase-merge|rebase-apply|sequencer|refs\/(?:bisect|worktree|rewritten))(?:\/.*)?)$/;
|
|
43
86
|
|
|
87
|
+
/** Objects, reflogs, LFS objects and Review's own checkouts; never watched. */
|
|
88
|
+
export const INERT_GIT_PATHS = ["objects", "logs", "lfs", "dev-fast"];
|
|
89
|
+
|
|
44
90
|
/** Objects, reflogs, locks and other checkouts' state cannot change this one. */
|
|
45
91
|
export function gitEventMatters(name: string, prefix: string): boolean {
|
|
46
92
|
const file = normalize(name);
|
package/src/review-app.ts
CHANGED
|
@@ -100,7 +100,7 @@ export async function runReviewAppPick(
|
|
|
100
100
|
const root = await runtime.resolveReviewRoot(input.cwd);
|
|
101
101
|
|
|
102
102
|
const reviews = (await client.read<ReviewApiSummary[]>(""))
|
|
103
|
-
.filter((review) => review.repositoryPath === root && !review.
|
|
103
|
+
.filter((review) => review.repositoryPath === root && !review.trashedAt)
|
|
104
104
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
105
105
|
|
|
106
106
|
if (!reviews.length) throw new ReviewAppStateError("No review to show.");
|
package/src/review-info.ts
CHANGED
package/src/review-stack.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function resolveReviewStackLayers(
|
|
|
26
26
|
subject: Pick<ReviewApiSummary, "origin"> & { reviewId?: string },
|
|
27
27
|
reviews: readonly Pick<
|
|
28
28
|
ReviewApiSummary,
|
|
29
|
-
"reviewId" | "title" | "origin" | "pins" | "
|
|
29
|
+
"reviewId" | "title" | "origin" | "pins" | "trashedAt"
|
|
30
30
|
>[],
|
|
31
31
|
runGitHubApi: RunGitHubApi = defaultRunGitHubApi,
|
|
32
32
|
): Promise<ReviewStackLayer[]> {
|
|
@@ -79,7 +79,7 @@ export async function resolveReviewStackLayers(
|
|
|
79
79
|
|
|
80
80
|
const review =
|
|
81
81
|
matches.find((candidate) => candidate.reviewId === subject.reviewId) ??
|
|
82
|
-
matches.findLast((candidate) => !candidate.
|
|
82
|
+
matches.findLast((candidate) => !candidate.trashedAt) ??
|
|
83
83
|
matches.at(-1);
|
|
84
84
|
|
|
85
85
|
return {
|
|
@@ -142,6 +142,21 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
|
|
|
142
142
|
const askThreads =
|
|
143
143
|
input.ask && new AskThreads(launchAskAgent, input.ask.tools);
|
|
144
144
|
|
|
145
|
+
const closeDeletedThreads = () =>
|
|
146
|
+
askThreads?.closeDeletedReviews((id) =>
|
|
147
|
+
id.startsWith("shared-")
|
|
148
|
+
? !shared?.has(id) || Boolean(shared.get(id).snapshot.trashedAt)
|
|
149
|
+
: !store.has(id) || Boolean(store.read(id).trashedAt),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const stopLocal =
|
|
153
|
+
askThreads &&
|
|
154
|
+
store.subscribe((result) => {
|
|
155
|
+
if (result.deleted || result.lifecycle) closeDeletedThreads();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const stopShared = askThreads && shared?.subscribe(closeDeletedThreads);
|
|
159
|
+
|
|
145
160
|
const api = createReviewApi(
|
|
146
161
|
store,
|
|
147
162
|
data,
|
|
@@ -158,7 +173,16 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
|
|
|
158
173
|
// A shared store mounts the publisher with the rest of sharing.
|
|
159
174
|
if (!shared) mountSharingPublisher(api, store, data);
|
|
160
175
|
|
|
161
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
app,
|
|
178
|
+
api,
|
|
179
|
+
close: () => {
|
|
180
|
+
stopLocal?.();
|
|
181
|
+
stopShared?.();
|
|
182
|
+
closeDeletedThreads();
|
|
183
|
+
askThreads?.closeAll();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
162
186
|
}
|
|
163
187
|
|
|
164
188
|
/** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
|
|
@@ -4,10 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { resolveRevision } from "@dev.fast/local-vcs";
|
|
6
6
|
import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
resourceReferences,
|
|
10
|
-
} from "@review/review-api/document";
|
|
7
|
+
import { lensSchema } from "@review/review-api/diff-lenses";
|
|
8
|
+
import { documentSchema } from "@review/review-api/document";
|
|
11
9
|
import type { LocalReviewData } from "@review/review-api/local-data";
|
|
12
10
|
import type { ReviewStore, Snapshot } from "@review/review-api/store";
|
|
13
11
|
import { devReviewHome } from "@review/review-home-paths";
|
|
@@ -18,6 +16,8 @@ const authoredSchema = z.strictObject({
|
|
|
18
16
|
document: documentSchema,
|
|
19
17
|
});
|
|
20
18
|
|
|
19
|
+
const lensesSchema = z.array(lensSchema.omit({ id: true }));
|
|
20
|
+
|
|
21
21
|
const pinsSchema = z.strictObject({
|
|
22
22
|
base: z.string().regex(/^[a-f0-9]{40}$/),
|
|
23
23
|
head: z.string().regex(/^[a-f0-9]{40}$/),
|
|
@@ -30,12 +30,7 @@ const stampSchema = z.object({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
export async function readTutorialAssets(assetsRoot: string) {
|
|
33
|
-
const names = [
|
|
34
|
-
"document.json",
|
|
35
|
-
"trace.json",
|
|
36
|
-
"software-map.json",
|
|
37
|
-
"pins.json",
|
|
38
|
-
];
|
|
33
|
+
const names = ["document.json", "lenses.json", "pins.json"];
|
|
39
34
|
|
|
40
35
|
const bytes = await Promise.all(
|
|
41
36
|
names.map((name) => readFile(path.join(assetsRoot, name), "utf8")),
|
|
@@ -43,9 +38,8 @@ export async function readTutorialAssets(assetsRoot: string) {
|
|
|
43
38
|
|
|
44
39
|
return {
|
|
45
40
|
authored: authoredSchema.parse(JSON.parse(bytes[0]!)),
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
pins: pinsSchema.parse(JSON.parse(bytes[3]!)),
|
|
41
|
+
lenses: lensesSchema.parse(JSON.parse(bytes[1]!)),
|
|
42
|
+
pins: pinsSchema.parse(JSON.parse(bytes[2]!)),
|
|
49
43
|
contentHash: createHash("sha256")
|
|
50
44
|
.update(JSON.stringify(bytes))
|
|
51
45
|
.digest("hex"),
|
|
@@ -68,38 +62,6 @@ export async function createNativeTutorial(input: {
|
|
|
68
62
|
assets.pins.head,
|
|
69
63
|
);
|
|
70
64
|
|
|
71
|
-
const aliases = new Map<string, string>();
|
|
72
|
-
const traceId = randomUUID();
|
|
73
|
-
await input.data.upload({
|
|
74
|
-
id: traceId,
|
|
75
|
-
repositoryId: repository.id,
|
|
76
|
-
kind: "trace",
|
|
77
|
-
trace: assets.trace,
|
|
78
|
-
});
|
|
79
|
-
aliases.set("tutorial-trace", traceId);
|
|
80
|
-
|
|
81
|
-
for (const side of ["base", "head"] as const) {
|
|
82
|
-
const id = randomUUID();
|
|
83
|
-
await input.data.upload({
|
|
84
|
-
id,
|
|
85
|
-
repositoryId: repository.id,
|
|
86
|
-
kind: "map",
|
|
87
|
-
pins,
|
|
88
|
-
side,
|
|
89
|
-
model: assets.model,
|
|
90
|
-
});
|
|
91
|
-
aliases.set(`tutorial-map-${side}`, id);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
for (const block of resourceReferences(assets.authored.document)) {
|
|
95
|
-
if (block.type === "trace_quote")
|
|
96
|
-
block.traceId = aliases.get(block.traceId) ?? block.traceId;
|
|
97
|
-
|
|
98
|
-
if (block.type === "software_map")
|
|
99
|
-
block.mapVersionId =
|
|
100
|
-
aliases.get(block.mapVersionId) ?? block.mapVersionId;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
65
|
const result = await input.store.execute(
|
|
104
66
|
{
|
|
105
67
|
operation: {
|
|
@@ -111,6 +73,15 @@ export async function createNativeTutorial(input: {
|
|
|
111
73
|
{ document: assets.authored.document, origin: { tutorial: true } },
|
|
112
74
|
);
|
|
113
75
|
|
|
76
|
+
for (const { title, targets } of assets.lenses)
|
|
77
|
+
await input.store.execute({
|
|
78
|
+
operation: {
|
|
79
|
+
type: "lens_edit",
|
|
80
|
+
reviewId: result.reviewId,
|
|
81
|
+
edit: { type: "insert", title, targets },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
114
85
|
return {
|
|
115
86
|
snapshot: input.store.read(result.reviewId),
|
|
116
87
|
contentHash: assets.contentHash,
|
|
@@ -163,10 +134,15 @@ export function createTutorialService(input: {
|
|
|
163
134
|
}
|
|
164
135
|
|
|
165
136
|
async function cleanup() {
|
|
166
|
-
for (const reviewId of input.store.tutorialIds())
|
|
137
|
+
for (const reviewId of input.store.tutorialIds()) {
|
|
138
|
+
if (!input.store.read(reviewId).trashedAt)
|
|
139
|
+
await input.store.execute({
|
|
140
|
+
operation: { type: "lifecycle", state: "trash", reviewId },
|
|
141
|
+
});
|
|
167
142
|
await input.store.execute({
|
|
168
143
|
operation: { type: "delete", reviewId },
|
|
169
144
|
});
|
|
145
|
+
}
|
|
170
146
|
|
|
171
147
|
// Windows cannot delete a checkout while its Git reader or watchers
|
|
172
148
|
// hold it open, and handles can outlive their close briefly.
|
package/src/sharing/export.ts
CHANGED
package/src/sharing/import.ts
CHANGED
|
@@ -251,10 +251,12 @@ export class SharedReviewStore {
|
|
|
251
251
|
>();
|
|
252
252
|
private readonly attention = new Map<
|
|
253
253
|
string,
|
|
254
|
-
{ viewedAt: string | null;
|
|
254
|
+
{ viewedAt: string | null; trashedAt: string | null }
|
|
255
255
|
>();
|
|
256
256
|
private readonly listeners = new Set<() => void>();
|
|
257
257
|
private catalogLoaded = false;
|
|
258
|
+
private readonly purgeTimer: ReturnType<typeof setInterval>;
|
|
259
|
+
private purgePending: Promise<void> | undefined;
|
|
258
260
|
private readonly downloads = new Set<Promise<void>>();
|
|
259
261
|
|
|
260
262
|
trackImport(job: Promise<void>) {
|
|
@@ -263,6 +265,8 @@ export class SharedReviewStore {
|
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
async close() {
|
|
268
|
+
clearInterval(this.purgeTimer);
|
|
269
|
+
await this.purgePending;
|
|
266
270
|
await Promise.allSettled([...this.downloads, ...this.jobs.values()]);
|
|
267
271
|
}
|
|
268
272
|
|
|
@@ -280,10 +284,38 @@ export class SharedReviewStore {
|
|
|
280
284
|
constructor(
|
|
281
285
|
readonly root: string,
|
|
282
286
|
private readonly fetchRepository = fetchPinnedRepository,
|
|
283
|
-
) {
|
|
287
|
+
) {
|
|
288
|
+
this.purgeTimer = setInterval(() => {
|
|
289
|
+
void this.purgeExpired().catch((error) =>
|
|
290
|
+
console.error("Could not purge expired shared whiteboards:", error),
|
|
291
|
+
);
|
|
292
|
+
}, 60_000);
|
|
293
|
+
this.purgeTimer.unref();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private purgeExpired(): Promise<void> {
|
|
297
|
+
if (this.purgePending) return this.purgePending;
|
|
298
|
+
|
|
299
|
+
const expired = [...this.attention].filter(
|
|
300
|
+
([, state]) =>
|
|
301
|
+
state.trashedAt &&
|
|
302
|
+
Date.parse(state.trashedAt) <= Date.now() - 30 * 86400000,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const pending = (async () => {
|
|
306
|
+
for (const [id] of expired) await this.removeLocal(id);
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
this.purgePending = pending.finally(() => {
|
|
310
|
+
this.purgePending = undefined;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return this.purgePending;
|
|
314
|
+
}
|
|
284
315
|
|
|
285
316
|
connect(store: ReviewStore, data: LocalReviewData) {
|
|
286
317
|
this.local = { store, data };
|
|
318
|
+
store.askHistory.setExternalWriteGuard((id) => this.assertWritable(id));
|
|
287
319
|
data.workspaces.attachExternalReviews({
|
|
288
320
|
has: (id) =>
|
|
289
321
|
this.has(id) || (!this.catalogLoaded && id.startsWith("shared-")),
|
|
@@ -517,33 +549,58 @@ export class SharedReviewStore {
|
|
|
517
549
|
"Reopen the share link to fetch its repository.",
|
|
518
550
|
);
|
|
519
551
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
552
|
+
const attentionPath = path.join(this.root, id, "attention.json");
|
|
553
|
+
|
|
554
|
+
if (existsSync(attentionPath)) {
|
|
555
|
+
const raw = JSON.parse(await readFile(attentionPath, "utf8"));
|
|
556
|
+
|
|
557
|
+
const migrated =
|
|
558
|
+
Object.hasOwn(raw, "dismissedAt") || Object.hasOwn(raw, "deletedAt");
|
|
559
|
+
|
|
560
|
+
const state = z
|
|
561
|
+
.strictObject({
|
|
562
|
+
viewedAt: z.string().nullable(),
|
|
563
|
+
trashedAt: z.string().nullable(),
|
|
564
|
+
})
|
|
565
|
+
.parse(
|
|
566
|
+
migrated
|
|
567
|
+
? {
|
|
568
|
+
viewedAt: raw.viewedAt,
|
|
569
|
+
trashedAt: Object.hasOwn(raw, "deletedAt")
|
|
570
|
+
? raw.deletedAt
|
|
571
|
+
: raw.dismissedAt
|
|
572
|
+
? new Date().toISOString()
|
|
573
|
+
: null,
|
|
574
|
+
}
|
|
575
|
+
: raw,
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
this.attention.set(id, state);
|
|
579
|
+
|
|
580
|
+
if (migrated)
|
|
581
|
+
await writeFile(attentionPath, JSON.stringify(state), {
|
|
582
|
+
mode: 0o600,
|
|
583
|
+
});
|
|
539
584
|
}
|
|
540
585
|
}
|
|
541
586
|
|
|
587
|
+
await this.purgeExpired();
|
|
588
|
+
|
|
542
589
|
this.catalogLoaded = true;
|
|
543
590
|
|
|
544
591
|
for (const listener of this.listeners) listener();
|
|
545
592
|
}
|
|
546
593
|
|
|
594
|
+
assertWritable(id: string) {
|
|
595
|
+
const trashedAt = this.attention.get(id)?.trashedAt;
|
|
596
|
+
|
|
597
|
+
if (trashedAt)
|
|
598
|
+
throw new ReviewInputError(
|
|
599
|
+
`This whiteboard was moved to Trash on ${new Date(trashedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}. Restore it with session_lifecycle({"sessionId":"${id}","state":"active"}) to continue.`,
|
|
600
|
+
409,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
547
604
|
get(id: string) {
|
|
548
605
|
const bundle = this.loaded.get(id);
|
|
549
606
|
|
|
@@ -569,6 +626,7 @@ export class SharedReviewStore {
|
|
|
569
626
|
snapshot: {
|
|
570
627
|
...validated.snapshot,
|
|
571
628
|
reviewId: id,
|
|
629
|
+
trashedAt: this.attention.get(id)?.trashedAt ?? null,
|
|
572
630
|
pins: validated.snapshot.pins
|
|
573
631
|
? { ...validated.snapshot.pins, repositoryId: repositoryId! }
|
|
574
632
|
: undefined,
|
|
@@ -605,7 +663,7 @@ export class SharedReviewStore {
|
|
|
605
663
|
snapshot.version === 0 ? snapshot.createdAt : undefined,
|
|
606
664
|
repositoryName: "Shared review",
|
|
607
665
|
viewedAt: this.attention.get(id)?.viewedAt ?? null,
|
|
608
|
-
|
|
666
|
+
trashedAt: this.attention.get(id)?.trashedAt ?? null,
|
|
609
667
|
};
|
|
610
668
|
})
|
|
611
669
|
.toArray();
|
|
@@ -649,6 +707,7 @@ export class SharedReviewStore {
|
|
|
649
707
|
this.repositories.delete(id);
|
|
650
708
|
this.states.delete(id);
|
|
651
709
|
await rm(path.join(this.root, id), { recursive: true, force: true });
|
|
710
|
+
this.local?.store.askHistory.removeReview(id);
|
|
652
711
|
this.loaded.delete(id);
|
|
653
712
|
this.validated.delete(id);
|
|
654
713
|
this.attention.delete(id);
|
|
@@ -656,18 +715,43 @@ export class SharedReviewStore {
|
|
|
656
715
|
for (const listener of this.listeners) listener();
|
|
657
716
|
}
|
|
658
717
|
|
|
659
|
-
async setAttention(id: string
|
|
718
|
+
async setAttention(id: string) {
|
|
719
|
+
await this.updateAttention(id, "view");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async setLifecycle(id: string, state: "active" | "trash") {
|
|
723
|
+
await this.updateAttention(id, state);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private async updateAttention(
|
|
727
|
+
id: string,
|
|
728
|
+
action: "view" | "active" | "trash",
|
|
729
|
+
) {
|
|
660
730
|
this.get(id);
|
|
661
731
|
|
|
732
|
+
const trashedAt = this.attention.get(id)?.trashedAt;
|
|
733
|
+
|
|
734
|
+
if (trashedAt && Date.parse(trashedAt) <= Date.now() - 30 * 86400000) {
|
|
735
|
+
await this.removeLocal(id);
|
|
736
|
+
throw new ReviewInputError(
|
|
737
|
+
"The 30-day recovery period has ended. This whiteboard was permanently deleted.",
|
|
738
|
+
404,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (action === "view") this.assertWritable(id);
|
|
743
|
+
|
|
662
744
|
const state = this.attention.get(id) ?? {
|
|
663
745
|
viewedAt: null,
|
|
664
|
-
|
|
746
|
+
trashedAt: null,
|
|
665
747
|
};
|
|
666
748
|
|
|
667
749
|
if (action === "view") state.viewedAt = new Date().toISOString();
|
|
668
750
|
else
|
|
669
|
-
state.
|
|
670
|
-
action === "
|
|
751
|
+
state.trashedAt =
|
|
752
|
+
action === "trash"
|
|
753
|
+
? (state.trashedAt ?? new Date().toISOString())
|
|
754
|
+
: null;
|
|
671
755
|
await writeFile(
|
|
672
756
|
path.join(this.root, id, "attention.json"),
|
|
673
757
|
JSON.stringify(state),
|
|
@@ -681,6 +765,7 @@ export class SharedReviewStore {
|
|
|
681
765
|
async import(origin: string, shareId: string, bundle: ShareBundle) {
|
|
682
766
|
const validated = validateShareBundle(bundle);
|
|
683
767
|
const id = sharedReviewId(origin, shareId);
|
|
768
|
+
this.assertWritable(id);
|
|
684
769
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
685
770
|
const staging = await mkdtemp(path.join(this.root, ".import-"));
|
|
686
771
|
|