@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
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/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
|
|
7
|
+
import { git, gitCommonDir } from "@dev.fast/local-vcs";
|
|
8
|
+
import { errorMessage, processIsAlive } from "@dev.fast/trace-core";
|
|
9
|
+
import { reviewManagedCheckoutRoot } from "@review/review-checkout-paths.js";
|
|
10
|
+
import {
|
|
11
|
+
ensureReviewPinnedCheckout,
|
|
12
|
+
removeReviewManagedCheckouts,
|
|
13
|
+
} from "@review/review-head-checkout.js";
|
|
14
|
+
import {
|
|
15
|
+
markerMatches,
|
|
16
|
+
prepareReviewPinnedCheckout,
|
|
17
|
+
removeReviewPrepareArtifacts,
|
|
18
|
+
reviewPrepareCommandsHash,
|
|
19
|
+
reviewPrepareLogPath,
|
|
20
|
+
reviewPrepareMarkerPath,
|
|
21
|
+
} from "@review/review-prepare.js";
|
|
22
|
+
|
|
23
|
+
import { type Pins, ReviewInputError } from "./document.js";
|
|
24
|
+
import type { ReviewStore } from "./store.js";
|
|
25
|
+
|
|
26
|
+
export interface WorkspaceStatus {
|
|
27
|
+
id: string;
|
|
28
|
+
commit: string;
|
|
29
|
+
rootPath: string | null;
|
|
30
|
+
generation: string;
|
|
31
|
+
state:
|
|
32
|
+
| "pending"
|
|
33
|
+
| "preparing"
|
|
34
|
+
| "ready"
|
|
35
|
+
| "unconfigured"
|
|
36
|
+
| "failed"
|
|
37
|
+
| "cleanup-failed";
|
|
38
|
+
log: string;
|
|
39
|
+
/** Acquisition failed; this can be transient. Optional setup failures are not issues. */
|
|
40
|
+
issue?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Environment extends WorkspaceStatus {
|
|
44
|
+
reviewId: string;
|
|
45
|
+
repositoryId: string;
|
|
46
|
+
repository: string;
|
|
47
|
+
commandsHash: string;
|
|
48
|
+
role: "base" | "head";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A lease lasts until the owning Desktop exits; closing the review does not release it.
|
|
52
|
+
const OWNED_ELSEWHERE =
|
|
53
|
+
"Another Desktop owns this review's language workspaces. Quit that Desktop, then retry.";
|
|
54
|
+
|
|
55
|
+
/** Local lifecycle only: source and authored history never depend on preparation.
|
|
56
|
+
* Status/queue/process lifecycle follows #334, retaining the legacy prepare config
|
|
57
|
+
* and per-review managed checkout layout instead of a new settings system.
|
|
58
|
+
*/
|
|
59
|
+
export class ReviewWorkspaces {
|
|
60
|
+
private readonly db: DatabaseSync;
|
|
61
|
+
private readonly requests = new Map<string, Promise<WorkspaceStatus>>();
|
|
62
|
+
private readonly jobs = new Map<
|
|
63
|
+
string,
|
|
64
|
+
{ done: Promise<void>; abort: AbortController }
|
|
65
|
+
>();
|
|
66
|
+
private readonly stop: () => void;
|
|
67
|
+
private cleanup: Promise<void> = Promise.resolve();
|
|
68
|
+
private closed = false;
|
|
69
|
+
private readonly ownerId = randomUUID();
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
databasePath: string,
|
|
73
|
+
private readonly store: ReviewStore,
|
|
74
|
+
) {
|
|
75
|
+
this.db = new DatabaseSync(databasePath, { timeout: 5000 });
|
|
76
|
+
this.db.exec(
|
|
77
|
+
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS pinned_environments(id TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
|
78
|
+
);
|
|
79
|
+
// Desktops sharing a profile each own the reviews they prepare; the
|
|
80
|
+
// lease stops a second process from preparing or collecting them. The
|
|
81
|
+
// profile-wide lock it replaces only ever shipped in preview builds.
|
|
82
|
+
this.db.exec(
|
|
83
|
+
"DROP TABLE IF EXISTS workspace_owner; CREATE TABLE IF NOT EXISTS workspace_leases(review_id TEXT PRIMARY KEY,owner TEXT NOT NULL,pid INTEGER NOT NULL)",
|
|
84
|
+
);
|
|
85
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Only the owning Desktop can invalidate generations or recover interrupted preparation.
|
|
89
|
+
for (const environment of this.all()) {
|
|
90
|
+
if (this.leasedElsewhere(environment.reviewId)) continue;
|
|
91
|
+
|
|
92
|
+
if (environment.state === "preparing") environment.state = "pending";
|
|
93
|
+
environment.generation = randomUUID();
|
|
94
|
+
this.save(environment);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.db.exec("COMMIT");
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.db.exec("ROLLBACK");
|
|
100
|
+
this.db.close();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.stop = store.subscribeCatalog(() => {
|
|
105
|
+
this.collect();
|
|
106
|
+
this.releaseDismissed();
|
|
107
|
+
});
|
|
108
|
+
this.collect();
|
|
109
|
+
this.releaseDismissed();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unset until the first scan, so startup also frees reviews dismissed
|
|
113
|
+
// while no Desktop was running.
|
|
114
|
+
private dismissed?: Set<string>;
|
|
115
|
+
private readonly releasing = new Map<string, Promise<void>>();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Frees a newly dismissed review's checkouts to save disk. The review itself
|
|
119
|
+
* stays; reopening it rebuilds them. Checkouts rebuilt by peeking at a
|
|
120
|
+
* dismissed review are kept until the next startup or dismissal, since
|
|
121
|
+
* the Desktop's canvas tabs and source windows may still show them.
|
|
122
|
+
*/
|
|
123
|
+
private releaseDismissed() {
|
|
124
|
+
const dismissed = new Set(this.store.dismissedIds());
|
|
125
|
+
const released = [...dismissed].filter((id) => !this.dismissed?.has(id));
|
|
126
|
+
this.dismissed = dismissed;
|
|
127
|
+
|
|
128
|
+
if (!released.length) return;
|
|
129
|
+
|
|
130
|
+
const repositories = this.repositoryDirs();
|
|
131
|
+
// Each release awaits this only after earlier cleanup and logs its failure.
|
|
132
|
+
repositories.catch(() => undefined);
|
|
133
|
+
|
|
134
|
+
for (const reviewId of released) this.release(reviewId, repositories);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async repositoryDirs(): Promise<string[]> {
|
|
138
|
+
const dirs = new Set(this.all().map((item) => item.repository));
|
|
139
|
+
|
|
140
|
+
for (const { path: root } of this.store.repositories()) {
|
|
141
|
+
const common = await gitCommonDir(root).catch(() => null);
|
|
142
|
+
|
|
143
|
+
if (common) dirs.add(common);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return [...dirs].filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolves once no release of the review is pending, so an ensure cannot race it. */
|
|
150
|
+
released(reviewId: string): Promise<void> {
|
|
151
|
+
return this.releasing.get(reviewId) ?? Promise.resolve();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private release(reviewId: string, repositories = this.repositoryDirs()) {
|
|
155
|
+
// Requests started later wait for this release; earlier ones finish first.
|
|
156
|
+
const requests = [...this.requests.values()];
|
|
157
|
+
|
|
158
|
+
const done = this.cleanup
|
|
159
|
+
.then(async () => {
|
|
160
|
+
await Promise.allSettled(requests);
|
|
161
|
+
await this.releaseCheckouts(reviewId, await repositories);
|
|
162
|
+
})
|
|
163
|
+
.catch((error) => {
|
|
164
|
+
console.error(
|
|
165
|
+
`Could not free the pinned checkouts of dismissed review ${reviewId}:`,
|
|
166
|
+
error,
|
|
167
|
+
);
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
if (this.releasing.get(reviewId) === done)
|
|
171
|
+
this.releasing.delete(reviewId);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.releasing.set(reviewId, done);
|
|
175
|
+
this.cleanup = done;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async releaseCheckouts(reviewId: string, repositories: string[]) {
|
|
179
|
+
// Restored since the dismissal: its checkouts may be in use.
|
|
180
|
+
if (!this.store.dismissedIds().includes(reviewId)) return;
|
|
181
|
+
|
|
182
|
+
const environments = this.all().filter(
|
|
183
|
+
(item) => item.reviewId === reviewId,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const checkouts = repositories.filter((repository) =>
|
|
187
|
+
existsSync(reviewManagedCheckoutRoot(repository, reviewId)),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if ((!environments.length && !checkouts.length) || !this.claim(reviewId))
|
|
191
|
+
return;
|
|
192
|
+
|
|
193
|
+
for (const environment of environments) {
|
|
194
|
+
const job = this.jobs.get(environment.id);
|
|
195
|
+
job?.abort.abort();
|
|
196
|
+
await job?.done;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const errors: string[] = [];
|
|
200
|
+
|
|
201
|
+
for (const repository of checkouts)
|
|
202
|
+
await removeReviewManagedCheckouts(repository, reviewId).catch((error) =>
|
|
203
|
+
errors.push(errorMessage(error)),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Only a checkout still on disk failed; the others are gone.
|
|
207
|
+
for (const environment of environments)
|
|
208
|
+
if (
|
|
209
|
+
errors.length &&
|
|
210
|
+
environment.rootPath &&
|
|
211
|
+
existsSync(environment.rootPath)
|
|
212
|
+
) {
|
|
213
|
+
environment.state = "cleanup-failed";
|
|
214
|
+
environment.log = errors.join("\n");
|
|
215
|
+
this.save(environment);
|
|
216
|
+
} else
|
|
217
|
+
this.db
|
|
218
|
+
.prepare("DELETE FROM pinned_environments WHERE id=?")
|
|
219
|
+
.run(environment.id);
|
|
220
|
+
this.db
|
|
221
|
+
.prepare(
|
|
222
|
+
"DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)",
|
|
223
|
+
)
|
|
224
|
+
.run(reviewId, this.ownerId, reviewId);
|
|
225
|
+
|
|
226
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private external?: {
|
|
230
|
+
has(id: string): boolean;
|
|
231
|
+
subscribe(listener: () => void): () => void;
|
|
232
|
+
};
|
|
233
|
+
private stopExternal?: () => void;
|
|
234
|
+
|
|
235
|
+
attachExternalReviews(source: {
|
|
236
|
+
has(id: string): boolean;
|
|
237
|
+
subscribe(listener: () => void): () => void;
|
|
238
|
+
}) {
|
|
239
|
+
if (this.external === source) return;
|
|
240
|
+
this.stopExternal?.();
|
|
241
|
+
this.external = source;
|
|
242
|
+
this.stopExternal = source.subscribe(() => this.collect());
|
|
243
|
+
this.collect();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private hasReview(id: string): boolean {
|
|
247
|
+
// The shared catalog loads after the local store during host startup.
|
|
248
|
+
if (id.startsWith("shared-") && !this.external) return true;
|
|
249
|
+
|
|
250
|
+
return this.store.has(id) || Boolean(this.external?.has(id));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private assertReview(id: string) {
|
|
254
|
+
if (!this.hasReview(id)) this.store.assertExists(id);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async remove(reviewId: string) {
|
|
258
|
+
if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
|
|
259
|
+
await Promise.all(this.requests.values());
|
|
260
|
+
this.collect(undefined, reviewId);
|
|
261
|
+
await this.cleanup;
|
|
262
|
+
|
|
263
|
+
if (this.all().some((item) => item.reviewId === reviewId))
|
|
264
|
+
throw new ReviewInputError(
|
|
265
|
+
"Could not remove the managed workspace. Retry deletion.",
|
|
266
|
+
409,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private lease(reviewId: string) {
|
|
271
|
+
return this.db
|
|
272
|
+
.prepare("SELECT owner,pid FROM workspace_leases WHERE review_id=?")
|
|
273
|
+
.get(reviewId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private leasedElsewhere(reviewId: string): boolean {
|
|
277
|
+
const lease = this.lease(reviewId);
|
|
278
|
+
|
|
279
|
+
return Boolean(
|
|
280
|
+
lease &&
|
|
281
|
+
lease.owner !== this.ownerId &&
|
|
282
|
+
processIsAlive(Number(lease.pid)),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Takes the review's lease unless another live Desktop holds it. */
|
|
287
|
+
private claim(reviewId: string): boolean {
|
|
288
|
+
// Status reads claim on every poll; holding the lease needs no write lock.
|
|
289
|
+
if (this.lease(reviewId)?.owner === this.ownerId) return true;
|
|
290
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const free = !this.leasedElsewhere(reviewId);
|
|
294
|
+
|
|
295
|
+
if (free)
|
|
296
|
+
this.db
|
|
297
|
+
.prepare("INSERT OR REPLACE INTO workspace_leases VALUES(?,?,?)")
|
|
298
|
+
.run(reviewId, this.ownerId, process.pid);
|
|
299
|
+
this.db.exec("COMMIT");
|
|
300
|
+
|
|
301
|
+
return free;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.db.exec("ROLLBACK");
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private all(): Environment[] {
|
|
309
|
+
return this.db
|
|
310
|
+
.prepare("SELECT value FROM pinned_environments")
|
|
311
|
+
.all()
|
|
312
|
+
.map((row) => JSON.parse(String(row.value)));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private get(id: string): Environment | undefined {
|
|
316
|
+
const row = this.db
|
|
317
|
+
.prepare("SELECT value FROM pinned_environments WHERE id=?")
|
|
318
|
+
.get(id);
|
|
319
|
+
|
|
320
|
+
return row ? JSON.parse(String(row.value)) : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private save(environment: Environment) {
|
|
324
|
+
this.db
|
|
325
|
+
.prepare(
|
|
326
|
+
"INSERT INTO pinned_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value",
|
|
327
|
+
)
|
|
328
|
+
.run(environment.id, JSON.stringify(environment));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private status(environment: Environment): WorkspaceStatus {
|
|
332
|
+
const { id, commit, rootPath, generation, state, log } = environment;
|
|
333
|
+
|
|
334
|
+
const status: WorkspaceStatus = {
|
|
335
|
+
id,
|
|
336
|
+
commit,
|
|
337
|
+
rootPath,
|
|
338
|
+
generation,
|
|
339
|
+
state,
|
|
340
|
+
log,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (state === "failed" && !rootPath) status.issue = log;
|
|
344
|
+
|
|
345
|
+
return status;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
list(reviewId: string): WorkspaceStatus[] {
|
|
349
|
+
return this.all()
|
|
350
|
+
.filter((item) => item.reviewId === reviewId)
|
|
351
|
+
.map((item) => this.status(item));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
failures(): WorkspaceStatus[] {
|
|
355
|
+
return this.all()
|
|
356
|
+
.filter((item) => item.state === "cleanup-failed")
|
|
357
|
+
.map((item) => this.status(item));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async retryCleanup(id: string) {
|
|
361
|
+
const environment = this.get(id);
|
|
362
|
+
|
|
363
|
+
if (!environment || environment.state !== "cleanup-failed")
|
|
364
|
+
throw new ReviewInputError("Cleanup failure not found.", 404);
|
|
365
|
+
|
|
366
|
+
// A dismissed review keeps its record, so collection would skip it.
|
|
367
|
+
if (this.store.has(environment.reviewId))
|
|
368
|
+
this.release(environment.reviewId);
|
|
369
|
+
else this.collect(id);
|
|
370
|
+
await this.cleanup;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async open(reviewId: string, pins: Pins): Promise<void> {
|
|
374
|
+
await this.source(reviewId, pins, "head");
|
|
375
|
+
|
|
376
|
+
if (pins.base !== pins.head) await this.source(reviewId, pins, "base");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
source(
|
|
380
|
+
reviewId: string,
|
|
381
|
+
pins: Pins,
|
|
382
|
+
side: "base" | "head",
|
|
383
|
+
retryFailed = false,
|
|
384
|
+
): Promise<WorkspaceStatus> {
|
|
385
|
+
if (this.closed)
|
|
386
|
+
return Promise.reject(new Error("Language environments are closed."));
|
|
387
|
+
this.assertReview(reviewId);
|
|
388
|
+
|
|
389
|
+
const id = createHash("sha256")
|
|
390
|
+
.update(JSON.stringify([reviewId, pins.repositoryId, pins[side]]))
|
|
391
|
+
.digest("hex");
|
|
392
|
+
|
|
393
|
+
const current = this.requests.get(id);
|
|
394
|
+
|
|
395
|
+
if (current) return current;
|
|
396
|
+
|
|
397
|
+
if (!this.claim(reviewId)) {
|
|
398
|
+
const environment = this.get(id);
|
|
399
|
+
|
|
400
|
+
return Promise.resolve(
|
|
401
|
+
environment
|
|
402
|
+
? this.status(environment)
|
|
403
|
+
: {
|
|
404
|
+
id,
|
|
405
|
+
commit: pins[side],
|
|
406
|
+
rootPath: null,
|
|
407
|
+
generation: "",
|
|
408
|
+
state: "pending",
|
|
409
|
+
log: OWNED_ELSEWHERE,
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const request = this.acquire(id, reviewId, pins, side, retryFailed).finally(
|
|
415
|
+
() => this.requests.delete(id),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
this.requests.set(id, request);
|
|
419
|
+
|
|
420
|
+
return request;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async acquire(
|
|
424
|
+
id: string,
|
|
425
|
+
reviewId: string,
|
|
426
|
+
pins: Pins,
|
|
427
|
+
side: "base" | "head",
|
|
428
|
+
retryFailed: boolean,
|
|
429
|
+
): Promise<WorkspaceStatus> {
|
|
430
|
+
await this.released(reviewId);
|
|
431
|
+
let environment = this.get(id);
|
|
432
|
+
|
|
433
|
+
if (this.jobs.has(id)) return this.status(environment!);
|
|
434
|
+
environment ??= {
|
|
435
|
+
id,
|
|
436
|
+
reviewId,
|
|
437
|
+
repositoryId: pins.repositoryId,
|
|
438
|
+
repository: "",
|
|
439
|
+
commit: pins[side],
|
|
440
|
+
rootPath: null,
|
|
441
|
+
generation: randomUUID(),
|
|
442
|
+
commandsHash: "",
|
|
443
|
+
role: pins.base === pins.head ? "head" : side,
|
|
444
|
+
state: "pending",
|
|
445
|
+
log: "",
|
|
446
|
+
};
|
|
447
|
+
this.save(environment);
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const root = this.store.repositoryPath(pins.repositoryId);
|
|
451
|
+
|
|
452
|
+
const repository =
|
|
453
|
+
environment.repository || (await gitCommonDir(root).catch(() => null));
|
|
454
|
+
|
|
455
|
+
if (!repository)
|
|
456
|
+
throw new Error(
|
|
457
|
+
"Repository Git directory is unavailable. Restore the registered checkout and retry preparation.",
|
|
458
|
+
);
|
|
459
|
+
environment.repository = repository;
|
|
460
|
+
|
|
461
|
+
const result = await git(
|
|
462
|
+
repository,
|
|
463
|
+
["config", "--null", "--get-all", "devfast.prepare"],
|
|
464
|
+
{ allowFailure: true },
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const commands = result.stdout.trim()
|
|
468
|
+
? result.stdout.split("\0").filter((command) => command.trim())
|
|
469
|
+
: [];
|
|
470
|
+
|
|
471
|
+
const commandsHash = reviewPrepareCommandsHash(commands);
|
|
472
|
+
|
|
473
|
+
const existed =
|
|
474
|
+
environment.rootPath &&
|
|
475
|
+
existsSync(path.join(environment.rootPath, ".git"));
|
|
476
|
+
|
|
477
|
+
const checkout = await ensureReviewPinnedCheckout({
|
|
478
|
+
rootPath: existsSync(root) ? root : repository,
|
|
479
|
+
reviewUuid: reviewId,
|
|
480
|
+
ref: pins[side],
|
|
481
|
+
role: environment.role,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (!checkout) throw new Error("Pinned checkout is unavailable.");
|
|
485
|
+
|
|
486
|
+
if (!this.hasReview(reviewId)) {
|
|
487
|
+
environment.rootPath = checkout;
|
|
488
|
+
this.save(environment);
|
|
489
|
+
this.collect();
|
|
490
|
+
|
|
491
|
+
return this.status(environment);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const changed =
|
|
495
|
+
!existed ||
|
|
496
|
+
environment.rootPath !== checkout ||
|
|
497
|
+
environment.commandsHash !== commandsHash;
|
|
498
|
+
|
|
499
|
+
environment.rootPath = checkout;
|
|
500
|
+
environment.commandsHash = commandsHash;
|
|
501
|
+
|
|
502
|
+
if (!commands.length) {
|
|
503
|
+
environment.state = "unconfigured";
|
|
504
|
+
environment.log =
|
|
505
|
+
"Configure dependencies with git config devfast.prepare '<command>'.";
|
|
506
|
+
} else if (
|
|
507
|
+
await markerMatches(reviewPrepareMarkerPath(checkout), commandsHash)
|
|
508
|
+
) {
|
|
509
|
+
environment.state = "ready";
|
|
510
|
+
environment.log = "";
|
|
511
|
+
} else if (environment.state !== "failed" || changed || retryFailed) {
|
|
512
|
+
environment.state = "preparing";
|
|
513
|
+
environment.generation = randomUUID();
|
|
514
|
+
environment.log = "Preparing pinned checkout…";
|
|
515
|
+
this.save(environment);
|
|
516
|
+
this.prepare(environment, commands);
|
|
517
|
+
|
|
518
|
+
return this.status(environment);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (changed) environment.generation = randomUUID();
|
|
522
|
+
} catch (error) {
|
|
523
|
+
environment.state = "failed";
|
|
524
|
+
environment.rootPath = null;
|
|
525
|
+
environment.log = errorMessage(error);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.save(environment);
|
|
529
|
+
|
|
530
|
+
return this.status(environment);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private prepare(environment: Environment, commands: string[]) {
|
|
534
|
+
const abort = new AbortController();
|
|
535
|
+
|
|
536
|
+
const done = prepareReviewPinnedCheckout({
|
|
537
|
+
checkoutPath: environment.rootPath!,
|
|
538
|
+
commit: environment.commit,
|
|
539
|
+
commands,
|
|
540
|
+
signal: abort.signal,
|
|
541
|
+
progress: (log) => {
|
|
542
|
+
environment.log = log;
|
|
543
|
+
this.save(environment);
|
|
544
|
+
},
|
|
545
|
+
warning: (log) => {
|
|
546
|
+
environment.log = log;
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
.then(async (result) => {
|
|
550
|
+
environment.state = result.prepared ? "ready" : "failed";
|
|
551
|
+
|
|
552
|
+
if (!result.prepared)
|
|
553
|
+
environment.log = await readFile(
|
|
554
|
+
reviewPrepareLogPath(environment.rootPath!),
|
|
555
|
+
"utf8",
|
|
556
|
+
).catch(() => environment.log);
|
|
557
|
+
})
|
|
558
|
+
.catch((error) => {
|
|
559
|
+
environment.state = "failed";
|
|
560
|
+
environment.log = errorMessage(error);
|
|
561
|
+
})
|
|
562
|
+
.finally(() => {
|
|
563
|
+
environment.generation = randomUUID();
|
|
564
|
+
this.save(environment);
|
|
565
|
+
this.jobs.delete(environment.id);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
this.jobs.set(environment.id, { done, abort });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async retry(reviewId: string, id: string): Promise<WorkspaceStatus> {
|
|
572
|
+
const environment = this.get(id);
|
|
573
|
+
|
|
574
|
+
if (!environment || environment.reviewId !== reviewId)
|
|
575
|
+
throw new ReviewInputError("Language environment not found.", 404);
|
|
576
|
+
|
|
577
|
+
if (this.jobs.has(id)) return this.status(environment);
|
|
578
|
+
|
|
579
|
+
if (!this.claim(reviewId)) throw new ReviewInputError(OWNED_ELSEWHERE, 409);
|
|
580
|
+
|
|
581
|
+
if (environment.state === "cleanup-failed") {
|
|
582
|
+
this.collect();
|
|
583
|
+
|
|
584
|
+
return this.status(environment);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
this.assertReview(reviewId);
|
|
588
|
+
|
|
589
|
+
if (environment.rootPath)
|
|
590
|
+
await rm(reviewPrepareMarkerPath(environment.rootPath), { force: true });
|
|
591
|
+
environment.state = "pending";
|
|
592
|
+
environment.generation = randomUUID();
|
|
593
|
+
this.save(environment);
|
|
594
|
+
|
|
595
|
+
return this.source(
|
|
596
|
+
reviewId,
|
|
597
|
+
{
|
|
598
|
+
repositoryId: environment.repositoryId,
|
|
599
|
+
base: environment.commit,
|
|
600
|
+
head: environment.commit,
|
|
601
|
+
},
|
|
602
|
+
"head",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private collect(retryId?: string, removedReviewId?: string) {
|
|
607
|
+
// Capture ownership before awaiting, so shutdown never reads a closed store.
|
|
608
|
+
const deleted = this.all().filter(
|
|
609
|
+
(environment) =>
|
|
610
|
+
(environment.reviewId === removedReviewId ||
|
|
611
|
+
!this.hasReview(environment.reviewId)) &&
|
|
612
|
+
(environment.state !== "cleanup-failed" ||
|
|
613
|
+
environment.id === retryId ||
|
|
614
|
+
environment.reviewId === removedReviewId) &&
|
|
615
|
+
this.claim(environment.reviewId),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
this.cleanup = this.cleanup.then(async () => {
|
|
619
|
+
for (const environment of deleted) {
|
|
620
|
+
const job = this.jobs.get(environment.id);
|
|
621
|
+
job?.abort.abort();
|
|
622
|
+
await job?.done;
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
if (environment.rootPath && existsSync(environment.rootPath)) {
|
|
626
|
+
const managed = reviewManagedCheckoutRoot(
|
|
627
|
+
environment.repository,
|
|
628
|
+
environment.reviewId,
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const relative = path.relative(managed, environment.rootPath);
|
|
632
|
+
|
|
633
|
+
if (
|
|
634
|
+
!relative ||
|
|
635
|
+
relative.startsWith("..") ||
|
|
636
|
+
path.isAbsolute(relative)
|
|
637
|
+
)
|
|
638
|
+
throw new Error("Refusing to remove a non-managed checkout.");
|
|
639
|
+
await git(environment.repository, [
|
|
640
|
+
"worktree",
|
|
641
|
+
"remove",
|
|
642
|
+
"--force",
|
|
643
|
+
environment.rootPath,
|
|
644
|
+
]);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (environment.rootPath)
|
|
648
|
+
await removeReviewPrepareArtifacts(environment.rootPath);
|
|
649
|
+
|
|
650
|
+
this.db
|
|
651
|
+
.prepare("DELETE FROM pinned_environments WHERE id=?")
|
|
652
|
+
.run(environment.id);
|
|
653
|
+
this.db
|
|
654
|
+
.prepare(
|
|
655
|
+
"DELETE FROM workspace_leases WHERE review_id=? AND owner=? AND NOT EXISTS(SELECT 1 FROM pinned_environments WHERE value->>'reviewId'=?)",
|
|
656
|
+
)
|
|
657
|
+
.run(environment.reviewId, this.ownerId, environment.reviewId);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
environment.state = "cleanup-failed";
|
|
660
|
+
environment.log = errorMessage(error);
|
|
661
|
+
this.save(environment);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async idle() {
|
|
668
|
+
await Promise.all(this.requests.values());
|
|
669
|
+
await Promise.all([...this.jobs.values()].map((job) => job.done));
|
|
670
|
+
await this.cleanup;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private closing?: Promise<void>;
|
|
674
|
+
|
|
675
|
+
close(): Promise<void> {
|
|
676
|
+
return (this.closing ??= this.closeAll());
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private async closeAll() {
|
|
680
|
+
this.closed = true;
|
|
681
|
+
this.stop();
|
|
682
|
+
this.stopExternal?.();
|
|
683
|
+
await Promise.all(this.requests.values());
|
|
684
|
+
|
|
685
|
+
for (const job of this.jobs.values()) job.abort.abort();
|
|
686
|
+
await this.idle();
|
|
687
|
+
this.db
|
|
688
|
+
.prepare("DELETE FROM workspace_leases WHERE owner=?")
|
|
689
|
+
.run(this.ownerId);
|
|
690
|
+
this.db.close();
|
|
691
|
+
}
|
|
692
|
+
}
|