@tt-a1i/openpi 0.6.1 → 0.7.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.
Files changed (37) hide show
  1. package/THIRD_PARTY_NOTICES.md +242 -0
  2. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  3. package/extensions/ai-providers/cursor/provider.ts +6 -19
  4. package/extensions/file-mutation-display/index.ts +13 -9
  5. package/extensions/shared/agent-transcript.ts +3 -2
  6. package/extensions/web/index.ts +69 -5
  7. package/extensions/workflows/artifacts.ts +362 -23
  8. package/extensions/workflows/dashboard.ts +2 -0
  9. package/package.json +28 -4
  10. package/web/dist/app.js +87 -0
  11. package/web/dist/favicon.svg +9 -0
  12. package/web/dist/index.html +15 -0
  13. package/web/dist/styles.css +3 -0
  14. package/web/host/web-host.ts +6 -9
  15. package/web/ui/index.html +3 -131
  16. package/web/ui/public/favicon.svg +9 -0
  17. package/web/ui/src/app/App.tsx +134 -0
  18. package/web/ui/src/app/providers.tsx +38 -0
  19. package/web/ui/src/components/Markdown.tsx +58 -0
  20. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  21. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  22. package/web/ui/src/features/composer/Composer.tsx +237 -0
  23. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  24. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  25. package/web/ui/src/i18n.ts +159 -0
  26. package/web/ui/src/lib/format.ts +57 -0
  27. package/web/ui/src/main.tsx +16 -0
  28. package/web/ui/src/protocol/client.ts +199 -0
  29. package/web/ui/src/protocol/event-stream.ts +88 -0
  30. package/web/ui/src/store/web-store.ts +926 -0
  31. package/web/ui/src/styles.css +420 -0
  32. package/web/ui/tsconfig.json +12 -0
  33. package/web/ui/vite-env.d.ts +1 -0
  34. package/web/vite.config.mjs +21 -1
  35. package/web/host/static-assets.ts +0 -4
  36. package/web/ui/app.js +0 -1700
  37. package/web/ui/styles.css +0 -680
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import {
@@ -22,10 +23,14 @@ import {
22
23
  } from "./serialization.ts";
23
24
 
24
25
  export const JOURNAL_FILE = "journal.json";
26
+ export const WORKFLOW_COMMIT_FILE = ".workflow-commit.json";
25
27
 
26
28
  const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
27
29
  const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
28
30
  const AGENT_RESULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
31
+ const WORKFLOW_MANIFEST_MAX_BYTES = 1024 * 1024;
32
+ const WORKFLOW_TRANSCRIPTS_MAX_BYTES = 2 * 1024 * 1024;
33
+ const WORKFLOW_COMMIT_MAX_BYTES = 3 * 1024 * 1024;
29
34
  export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
30
35
  const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
31
36
  const TRANSCRIPT_TRUNCATION_MARKER =
@@ -35,10 +40,317 @@ type WorkflowJournalSource =
35
40
  | readonly JournalEntry[]
36
41
  | WorkflowJournalAccumulator;
37
42
 
43
+ interface WorkflowArtifactWrite {
44
+ name: typeof JOURNAL_FILE | "result.json" | "transcripts.json";
45
+ content: string;
46
+ }
47
+
48
+ interface WorkflowCommitArtifact {
49
+ name: WorkflowArtifactWrite["name"];
50
+ bytes: number;
51
+ sha256: string;
52
+ }
53
+
54
+ interface WorkflowCommitMarker {
55
+ version: 1;
56
+ runId: string;
57
+ manifest: string;
58
+ predecessorSha256: string;
59
+ artifacts: WorkflowCommitArtifact[];
60
+ }
61
+
62
+ export type WorkflowCommitRecovery =
63
+ | "none"
64
+ | "recovered"
65
+ | "already-committed"
66
+ | "incomplete"
67
+ | "invalid"
68
+ | "failed";
69
+
70
+ const artifactLimits = new Map<WorkflowArtifactWrite["name"], number>([
71
+ ["transcripts.json", WORKFLOW_TRANSCRIPTS_MAX_BYTES],
72
+ ["result.json", WORKFLOW_MANIFEST_MAX_BYTES],
73
+ [JOURNAL_FILE, JOURNAL_MAX_BYTES],
74
+ ]);
75
+
38
76
  function textBytes(text: string) {
39
77
  return Buffer.byteLength(text, "utf8");
40
78
  }
41
79
 
80
+ function sha256(content: string | Buffer) {
81
+ return createHash("sha256").update(content).digest("hex");
82
+ }
83
+
84
+ function removeWorkflowCommit(runDir: string, strict = false) {
85
+ try {
86
+ fs.unlinkSync(path.join(runDir, WORKFLOW_COMMIT_FILE));
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
89
+ if (strict) throw error;
90
+ }
91
+ }
92
+
93
+ function workflowCommitMarker(
94
+ details: WorkflowDetails,
95
+ manifest: string,
96
+ artifacts: WorkflowArtifactWrite[],
97
+ predecessorSha256: string,
98
+ ): WorkflowCommitMarker {
99
+ for (const { name, content } of artifacts) {
100
+ const bytes = textBytes(content);
101
+ const limit = artifactLimits.get(name);
102
+ if (limit === undefined || bytes > limit) {
103
+ throw new Error(`Workflow artifact ${name} exceeded its commit budget`);
104
+ }
105
+ }
106
+ return {
107
+ version: 1,
108
+ runId: details.runId,
109
+ manifest,
110
+ predecessorSha256,
111
+ artifacts: artifacts.map(({ name, content }) => ({
112
+ name,
113
+ bytes: textBytes(content),
114
+ sha256: sha256(content),
115
+ })),
116
+ };
117
+ }
118
+
119
+ function serializeWorkflowCommitMarker(
120
+ details: WorkflowDetails,
121
+ manifest: string,
122
+ artifacts: WorkflowArtifactWrite[],
123
+ predecessorSha256: string,
124
+ ) {
125
+ const content = JSON.stringify(
126
+ workflowCommitMarker(details, manifest, artifacts, predecessorSha256),
127
+ );
128
+ if (textBytes(content) > WORKFLOW_COMMIT_MAX_BYTES) {
129
+ throw new Error(
130
+ "Workflow artifact commit receipt exceeded its byte budget",
131
+ );
132
+ }
133
+ return content;
134
+ }
135
+
136
+ function parseWorkflowCommitMarker(
137
+ runDir: string,
138
+ ): WorkflowCommitMarker | "none" | "invalid" {
139
+ const markerPath = path.join(runDir, WORKFLOW_COMMIT_FILE);
140
+ let stat: fs.Stats;
141
+ try {
142
+ stat = fs.lstatSync(markerPath);
143
+ } catch (error) {
144
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
145
+ ? "none"
146
+ : "invalid";
147
+ }
148
+ if (
149
+ !stat.isFile() ||
150
+ stat.isSymbolicLink() ||
151
+ stat.size <= 0 ||
152
+ stat.size > WORKFLOW_COMMIT_MAX_BYTES
153
+ ) {
154
+ return "invalid";
155
+ }
156
+
157
+ let raw: unknown;
158
+ try {
159
+ raw = JSON.parse(fs.readFileSync(markerPath, "utf8"));
160
+ } catch {
161
+ return "invalid";
162
+ }
163
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "invalid";
164
+ const record = raw as Record<string, unknown>;
165
+ if (
166
+ record.version !== 1 ||
167
+ typeof record.runId !== "string" ||
168
+ record.runId !== path.basename(runDir) ||
169
+ typeof record.predecessorSha256 !== "string" ||
170
+ !/^[0-9a-f]{64}$/u.test(record.predecessorSha256) ||
171
+ typeof record.manifest !== "string" ||
172
+ textBytes(record.manifest) > WORKFLOW_MANIFEST_MAX_BYTES ||
173
+ !Array.isArray(record.artifacts) ||
174
+ record.artifacts.length < 1 ||
175
+ record.artifacts.length > artifactLimits.size
176
+ ) {
177
+ return "invalid";
178
+ }
179
+
180
+ let manifest: Record<string, unknown>;
181
+ try {
182
+ const parsed: unknown = JSON.parse(record.manifest);
183
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
184
+ return "invalid";
185
+ }
186
+ manifest = parsed as Record<string, unknown>;
187
+ } catch {
188
+ return "invalid";
189
+ }
190
+ if (
191
+ manifest.runId !== record.runId ||
192
+ !["completed", "failed", "aborted", "uncertain"].includes(
193
+ String(manifest.status),
194
+ ) ||
195
+ manifest.transcriptArtifact !== "transcripts.json" ||
196
+ (manifest.resultArtifact !== undefined &&
197
+ manifest.resultArtifact !== "result.json")
198
+ ) {
199
+ return "invalid";
200
+ }
201
+
202
+ const artifacts: WorkflowCommitArtifact[] = [];
203
+ const names = new Set<string>();
204
+ for (const value of record.artifacts) {
205
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
206
+ return "invalid";
207
+ }
208
+ const artifact = value as Record<string, unknown>;
209
+ if (
210
+ typeof artifact.name !== "string" ||
211
+ !artifactLimits.has(artifact.name as WorkflowArtifactWrite["name"]) ||
212
+ names.has(artifact.name) ||
213
+ typeof artifact.bytes !== "number" ||
214
+ !Number.isInteger(artifact.bytes) ||
215
+ artifact.bytes < 0 ||
216
+ artifact.bytes >
217
+ (artifactLimits.get(artifact.name as WorkflowArtifactWrite["name"]) ??
218
+ -1) ||
219
+ typeof artifact.sha256 !== "string" ||
220
+ !/^[0-9a-f]{64}$/u.test(artifact.sha256)
221
+ ) {
222
+ return "invalid";
223
+ }
224
+ names.add(artifact.name);
225
+ artifacts.push({
226
+ name: artifact.name as WorkflowArtifactWrite["name"],
227
+ bytes: artifact.bytes,
228
+ sha256: artifact.sha256,
229
+ });
230
+ }
231
+ if (
232
+ !names.has("transcripts.json") ||
233
+ (manifest.resultArtifact === "result.json") !== names.has("result.json")
234
+ ) {
235
+ return "invalid";
236
+ }
237
+ return {
238
+ version: 1,
239
+ runId: record.runId,
240
+ manifest: record.manifest,
241
+ predecessorSha256: record.predecessorSha256,
242
+ artifacts,
243
+ };
244
+ }
245
+
246
+ function hasCommittedManifest(
247
+ manifestPath: string,
248
+ marker: WorkflowCommitMarker,
249
+ ) {
250
+ try {
251
+ const stat = fs.lstatSync(manifestPath);
252
+ if (
253
+ !stat.isFile() ||
254
+ stat.isSymbolicLink() ||
255
+ stat.size > WORKFLOW_MANIFEST_MAX_BYTES
256
+ )
257
+ return false;
258
+ const content = fs.readFileSync(manifestPath);
259
+ if (content.byteLength > WORKFLOW_MANIFEST_MAX_BYTES) return false;
260
+ const parsed: unknown = JSON.parse(content.toString("utf8"));
261
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
262
+ return false;
263
+ }
264
+ const manifest = parsed as Record<string, unknown>;
265
+ const markerManifest = JSON.parse(marker.manifest) as Record<
266
+ string,
267
+ unknown
268
+ >;
269
+ return (
270
+ manifest.runId === marker.runId &&
271
+ manifest.status === markerManifest.status &&
272
+ manifest.transcriptArtifact === markerManifest.transcriptArtifact &&
273
+ manifest.resultArtifact === markerManifest.resultArtifact
274
+ );
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Complete a terminal artifact commit only when every prepared file matches
282
+ * the exact bounded receipt written before the side-artifact sequence began.
283
+ */
284
+ export function recoverPendingWorkflowCommit(
285
+ runDir: string,
286
+ ): WorkflowCommitRecovery {
287
+ const marker = parseWorkflowCommitMarker(runDir);
288
+ if (marker === "none" || marker === "invalid") return marker;
289
+
290
+ for (const artifact of marker.artifacts) {
291
+ const artifactPath = path.join(runDir, artifact.name);
292
+ let stat: fs.Stats;
293
+ let content: Buffer;
294
+ try {
295
+ stat = fs.lstatSync(artifactPath);
296
+ if (
297
+ !stat.isFile() ||
298
+ stat.isSymbolicLink() ||
299
+ stat.size !== artifact.bytes
300
+ ) {
301
+ return "incomplete";
302
+ }
303
+ content = fs.readFileSync(artifactPath);
304
+ } catch {
305
+ return "incomplete";
306
+ }
307
+ if (
308
+ content.byteLength !== artifact.bytes ||
309
+ sha256(content) !== artifact.sha256
310
+ ) {
311
+ return "incomplete";
312
+ }
313
+ }
314
+
315
+ const manifestPath = path.join(runDir, "workflow.json");
316
+ try {
317
+ if (
318
+ fs.existsSync(manifestPath) &&
319
+ hasCommittedManifest(manifestPath, marker)
320
+ ) {
321
+ removeWorkflowCommit(runDir);
322
+ return "already-committed";
323
+ }
324
+ // A later terminal/cleanup/delivery publication supersedes this receipt.
325
+ // Artifact validity alone cannot authorize replacing canonical facts.
326
+ let predecessor: Buffer;
327
+ try {
328
+ const stat = fs.lstatSync(manifestPath);
329
+ if (
330
+ !stat.isFile() ||
331
+ stat.isSymbolicLink() ||
332
+ stat.size > WORKFLOW_MANIFEST_MAX_BYTES
333
+ ) {
334
+ return "invalid";
335
+ }
336
+ predecessor = fs.readFileSync(manifestPath);
337
+ } catch {
338
+ return "invalid";
339
+ }
340
+ if (
341
+ predecessor.byteLength > WORKFLOW_MANIFEST_MAX_BYTES ||
342
+ sha256(predecessor) !== marker.predecessorSha256
343
+ ) {
344
+ return "invalid";
345
+ }
346
+ writeFileAtomic(manifestPath, marker.manifest);
347
+ removeWorkflowCommit(runDir);
348
+ return "recovered";
349
+ } catch {
350
+ return "failed";
351
+ }
352
+ }
353
+
42
354
  function boundEntry(entry: TranscriptEntry, maxBytes: number) {
43
355
  if (textBytes(entry.text) <= maxBytes) return { ...entry };
44
356
  const markerBytes = textBytes(ENTRY_TRUNCATION_MARKER);
@@ -120,11 +432,11 @@ export function persistWorkflowTerminalState(
120
432
  delete terminalManifest.result;
121
433
  delete terminalManifest.resultArtifact;
122
434
  delete terminalManifest.transcriptArtifact;
123
- writeRunFile(
124
- runDir,
125
- "workflow.json",
126
- safeStringify(terminalManifest, { maxBytes: 1024 * 1024 }),
127
- );
435
+ const content = safeStringify(terminalManifest, {
436
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
437
+ });
438
+ writeRunFile(runDir, "workflow.json", content);
439
+ return sha256(content);
128
440
  }
129
441
 
130
442
  /** Persist one successful child result before any handoff/context projection. */
@@ -177,15 +489,24 @@ export function persistWorkflowJson(
177
489
  // later artifact write fails, readers still see an explained terminal run
178
490
  // instead of the previous `running` manifest. The final manifest below adds
179
491
  // the artifact references once every dependent file has committed.
180
- if (details.status !== "running") {
181
- persistWorkflowTerminalState(runDir, details);
492
+ const terminal = details.status !== "running";
493
+ let predecessorSha256: string | undefined;
494
+ if (terminal) {
495
+ // A retry supersedes an older unfinished receipt before it publishes a new
496
+ // terminal fact. Failing to remove it must stop the new commit rather than
497
+ // let a concurrent reader promote stale artifact identities.
498
+ removeWorkflowCommit(runDir, true);
499
+ predecessorSha256 = persistWorkflowTerminalState(runDir, details);
182
500
  }
183
501
 
184
- writeRunFile(
185
- runDir,
186
- "transcripts.json",
187
- safeStringify(transcripts, { maxBytes: 2 * 1024 * 1024 }),
188
- );
502
+ const artifactWrites: WorkflowArtifactWrite[] = [
503
+ {
504
+ name: "transcripts.json",
505
+ content: safeStringify(transcripts, {
506
+ maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES,
507
+ }),
508
+ },
509
+ ];
189
510
  // Written alongside the rest so it inherits atomic write, 500ms coalescing,
190
511
  // and the final flush. Only present once a call has actually succeeded.
191
512
  // Accumulators already enforce the cap incrementally and can assemble the
@@ -201,14 +522,15 @@ export function persistWorkflowJson(
201
522
  "toJson" in journal
202
523
  ? journal.toJson()
203
524
  : JSON.stringify(boundedJournal(journal).journal, null, 2);
204
- writeRunFile(runDir, JOURNAL_FILE, content);
525
+ artifactWrites.push({ name: JOURNAL_FILE, content });
205
526
  }
206
527
  if (details.result !== undefined) {
207
- writeRunFile(
208
- runDir,
209
- "result.json",
210
- safeStringify(details.result, { maxBytes: 1024 * 1024 }),
211
- );
528
+ artifactWrites.push({
529
+ name: "result.json",
530
+ content: safeStringify(details.result, {
531
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
532
+ }),
533
+ });
212
534
  }
213
535
  const compact: WorkflowDetails = {
214
536
  ...details,
@@ -218,11 +540,27 @@ export function persistWorkflowJson(
218
540
  transcriptArtifact: "transcripts.json",
219
541
  agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
220
542
  };
221
- writeRunFile(
222
- runDir,
223
- "workflow.json",
224
- safeStringify(compact, { maxBytes: 1024 * 1024 }),
225
- );
543
+ const manifest = safeStringify(compact, {
544
+ maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
545
+ });
546
+
547
+ if (predecessorSha256 !== undefined) {
548
+ writeRunFile(
549
+ runDir,
550
+ WORKFLOW_COMMIT_FILE,
551
+ serializeWorkflowCommitMarker(
552
+ details,
553
+ manifest,
554
+ artifactWrites,
555
+ predecessorSha256,
556
+ ),
557
+ );
558
+ }
559
+ for (const artifact of artifactWrites) {
560
+ writeRunFile(runDir, artifact.name, artifact.content);
561
+ }
562
+ writeRunFile(runDir, "workflow.json", manifest);
563
+ if (terminal) removeWorkflowCommit(runDir);
226
564
  }
227
565
 
228
566
  /**
@@ -235,6 +573,7 @@ export function persistWorkflowDeliveryState(
235
573
  runDir: string,
236
574
  delivery: WorkflowDelivery,
237
575
  ) {
576
+ recoverPendingWorkflowCommit(runDir);
238
577
  const file = path.join(runDir, "workflow.json");
239
578
  const raw: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
240
579
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
@@ -31,6 +31,7 @@ import {
31
31
  import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
32
32
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
33
33
  import { isAcceptanceLedger } from "./acceptance.ts";
34
+ import { recoverPendingWorkflowCommit } from "./artifacts.ts";
34
35
  import { projectWorkflowGraph } from "./graph-projection.ts";
35
36
  import {
36
37
  classifyInterruptedInvocation,
@@ -161,6 +162,7 @@ function normalizeReadRecord(runId: string, raw: unknown) {
161
162
  }
162
163
 
163
164
  function readPersistedWorkflowRecord(runId: string) {
165
+ recoverPendingWorkflowCommit(path.join(runsDir(), runId));
164
166
  try {
165
167
  const raw: unknown = JSON.parse(
166
168
  fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tt-a1i/openpi",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "OpenPI — a Pi-native multi-agent workbench with background execution, isolated subagents, replay-safe workflows, goals, tasks, and observable TUI",
5
5
  "license": "MIT",
6
6
  "author": "tt-a1i",
@@ -58,21 +58,43 @@
58
58
  "acorn": "^8.17.0",
59
59
  "effect": "^4.0.0-beta.99",
60
60
  "jiti": "2.7.0",
61
- "marked": "^18.0.5",
62
61
  "undici": "8.9.0"
63
62
  },
64
63
  "devDependencies": {
64
+ "@astryxdesign/core": "0.5.2",
65
+ "@astryxdesign/theme-neutral": "0.5.2",
66
+ "@axe-core/playwright": "4.13.0",
65
67
  "@biomejs/biome": "2.5.8",
66
68
  "@earendil-works/pi-ai": "^0.85.1",
67
69
  "@earendil-works/pi-coding-agent": "^0.85.1",
68
70
  "@earendil-works/pi-tui": "^0.85.1",
69
71
  "@effect/tsgo": "^0.24.2",
70
72
  "@effect/vitest": "^4.0.0-beta.99",
73
+ "@playwright/test": "1.62.1",
74
+ "@stylexjs/stylex": "0.19.0",
75
+ "@tailwindcss/vite": "4.3.3",
76
+ "@testing-library/react": "16.3.3",
71
77
  "@types/node": "^26.1.1",
78
+ "@types/react": "19.2.18",
79
+ "@types/react-dom": "19.2.5",
80
+ "@vitejs/plugin-react": "6.1.1",
81
+ "eventsource-parser": "4.1.0",
82
+ "i18next": "26.4.1",
83
+ "jsdom": "30.0.1",
84
+ "lucide-react": "1.39.0",
85
+ "react": "19.2.8",
86
+ "react-dom": "19.2.8",
87
+ "react-i18next": "17.0.13",
88
+ "react-markdown": "10.1.0",
89
+ "rehype-sanitize": "6.0.0",
90
+ "remark-breaks": "4.0.0",
91
+ "remark-gfm": "4.0.1",
92
+ "tailwindcss": "4.3.3",
72
93
  "typebox": "^1.3.6",
73
94
  "typescript": "^7.0.2",
74
95
  "vite": "^8.2.0",
75
- "vitest": "4.1.10"
96
+ "vitest": "4.1.10",
97
+ "zustand": "5.0.15"
76
98
  },
77
99
  "peerDependencies": {
78
100
  "@earendil-works/pi-ai": ">=0.85.1",
@@ -98,7 +120,9 @@
98
120
  "dev:web": "node --experimental-strip-types scripts/dev-web.mjs",
99
121
  "dev:web:ui": "vite --config web/vite.config.mjs --host 127.0.0.1",
100
122
  "dev:web:backend": "node scripts/dev-web-backend.mjs",
101
- "check:web": "node --check scripts/dev-web.mjs && node --check web/vite.config.mjs && node --check web/ui/app.js",
123
+ "build:web": "vite build --config web/vite.config.mjs",
124
+ "check:web": "node --check scripts/dev-web.mjs && node --check web/vite.config.mjs && tsc --noEmit --project web/ui/tsconfig.json && bun run build:web",
125
+ "test:web:e2e": "bun run build:web && playwright test --config tests/web/playwright.config.ts",
102
126
  "benchmark:workflow-child-startup": "node --experimental-strip-types scripts/benchmark-workflow-child-startup.mjs",
103
127
  "test": "node scripts/run-tests.mjs",
104
128
  "provenance": "node scripts/provenance.mjs"