summaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
commiteff289b2b4cc41db706f3c3274a089d46012be87 (patch)
tree3f5de169708f89a38f913954da2a351cb24575da /scripts
parentc95cc77b658edd072785d3ac93856de3ab9ad2ec (diff)
downloaddispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.tar.gz
dispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.zip
chore: regenerate contract mirrors, bump deps, update docs/notes/scripts
- Regenerate .dispatch/{wire,transport-contract}.reference.md mirrors - Bump deps (package.json, bun.lock) - Update AGENTS.md, GLOSSARY.md, README.md, ROADMAP.md - Add workspaces backend-handoff exchange + notes/assumptions-log.md - Add scripts/fix-dist-perms.sh (root-owned dist/ from Docker build) - Add scripts/live-probe-provider-retry.ts
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/fix-dist-perms.sh25
-rw-r--r--scripts/live-probe-provider-retry.ts188
2 files changed, 213 insertions, 0 deletions
diff --git a/scripts/fix-dist-perms.sh b/scripts/fix-dist-perms.sh
new file mode 100755
index 0000000..471cbdf
--- /dev/null
+++ b/scripts/fix-dist-perms.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Fix ownership of dist/ so Vite can clean + rebuild it.
+# The dist/assets/ dir was created as root (likely a Docker build) and Vite
+# can't rmSync it as a non-root user → EACCES on `bun run build`.
+#
+# Usage: sudo ./scripts/fix-dist-perms.sh
+set -euo pipefail
+
+DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
+
+if [ ! -d "$DIST_DIR" ]; then
+ echo "No dist/ directory found — nothing to fix."
+ exit 0
+fi
+
+OWNER="$(stat -c '%U:%G' "$DIST_DIR")"
+echo "dist/ is currently owned by: $OWNER"
+
+if [ "$OWNER" = "root:root" ]; then
+ echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..."
+fi
+
+# chown the whole dist/ tree to the same owner as the repo root
+chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR"
+echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")"
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
new file mode 100644
index 0000000..22f1794
--- /dev/null
+++ b/scripts/live-probe-provider-retry.ts
@@ -0,0 +1,188 @@
+/**
+ * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient
+ * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up).
+ * NOT part of `bun run test`.
+ *
+ * A real `provider-retry` only fires on an upstream 429/5xx, which we can't
+ * force from here. So this probe verifies the two things unit tests CAN'T:
+ *
+ * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket +
+ * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper)
+ * seals cleanly and `providerRetry` stays NULL throughout — no spurious
+ * banner, and the wrapper's re-spread didn't break streaming.
+ * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a
+ * synthetic `provider-retry` `chat.delta` JSON string through the REAL
+ * `parseServerMessage` wire parser (the function that runs on every inbound
+ * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) →
+ * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk →
+ * a subsequent `text-delta` CLEARS it. This proves the new variant survives
+ * the JSON-parse boundary the unit tests skip (they pass constructed events).
+ *
+ * bun scripts/live-probe-provider-retry.ts
+ * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts
+ */
+import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract";
+import type { SurfaceServerMessage } from "@dispatch/ui-contract";
+import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
+import { parseServerMessage } from "../src/adapters/ws/logic.ts";
+import {
+ foldEvent,
+ initialState,
+ selectChunks,
+ selectProviderRetry,
+} from "../src/core/chunks/index.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok";
+
+type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
+
+const checks: { name: string; ok: boolean; detail?: string }[] = [];
+const record = (name: string, ok: boolean, detail?: string) => {
+ checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+};
+const fail = (msg: string): never => {
+ console.error(`\n[probe] FATAL: ${msg}`);
+ process.exit(1);
+};
+
+/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */
+function deltaFrame(event: ChatDeltaMessage["event"]): string {
+ return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage);
+}
+
+async function main() {
+ console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`);
+
+ // ─── 1. REGRESSION: a real text turn through the updated foldEvent ──────────
+ // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts).
+ const handlers = new Map<string, (msg: ChatMsg) => void>();
+ const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (_m: SurfaceServerMessage) => {},
+ onChat: (msg: ChatMsg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ h?.(msg);
+ },
+ });
+ await new Promise((r) => setTimeout(r, 500));
+
+ const conversationId = crypto.randomUUID();
+ let state = initialState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
+
+ socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+
+ record(
+ "regression: a real text turn sealed cleanly",
+ sealed && error === null,
+ `${deltas} deltas${error ? ` err=${error}` : ""}`,
+ );
+ record(
+ "regression: providerRetry stayed NULL through a normal turn (no spurious banner)",
+ selectProviderRetry(state) === null,
+ );
+
+ // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─
+ console.log("\n[probe] parser+reducer seam (synthetic provider-retry)");
+ let s = initialState();
+ s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" });
+
+ const retryJson = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 0,
+ delayMs: 5000,
+ message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}',
+ code: "429",
+ });
+ const parsed1 = parseServerMessage(retryJson);
+ record(
+ "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)",
+ parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry",
+ parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null",
+ );
+
+ if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event);
+ const retry1 = selectProviderRetry(s);
+ record(
+ "foldEvent SETS providerRetry from the PARSED event",
+ retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429",
+ retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null",
+ );
+ record(
+ "provider-retry adds NO chunk (never persisted — never pollutes the prompt)",
+ selectChunks(s).length === 0,
+ `${selectChunks(s).length} chunk(s)`,
+ );
+
+ const retry2Json = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 1,
+ delayMs: 10000,
+ message: "HTTP 429: still overloaded",
+ code: "429",
+ });
+ const parsed2 = parseServerMessage(retry2Json);
+ if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event);
+ const retry2 = selectProviderRetry(s);
+ record(
+ "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)",
+ retry2 !== null &&
+ retry2.attempt === 1 &&
+ retry2.delayMs === 10000 &&
+ retry2.message === "HTTP 429: still overloaded",
+ retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null",
+ );
+
+ const textJson = deltaFrame({
+ type: "text-delta",
+ conversationId,
+ turnId: "t1",
+ delta: "here is the reply",
+ });
+ const parsedText = parseServerMessage(textJson);
+ if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event);
+ record(
+ "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)",
+ selectProviderRetry(s) === null,
+ );
+ record(
+ "…and the text-delta content DID land as a chunk (the reply streams normally after retries)",
+ selectChunks(s).some((c) => c.chunk.type === "text"),
+ );
+
+ socket.close();
+ const passed = checks.filter((c) => c.ok).length;
+ const total = checks.length;
+ console.log(`\n[probe] ${passed}/${total} checks passed`);
+ process.exit(passed === total ? 0 : 1);
+}
+
+main().catch((e) => fail(String(e)));