@dev.fast/whiteboard 0.0.0-stage → 0.2.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/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,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JsonValue,
|
|
3
|
+
isJsonObject,
|
|
4
|
+
jsonString,
|
|
5
|
+
parseJsonText,
|
|
6
|
+
} from "@dev.fast/review-protocol";
|
|
7
|
+
import pino from "pino";
|
|
8
|
+
import pretty from "pino-pretty";
|
|
9
|
+
|
|
10
|
+
export type ReviewLogFormat = "ndjson" | "pretty";
|
|
11
|
+
|
|
12
|
+
export type ReviewLifecyclePhaseName = "review_document" | "server";
|
|
13
|
+
|
|
14
|
+
export interface ReviewLifecycleError {
|
|
15
|
+
name: string;
|
|
16
|
+
message: string;
|
|
17
|
+
stack?: string;
|
|
18
|
+
component?: string;
|
|
19
|
+
propertyPath?: string;
|
|
20
|
+
expected?: unknown;
|
|
21
|
+
received?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ReviewLifecycleEvent =
|
|
25
|
+
| {
|
|
26
|
+
event: "start";
|
|
27
|
+
cliPath: string;
|
|
28
|
+
cliName: string;
|
|
29
|
+
cliVersion: string;
|
|
30
|
+
provenance: "workspace" | "installed";
|
|
31
|
+
source: string;
|
|
32
|
+
args: string[];
|
|
33
|
+
base: string;
|
|
34
|
+
head: string;
|
|
35
|
+
repo?: string;
|
|
36
|
+
pullRequest?: number;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
event: "phase";
|
|
40
|
+
name: ReviewLifecyclePhaseName;
|
|
41
|
+
status: "running" | "complete";
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
event: "ready";
|
|
45
|
+
url: string;
|
|
46
|
+
document: string;
|
|
47
|
+
headCheckout?: string;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
event: "diagnostic";
|
|
51
|
+
level: "info" | "warn" | "error";
|
|
52
|
+
origin: "review";
|
|
53
|
+
message: string;
|
|
54
|
+
error?: ReviewLifecycleError;
|
|
55
|
+
}
|
|
56
|
+
| { event: "dismissed"; reason: "canvas_closed" }
|
|
57
|
+
| {
|
|
58
|
+
event: "error";
|
|
59
|
+
error: ReviewLifecycleError;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// This is the only presentation choice in the Review logging pipeline.
|
|
63
|
+
export const DEFAULT_REVIEW_LOG_FORMAT: ReviewLogFormat = "ndjson";
|
|
64
|
+
|
|
65
|
+
export interface ReviewLogger {
|
|
66
|
+
event(event: ReviewLifecycleEvent): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createReviewLogger(input: {
|
|
70
|
+
output: NodeJS.WritableStream;
|
|
71
|
+
format?: ReviewLogFormat;
|
|
72
|
+
colorize?: boolean;
|
|
73
|
+
}): ReviewLogger {
|
|
74
|
+
const format = input.format ?? DEFAULT_REVIEW_LOG_FORMAT;
|
|
75
|
+
|
|
76
|
+
const destination =
|
|
77
|
+
format === "pretty"
|
|
78
|
+
? pretty({
|
|
79
|
+
destination: input.output,
|
|
80
|
+
sync: true,
|
|
81
|
+
colorize:
|
|
82
|
+
input.colorize ??
|
|
83
|
+
("isTTY" in input.output && Boolean(input.output.isTTY)),
|
|
84
|
+
translateTime: false,
|
|
85
|
+
singleLine: true,
|
|
86
|
+
messageFormat: "{event}",
|
|
87
|
+
ignore: "pid,hostname,time,event",
|
|
88
|
+
})
|
|
89
|
+
: input.output;
|
|
90
|
+
|
|
91
|
+
const logger = pino(
|
|
92
|
+
{
|
|
93
|
+
base: null,
|
|
94
|
+
timestamp: false,
|
|
95
|
+
formatters: {
|
|
96
|
+
level(label) {
|
|
97
|
+
return { level: label };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
destination,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
event(event) {
|
|
106
|
+
if (event.event === "diagnostic") {
|
|
107
|
+
const { level, ...record } = event;
|
|
108
|
+
logger[level](record);
|
|
109
|
+
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (event.event === "error") {
|
|
114
|
+
logger.error(event);
|
|
115
|
+
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
logger.info(event);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const defaultLoggers = new WeakMap<NodeJS.WritableStream, ReviewLogger>();
|
|
125
|
+
|
|
126
|
+
export function emitReviewEvent(
|
|
127
|
+
output: NodeJS.WritableStream,
|
|
128
|
+
event: ReviewLifecycleEvent,
|
|
129
|
+
): void {
|
|
130
|
+
let logger = defaultLoggers.get(output);
|
|
131
|
+
|
|
132
|
+
if (!logger) {
|
|
133
|
+
logger = createReviewLogger({ output });
|
|
134
|
+
defaultLoggers.set(output, logger);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
logger.event(event);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function serializeReviewError(cause: unknown): ReviewLifecycleError {
|
|
141
|
+
// A thrown object is read as the JSON record it is about to be logged as;
|
|
142
|
+
// each field the record keeps is decoded on its own below.
|
|
143
|
+
const fields = isJsonObject(cause) ? cause : undefined;
|
|
144
|
+
const stack = jsonString(fields?.stack);
|
|
145
|
+
const component = jsonString(fields?.component);
|
|
146
|
+
const propertyPath = jsonString(fields?.propertyPath);
|
|
147
|
+
|
|
148
|
+
const error: ReviewLifecycleError = {
|
|
149
|
+
name:
|
|
150
|
+
cause instanceof Error
|
|
151
|
+
? cause.name
|
|
152
|
+
: (jsonString(fields?.name) ?? "Error"),
|
|
153
|
+
message:
|
|
154
|
+
cause instanceof Error
|
|
155
|
+
? cause.message
|
|
156
|
+
: (jsonString(fields?.message) ?? String(cause)),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (stack !== undefined) error.stack = stack;
|
|
160
|
+
|
|
161
|
+
if (component !== undefined) error.component = component;
|
|
162
|
+
|
|
163
|
+
if (propertyPath !== undefined) error.propertyPath = propertyPath;
|
|
164
|
+
|
|
165
|
+
if (fields && "expected" in fields) {
|
|
166
|
+
error.expected = jsonSafeValue(fields.expected);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (fields && "received" in fields) {
|
|
170
|
+
error.received = jsonSafeValue(fields.received);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Round-trip through JSON so a value that cannot serialize still logs. */
|
|
177
|
+
function jsonSafeValue(value: JsonValue): JsonValue {
|
|
178
|
+
try {
|
|
179
|
+
return parseJsonText(JSON.stringify(value));
|
|
180
|
+
} catch {
|
|
181
|
+
return String(value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type JsonValue,
|
|
6
|
+
isJsonObject,
|
|
7
|
+
parseJsonText,
|
|
8
|
+
} from "@dev.fast/review-protocol";
|
|
9
|
+
import { writePrivateJsonAtomic } from "@dev.fast/trace-core";
|
|
10
|
+
|
|
11
|
+
import { devReviewHome } from "./review-home-paths";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Machine-wide Review preferences the server itself needs. Workbench settings
|
|
15
|
+
* do not work here: the review server never reads the workbench
|
|
16
|
+
* configuration.
|
|
17
|
+
*/
|
|
18
|
+
export interface ReviewPreferences {
|
|
19
|
+
/**
|
|
20
|
+
* Whether the scratchpad exists on this machine. Off, the server neither
|
|
21
|
+
* makes nor lists it and Review's instructions do not offer it to agents.
|
|
22
|
+
*/
|
|
23
|
+
scratchpadEnabled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_REVIEW_PREFERENCES: ReviewPreferences = {
|
|
27
|
+
scratchpadEnabled: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function reviewPreferencesPath(devHome = devReviewHome()): string {
|
|
31
|
+
return path.join(devHome, "preferences.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A missing or unreadable file falls back to the defaults; it never throws. */
|
|
35
|
+
export async function readReviewPreferences(
|
|
36
|
+
devHome?: string,
|
|
37
|
+
): Promise<ReviewPreferences> {
|
|
38
|
+
try {
|
|
39
|
+
const raw = parseJsonText(
|
|
40
|
+
await readFile(reviewPreferencesPath(devHome), "utf8"),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
scratchpadEnabled: parseScratchpadEnabled(raw),
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return { ...DEFAULT_REVIEW_PREFERENCES };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeReviewPreferences(
|
|
52
|
+
preferences: ReviewPreferences,
|
|
53
|
+
devHome?: string,
|
|
54
|
+
): Promise<ReviewPreferences> {
|
|
55
|
+
const next: ReviewPreferences = {
|
|
56
|
+
scratchpadEnabled: preferences.scratchpadEnabled === true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await writePrivateJsonAtomic(reviewPreferencesPath(devHome), next);
|
|
60
|
+
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reads the scratchpad preference; a missing file means off. */
|
|
65
|
+
export async function readScratchpadEnabled(
|
|
66
|
+
devHome?: string,
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
return (await readReviewPreferences(devHome)).scratchpadEnabled;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sets the scratchpad preference, keeping the other preferences as they are. */
|
|
72
|
+
export async function writeScratchpadEnabled(
|
|
73
|
+
enabled: boolean,
|
|
74
|
+
devHome?: string,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
const current = await readReviewPreferences(devHome);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
await writeReviewPreferences(
|
|
80
|
+
{ ...current, scratchpadEnabled: enabled },
|
|
81
|
+
devHome,
|
|
82
|
+
)
|
|
83
|
+
).scratchpadEnabled;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseScratchpadEnabled(raw: JsonValue): boolean {
|
|
87
|
+
return isJsonObject(raw) && raw.scratchpadEnabled === true;
|
|
88
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
import { jsonObject, parseJsonText } from "@dev.fast/review-protocol";
|
|
6
|
+
import { withFileLock } from "@dev.fast/trace-core";
|
|
7
|
+
|
|
8
|
+
// Dependency preparation for pinned worktrees. The repo owner configures the
|
|
9
|
+
// commands once per clone (`git config devfast.prepare '<command>'`, the key
|
|
10
|
+
// is multi-valued and ordered); Review runs them opaquely with the worktree as
|
|
11
|
+
// the working directory. A marker beside the worktree records the command-list
|
|
12
|
+
// hash, so a tree prepares once per commit and re-prepares when the
|
|
13
|
+
// configuration changes. Failure is soft by design: the caller warns and
|
|
14
|
+
// indexes the unprepared tree, which is today's quality — a broken install
|
|
15
|
+
// must never block review.
|
|
16
|
+
|
|
17
|
+
const PREPARE_LOCK_RETRY_MS = 250;
|
|
18
|
+
|
|
19
|
+
const PREPARE_LOCK_STALE_MS = 30 * 60_000;
|
|
20
|
+
|
|
21
|
+
const PREPARE_LOCK_TIMEOUT_MS = 1000;
|
|
22
|
+
|
|
23
|
+
const PREPARE_LOCK_UNOWNED_GRACE_MS = 1_000;
|
|
24
|
+
|
|
25
|
+
const PREPARE_OUTPUT_TAIL_LINES = 20;
|
|
26
|
+
|
|
27
|
+
export interface PrepareCommandResult {
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
/** Combined stdout+stderr, bounded to the most recent output. */
|
|
30
|
+
output?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PrepareReviewPinnedCheckoutInput {
|
|
34
|
+
checkoutPath: string;
|
|
35
|
+
commit: string;
|
|
36
|
+
commands: readonly string[];
|
|
37
|
+
warning?: (message: string) => void;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
progress?: (log: string) => void;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
/** Test seam: replaces the shell execution of one prepare command. */
|
|
42
|
+
runCommand?: (command: string, cwd: string) => Promise<PrepareCommandResult>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The marker file that records a completed prepare, beside the worktree. */
|
|
46
|
+
export function reviewPrepareMarkerPath(checkoutPath: string): string {
|
|
47
|
+
return `${checkoutPath}.prepared`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The failure log written beside the worktree when a prepare command fails. */
|
|
51
|
+
export function reviewPrepareLogPath(checkoutPath: string): string {
|
|
52
|
+
return `${checkoutPath}.prepare-log`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function reviewPrepareCommandsHash(commands: readonly string[]): string {
|
|
56
|
+
return crypto
|
|
57
|
+
.createHash("sha256")
|
|
58
|
+
.update(JSON.stringify(commands))
|
|
59
|
+
.digest("hex")
|
|
60
|
+
.slice(0, 16);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A tree that ceases to exist takes its prepare state with it: the caller
|
|
64
|
+
// removes these artifacts on every checkout removal or recreation, so a fresh
|
|
65
|
+
// bare tree can never inherit a marker that claims it is prepared.
|
|
66
|
+
export async function removeReviewPrepareArtifacts(
|
|
67
|
+
checkoutPath: string,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await rm(reviewPrepareMarkerPath(checkoutPath), { force: true });
|
|
70
|
+
await rm(reviewPrepareLogPath(checkoutPath), { force: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Run the configured prepare commands in order inside the pinned checkout.
|
|
74
|
+
// Returns whether the tree is prepared for exactly this command list. On the
|
|
75
|
+
// first failing command: warn with the commit, the command, and the output
|
|
76
|
+
// tail; write the captured output beside the worktree; leave no marker; and
|
|
77
|
+
// return unprepared — the caller continues against the bare tree.
|
|
78
|
+
export async function prepareReviewPinnedCheckout(
|
|
79
|
+
input: PrepareReviewPinnedCheckoutInput,
|
|
80
|
+
): Promise<{ prepared: boolean }> {
|
|
81
|
+
if (input.commands.length === 0) return { prepared: false };
|
|
82
|
+
const markerPath = reviewPrepareMarkerPath(input.checkoutPath);
|
|
83
|
+
const expectedHash = reviewPrepareCommandsHash(input.commands);
|
|
84
|
+
|
|
85
|
+
if (await markerMatches(markerPath, expectedHash)) {
|
|
86
|
+
return { prepared: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const outcome = await withFileLock(
|
|
90
|
+
`${input.checkoutPath}.prepare-lock`,
|
|
91
|
+
{
|
|
92
|
+
retryMs: PREPARE_LOCK_RETRY_MS,
|
|
93
|
+
staleMs: PREPARE_LOCK_STALE_MS,
|
|
94
|
+
heartbeatMs: 5000,
|
|
95
|
+
timeoutMs: PREPARE_LOCK_TIMEOUT_MS,
|
|
96
|
+
unownedGraceMs: PREPARE_LOCK_UNOWNED_GRACE_MS,
|
|
97
|
+
},
|
|
98
|
+
async () => {
|
|
99
|
+
if (await markerMatches(markerPath, expectedHash)) {
|
|
100
|
+
return { prepared: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A stale marker from an older command list must not survive a failed
|
|
104
|
+
// re-prepare: remove it before the first command runs.
|
|
105
|
+
await rm(markerPath, { force: true });
|
|
106
|
+
|
|
107
|
+
const runCommand =
|
|
108
|
+
input.runCommand ??
|
|
109
|
+
(async (command: string, cwd: string) => {
|
|
110
|
+
const output = await runPrepareCommand(
|
|
111
|
+
command,
|
|
112
|
+
cwd,
|
|
113
|
+
input.progress ?? (() => {}),
|
|
114
|
+
input.signal ?? new AbortController().signal,
|
|
115
|
+
input.timeoutMs,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return { exitCode: 0, output };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
for (const command of input.commands) {
|
|
122
|
+
const result = await runCommand(command, input.checkoutPath).catch(
|
|
123
|
+
(cause: unknown) => ({
|
|
124
|
+
exitCode: null,
|
|
125
|
+
output: cause instanceof Error ? cause.message : String(cause),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (result.exitCode !== 0) {
|
|
130
|
+
const exit =
|
|
131
|
+
result.exitCode === null
|
|
132
|
+
? "no exit code"
|
|
133
|
+
: `exit ${result.exitCode}`;
|
|
134
|
+
|
|
135
|
+
// The log is written whether or not anyone listens for warnings:
|
|
136
|
+
// it is the durable diagnostic for a failure the caller soft-skips.
|
|
137
|
+
const logNote = await writePrepareFailureLog(
|
|
138
|
+
input.checkoutPath,
|
|
139
|
+
result.output,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
input.warning?.(
|
|
143
|
+
formatPrepareFailure({
|
|
144
|
+
commit: input.commit,
|
|
145
|
+
command,
|
|
146
|
+
exit,
|
|
147
|
+
output: result.output,
|
|
148
|
+
logNote,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return { prepared: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await rm(reviewPrepareLogPath(input.checkoutPath), { force: true });
|
|
157
|
+
await writeFile(
|
|
158
|
+
markerPath,
|
|
159
|
+
JSON.stringify({ commandsHash: expectedHash, preparedAt: Date.now() }),
|
|
160
|
+
"utf8",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return { prepared: true };
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (!outcome.acquired) {
|
|
168
|
+
input.warning?.(
|
|
169
|
+
`devfast.prepare skipped for commit ${input.commit.slice(0, 12)}: another process holds the prepare lock. Language features use the unprepared pinned checkout.`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return { prepared: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return outcome.result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function writePrepareFailureLog(
|
|
179
|
+
checkoutPath: string,
|
|
180
|
+
output: string | undefined,
|
|
181
|
+
): Promise<string> {
|
|
182
|
+
const trimmed = output?.trimEnd();
|
|
183
|
+
|
|
184
|
+
if (!trimmed) return "";
|
|
185
|
+
const logPath = reviewPrepareLogPath(checkoutPath);
|
|
186
|
+
|
|
187
|
+
return writeFile(logPath, `${trimmed}\n`, "utf8")
|
|
188
|
+
.then(() => ` Full output: ${logPath}.`)
|
|
189
|
+
.catch(() => "");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatPrepareFailure(input: {
|
|
193
|
+
commit: string;
|
|
194
|
+
command: string;
|
|
195
|
+
exit: string;
|
|
196
|
+
output: string | undefined;
|
|
197
|
+
logNote: string;
|
|
198
|
+
}): string {
|
|
199
|
+
const base = `devfast.prepare failed for commit ${input.commit.slice(0, 12)} (${input.exit}): ${input.command}. Language features use the unprepared pinned checkout.`;
|
|
200
|
+
const output = input.output?.trimEnd();
|
|
201
|
+
|
|
202
|
+
if (!output) return base;
|
|
203
|
+
const lines = output.split("\n");
|
|
204
|
+
const tail = lines.slice(-PREPARE_OUTPUT_TAIL_LINES).join("\n");
|
|
205
|
+
const elided = lines.length > PREPARE_OUTPUT_TAIL_LINES ? "…\n" : "";
|
|
206
|
+
|
|
207
|
+
return `${base} Last output:\n${elided}${tail}${input.logNote}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function markerMatches(
|
|
211
|
+
markerPath: string,
|
|
212
|
+
expectedHash: string,
|
|
213
|
+
): Promise<boolean> {
|
|
214
|
+
return readFile(markerPath, "utf8")
|
|
215
|
+
.then(
|
|
216
|
+
(contents) =>
|
|
217
|
+
jsonObject(parseJsonText(contents))?.commandsHash === expectedHash,
|
|
218
|
+
)
|
|
219
|
+
.catch(() => false);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Process lifecycle adapted from Milan’s managed-workspaces implementation (#334).
|
|
223
|
+
export function runPrepareCommand(
|
|
224
|
+
command: string,
|
|
225
|
+
cwd: string,
|
|
226
|
+
progress: (log: string) => void,
|
|
227
|
+
signal: AbortSignal,
|
|
228
|
+
timeoutMs = 15 * 60_000,
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
signal.throwIfAborted();
|
|
232
|
+
let output = "";
|
|
233
|
+
|
|
234
|
+
const child = spawn(command, {
|
|
235
|
+
cwd,
|
|
236
|
+
shell: true,
|
|
237
|
+
detached: process.platform !== "win32",
|
|
238
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
let interrupted: Error | undefined;
|
|
242
|
+
let force: ReturnType<typeof setTimeout> | undefined;
|
|
243
|
+
|
|
244
|
+
const kill = (signal: NodeJS.Signals) => {
|
|
245
|
+
try {
|
|
246
|
+
if (process.platform !== "win32" && child.pid)
|
|
247
|
+
process.kill(-child.pid, signal);
|
|
248
|
+
else child.kill(signal);
|
|
249
|
+
} catch {
|
|
250
|
+
/* The process may already have exited. */
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const stop = (reason: Error) => {
|
|
255
|
+
interrupted = reason;
|
|
256
|
+
kill("SIGTERM");
|
|
257
|
+
force ??= setTimeout(() => kill("SIGKILL"), 2000);
|
|
258
|
+
force.unref();
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const abort = () =>
|
|
262
|
+
stop(new Error("Project command interrupted by shutdown."));
|
|
263
|
+
|
|
264
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
265
|
+
|
|
266
|
+
const timeout = setTimeout(
|
|
267
|
+
() => stop(new Error("Project preparation timed out.")),
|
|
268
|
+
timeoutMs,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
timeout.unref();
|
|
272
|
+
|
|
273
|
+
const append = (chunk: Buffer) => {
|
|
274
|
+
output = (output + chunk.toString()).slice(-64_000);
|
|
275
|
+
progress(output);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
child.stdout.on("data", append);
|
|
279
|
+
child.stderr.on("data", append);
|
|
280
|
+
child.on("error", reject);
|
|
281
|
+
child.on("close", (code) => {
|
|
282
|
+
clearTimeout(timeout);
|
|
283
|
+
clearTimeout(force);
|
|
284
|
+
signal.removeEventListener("abort", abort);
|
|
285
|
+
|
|
286
|
+
if (interrupted) reject(new Error(`${interrupted.message}\n${output}`));
|
|
287
|
+
else if (code === 0) resolve(output);
|
|
288
|
+
else reject(new Error(`Command exited with ${code}.\n${output}`));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ReviewApiSummary,
|
|
6
|
+
ReviewStackLayer,
|
|
7
|
+
} from "@dev.fast/review-protocol";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
const GitHubStacksSchema = z.array(
|
|
13
|
+
z.object({
|
|
14
|
+
pull_requests: z.array(
|
|
15
|
+
z.object({
|
|
16
|
+
number: z.number().int().positive(),
|
|
17
|
+
head: z.object({ ref: z.string().min(1) }),
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export type RunGitHubApi = (host: string, endpoint: string) => Promise<string>;
|
|
24
|
+
|
|
25
|
+
export async function resolveReviewStackLayers(
|
|
26
|
+
subject: Pick<ReviewApiSummary, "origin">,
|
|
27
|
+
reviews: readonly Pick<
|
|
28
|
+
ReviewApiSummary,
|
|
29
|
+
"reviewId" | "title" | "origin" | "pins"
|
|
30
|
+
>[],
|
|
31
|
+
runGitHubApi: RunGitHubApi = defaultRunGitHubApi,
|
|
32
|
+
): Promise<ReviewStackLayer[]> {
|
|
33
|
+
const binding = subject.origin?.pullRequestUrl?.match(
|
|
34
|
+
/^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!binding) return [];
|
|
38
|
+
const [, host, owner, repository, number] = binding;
|
|
39
|
+
const pullRequestNumber = Number(number);
|
|
40
|
+
const repoKey = `https://${host}/${owner}/${repository}`;
|
|
41
|
+
let stacks: z.infer<typeof GitHubStacksSchema>;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
stacks = GitHubStacksSchema.parse(
|
|
45
|
+
JSON.parse(
|
|
46
|
+
await runGitHubApi(
|
|
47
|
+
host!,
|
|
48
|
+
`repos/${owner}/${repository}/stacks?pull_request=${number}`,
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
// Stack discovery must not make the Review unavailable when GitHub or
|
|
54
|
+
// authentication is unavailable, or stacks are not enabled for the repo.
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stack = stacks.find((candidate) =>
|
|
59
|
+
candidate.pull_requests.some((pr) => pr.number === pullRequestNumber),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!stack) return [];
|
|
63
|
+
|
|
64
|
+
const currentIndex = stack.pull_requests.findIndex(
|
|
65
|
+
(pr) => pr.number === pullRequestNumber,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return stack.pull_requests.map((pr, index) => {
|
|
69
|
+
const review = reviews.find((candidate) => {
|
|
70
|
+
const repository =
|
|
71
|
+
candidate.origin?.pullRequestUrl?.replace(/\/pull\/\d+.*$/, "") ??
|
|
72
|
+
candidate.pins?.repositoryId;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
repository === repoKey &&
|
|
76
|
+
candidate.origin?.pullRequestNumber === pr.number
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
branch: pr.head.ref,
|
|
82
|
+
pullRequestNumber: pr.number,
|
|
83
|
+
pullRequestUrl: `${repoKey}/pull/${pr.number}`,
|
|
84
|
+
reviewUuid: review?.reviewId ?? null,
|
|
85
|
+
reviewTitle: review?.title ?? null,
|
|
86
|
+
relation:
|
|
87
|
+
index < currentIndex
|
|
88
|
+
? "earlier"
|
|
89
|
+
: index === currentIndex
|
|
90
|
+
? "current"
|
|
91
|
+
: "later",
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function defaultRunGitHubApi(
|
|
97
|
+
host: string,
|
|
98
|
+
endpoint: string,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
const { stdout } = await execFileAsync(
|
|
101
|
+
"gh",
|
|
102
|
+
["api", "--hostname", host, endpoint],
|
|
103
|
+
{
|
|
104
|
+
env: { ...process.env, GH_PROMPT_DISABLED: "1" },
|
|
105
|
+
encoding: "utf8",
|
|
106
|
+
timeout: 10_000,
|
|
107
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return stdout;
|
|
112
|
+
}
|