merge: integrate completed JS web and release audit repairs

Preserve all 16 Opus lane commits through 251470477, with source review of computer-use dispatch outcomes and ownership, bridge durable writes, draft-release inventory publication, installer rollback, web bounds and shared pet validation. Lane receipts: computer-use 380 passed/0 failed/16 skipped plus 66/0 follow-up; web 642/0 and typecheck/build; bridge consumers 106/0; release inventory 5/0 and installers 27/0; pet 75/0 and SDK19/0. Those are lane receipts, not new integrated proof. Root npm/web gate and focused governed Rust pet/merge regressions remain pending before push. W02-02 reconciliation and W02-05 generation concurrency still need root follow-up; no deployment/publication/native-platform claim.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-30 01:27:45 -07:00
commit 6352eb2754
79 files changed
+1840 -306

No files matched your search

+18
View File
@@ -498,6 +498,24 @@ assert.equal(
);
assert.match(release, /overwrite_files:\s*false/);
assert.match(release, /fail_on_unmatched_files:\s*true/);
// Assets land in a draft; the whole verified set becomes public at once, and
// derived channels (the tagged container manifest) follow the canonical release.
assert.match(release, /files: artifacts\/\*\n\s+draft: true\n/);
assert.match(
namedStep(release, "Verify the draft's exact asset set, then publish it at once"),
/verify-release-inventory\.js \\\n\s+--draft --asset-dir artifacts --publish/,
);
const releaseJob = release.match(/\n release:\n([\s\S]*?)\n npm:\n/);
assert.ok(releaseJob, "public release must retain its release job");
assert.match(releaseJob[1], /^ needs: \[artifacts, docker-build, resolve\]$/m);
const dockerJob = release.match(/\n docker:\n([\s\S]*?)\n release:\n/);
assert.ok(dockerJob, "public release must retain its container manifest job");
assert.match(dockerJob[1], /^ needs: \[docker-build, release, resolve\]$/m);
assert.match(dockerJob[1], /needs\.release\.result == 'success'/);
assert.match(
namedStep(republish, "Require a complete published release"),
/verify-release-inventory\.js --manifest/,
);
assert.match(release, /^ docker-build:\n/m);
assert.match(release, /^ docker:\n/m);
+1
View File
@@ -269,6 +269,7 @@ jobs:
node .github/scripts/release-workflows.test.js
node --test scripts/release/assemble-release-assets.test.js
node --test scripts/release/ensure-release-assets-absent.test.js
node --test scripts/release/verify-release-inventory.test.js
- name: Run runtime web client tests
# crates/tui/tests/runtime_web_client.test.mjs exercises the embedded
# web client's event/snapshot state machine; it ran nowhere before.
+7 -11
View File
@@ -75,21 +75,17 @@ jobs:
echo "version=${INPUT_VERSION}"
} >> "${GITHUB_OUTPUT}"
echo "Resolved ${tag} -> ${sha}"
- name: Require an existing published release
- name: Require a complete published release
# The inverse of release.yml's immutability guard. That one refuses to
# run when assets exist; this one refuses to run when they do not,
# because there is nothing to derive a channel from.
# run when assets exist; this one refuses to run unless the published
# release carries exactly the assets its own checksum manifest lists,
# each fully uploaded, because a channel derived from a partial set
# would advertise bytes that are not there. The tag may predate the
# current inventory, so its manifest, not today's list, is the check.
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
count="$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json assets --jq '.assets | length')"
if [[ "${count}" -eq 0 ]]; then
echo "::error::${TAG} has no published assets. Run release.yml, not this recovery workflow." >&2
exit 1
fi
echo "${TAG} has ${count} published assets."
run: node scripts/release/verify-release-inventory.js --manifest "${GITHUB_REPOSITORY}" "${TAG}"
- name: Verify the remote tag still points at this commit
env:
EXPECTED_SHA: ${{ steps.release.outputs.sha }}
+20 -4
View File
@@ -266,10 +266,15 @@ jobs:
retention-days: 1
overwrite: true
# Derived channel: the tagged/latest manifest is published only after the
# canonical GitHub Release exists. docker-build pushes by digest (untagged)
# and smokes each image before the release, so a broken image still stops
# the release, but no public tag can point at a version whose GitHub
# Release was never published.
docker:
timeout-minutes: 30
needs: [docker-build, resolve]
if: ${{ !cancelled() && needs.docker-build.result == 'success' }}
needs: [docker-build, release, resolve]
if: ${{ !cancelled() && needs.docker-build.result == 'success' && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -373,8 +378,8 @@ jobs:
release:
timeout-minutes: 30
needs: [artifacts, docker, resolve]
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.docker.result == 'success' }}
needs: [artifacts, docker-build, resolve]
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.docker-build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -412,14 +417,25 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
run: node repo/scripts/release/ensure-release-assets-absent.js "${GITHUB_REPOSITORY}" "${TAG}"
# Upload into a DRAFT: 34 sequential uploads that die halfway must leave
# nothing public (and nothing a rerun's immutability guard refuses).
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
tag_name: ${{ needs.resolve.outputs.tag }}
files: artifacts/*
draft: true
prerelease: false
body_path: release-body.md
overwrite_files: false
fail_on_unmatched_files: true
- name: Verify the draft's exact asset set, then publish it at once
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
node repo/scripts/release/verify-release-inventory.js \
--draft --asset-dir artifacts --publish \
"${GITHUB_REPOSITORY}" "${TAG}"
npm:
timeout-minutes: 20
+14 -2
View File
@@ -901,6 +901,11 @@ async function callTool(params) {
try { teardown = await destroyDockerComputer(entry); }
catch (err) { teardown = { destroyed: false, cleanup_error: err.message ?? String(err) }; }
}
if (teardown?.reason === "other_session") {
// Another live (or crashed) MCP session owns this desktop. Leave its
// container and registry entry alone; the owner removes both at exit.
return { content: [{ type: "text", text: JSON.stringify(fail(entry, "computer_owned_elsewhere", `computer "${entry.id}" was spawned by another session and is still its disposable desktop — it is removed when that session ends. If that session is gone, remove the container with docker rm -f ${entry.container}.`)) }], isError: true };
}
const res = registry.remove(args.computer);
if (activeComputerId === args.computer) activeComputerId = "local";
res.active = activeComputerId;
@@ -1107,7 +1112,12 @@ async function callTool(params) {
const timeoutMs = backendMethod.startsWith("recording") || backendMethod === "get_app_state" ? 60_000 : 30_000;
const invoke = async (tool, a) => {
const r = await remoteCall({ tool, args: a }, { timeoutMs });
if (!r.ok) throw new ServerError(r.error?.code ?? "remote_error", r.error?.message ?? "remote agent failed");
if (!r.ok) {
const error = new ServerError(r.error?.code ?? "remote_error", r.error?.message ?? "remote agent failed");
// The helper's backend reported that input may already have landed.
if (r.error?.request_dispatched === true) error.requestDispatched = true;
throw error;
}
return r.data;
};
data = name === "type" ? await invokeType(invoke, wireArgs) : await invoke(backendMethod, wireArgs);
@@ -1246,7 +1256,9 @@ async function callTool(params) {
// A failed open_application cleared the backend's input binding before it
// attempted anything — the tracked bound app must not claim otherwise.
if (name === "open_application") boundApps.delete(computer.id);
let outcomeUnknown = !!err.requestDispatched;
// A local backend that timed out or was cancelled after posting input
// (inputMayHaveBeenSent) is as unknown as a transport that lost the reply.
let outcomeUnknown = !!(err.requestDispatched || err.inputMayHaveBeenSent);
if (dispatched && !outcomeUnknown) {
// A transport/backend can fail after delivering input. Reconcile its
// captured route on failure too, without replacing the original error
@@ -30,7 +30,7 @@ Never retry a refusal unchanged — re-observe, re-target, or change route.
| `menu_item_not_found` | exact title not present (menus expose items only while open) | check the exact title; an ellipsis is part of it |
| `menu_item_disabled` | item present but the app refuses it right now (often a missing key window) | use the window's own control element instead |
| `app_not_found` | selector missed — `open_application` names/bundle ids that resolve nowhere and dead pids report it too | `list_apps` (or `all:true`) for exact names/pids |
| `ambiguous_application` | `kill_app` name matched several running apps | pass `pid` to choose one |
| `ambiguous_application` | an app name or bundle id matched several running apps (observation, input or `kill_app`) | pass `pid` to choose one |
| `protected_application` | the target is the Computer Use helper or its host | name the intended app instead; these cannot be terminated through the plugin |
| `browser_not_running` | browser action before `browser {action:"start"}` (or the browser went away) | start it; a closed CDP connection clears the session state |
| `browser_not_installed` | no Chromium-family browser found | install one, or set `CODEWHALE_CU_BROWSER_APP` to the app path |
@@ -56,6 +56,7 @@ Never retry a refusal unchanged — re-observe, re-target, or change route.
| `spawn_failed` | provisioning failed or the desktop did not become ready | read the message; the failed container is removed automatically — fix the cause and spawn again |
| `invalid_container` | a docker registry entry lacks a valid container name | register it through `computer spawn`, never by hand |
| `cleanup_failed` | `docker rm` failed while tearing down a spawned computer | the registry entry is still removed; check `docker ps` for the labeled container and remove it manually |
| `computer_owned_elsewhere` | `computer remove` named a desktop another session spawned | leave it; its session removes it at exit (the message names the container if that session is gone) |
| `script_error` | osascript exited non-zero; stderr is in the message | read the error, check the app's scripting dictionary (`sdef`), fix the script |
| `script_timeout` | the script — or a consent dialog — was still open at the deadline | narrow the script; a consent prompt is the person's choice, report it |
| `script_cancelled` | the script's own dialog was cancelled (-128) | the user declined in-app; stop or ask |
@@ -75,5 +76,7 @@ computer; a typing pause does not authorize foreground control.
not task success. Verify the effect with a fresh observation.
- `front_lease` / `front_restored` describe focus accounting for window-record
deliveries in explicitly authorized foreground mode. `front_restored:false` is a person-visible event: say it out loud.
- `input_may_have_been_sent` on an error means the press left before the
failure: observe the target before doing anything else.
- `outcome_unknown: true` (with `request_dispatched: true`) on an error means
the action was already handed to the helper, remote agent or browser when it
timed out, was cancelled or lost its reply: observe the target before doing
anything else, and never retry the action blindly.
@@ -226,7 +226,7 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
return { ok: true, platform: process.platform, tool, data };
}));
} catch (err) {
return { ok: false, platform: process.platform, tool, error: { code: err?.code ?? "tool_error", message: String(err?.message ?? err) } };
return { ok: false, platform: process.platform, tool, error: { code: err?.code ?? "tool_error", message: String(err?.message ?? err), ...(err?.inputMayHaveBeenSent || err?.requestDispatched ? { request_dispatched: true } : {}) } };
} finally {
signal?.removeEventListener("abort", abort);
session.requests.delete(controller);
@@ -74,27 +74,37 @@ export function writeRegistration(reg) {
fs.writeFileSync(registrationPath(), JSON.stringify({ ...reg, registeredAt: new Date().toISOString() }, null, 2) + "\n");
}
/** Send one request to the app and await its single-line reply. */
/**
* Send one request to the app and await its single-line reply.
*
* Once the request line is written the helper may already have acted on it,
* so every failure after that point (timeout, cancel, a dropped connection, a
* malformed reply) carries `requestDispatched: true`. The MCP server turns
* that into `outcome_unknown` instead of a plain cancel that invites a blind
* retry of an input action that may have landed.
*/
function requestConnection(request, { timeoutMs = 30_000, signal = currentSignal(), keepOpen = false } = {}) {
throwIfAborted(signal);
return new Promise((resolve, reject) => {
const sock = net.connect(socketPath());
let buf = "";
let settled = false;
let written = false;
const failure = (message, code) => Object.assign(new ExecError(message), { code }, written ? { requestDispatched: true } : {});
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); if (!keepOpen || fn === reject) sock.destroy(); fn(v); };
const abort = () => done(reject, Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
const timer = setTimeout(() => done(reject, Object.assign(new ExecError(`${APP_NAME}: request timed out after ${timeoutMs}ms`), { code: "app_timeout" })), timeoutMs);
const abort = () => done(reject, failure("computer request cancelled", "cancelled"));
const timer = setTimeout(() => done(reject, failure(`${APP_NAME}: request timed out after ${timeoutMs}ms`, "app_timeout")), timeoutMs);
signal?.addEventListener("abort", abort, { once: true });
sock.on("error", (err) => done(reject, Object.assign(new ExecError(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`), { code: "app_unavailable" })));
sock.on("connect", () => sock.write(JSON.stringify(request) + "\n"));
sock.on("error", (err) => done(reject, failure(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`, "app_unavailable")));
sock.on("connect", () => { written = true; sock.write(JSON.stringify(request) + "\n"); });
sock.on("data", (d) => {
buf += d.toString("utf8");
const nl = buf.indexOf("\n");
if (nl === -1) return;
try { const reply = JSON.parse(buf.slice(0, nl)); done(resolve, keepOpen ? { reply, socket: sock } : reply); }
catch { done(reject, Object.assign(new ExecError(`${APP_NAME}: malformed reply`), { code: "app_bad_reply" })); }
catch { done(reject, failure(`${APP_NAME}: malformed reply`, "app_bad_reply")); }
});
sock.on("close", () => done(reject, Object.assign(new ExecError(`${APP_NAME}: connection closed before a reply`), { code: "app_unavailable" })));
sock.on("close", () => done(reject, failure(`${APP_NAME}: connection closed before a reply`, "app_unavailable")));
});
}
@@ -128,6 +138,11 @@ export function openAppSession(sessionId) {
// Library clients need not keep Node alive solely for an idle lease.
socket.unref();
return lease;
}, (error) => {
// Opening a lease sends no input, so its failure is never
// outcome-unknown for the action that was waiting on it.
if (error && typeof error === "object") delete error.requestDispatched;
throw error;
});
// A refused or unreachable open is retried on the next request, not cached.
pending.catch(() => { if (sessionLeases.get(sessionId) === pending) sessionLeases.delete(sessionId); });
@@ -856,13 +856,23 @@ static NSRunningApplication *resolve(NSDictionary *ref) {
} else return nil;
}
NSString *bundle=ref[@"bundle_id"], *name=ref[@"name"];
// A name or bundle shared by several running processes (two instances of
// one browser, a helper and its app) must not resolve to whichever happens
// to be listed first: that sends observation and input to the wrong window.
// Refuse like kill_app does; the observed pid disambiguates.
NSMutableArray<NSRunningApplication *> *hits=[NSMutableArray array];
for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
if(ref[@"pid"] && a.processIdentifier!=[ref[@"pid"] intValue]) continue;
if(bundle && !matchesName(a.bundleIdentifier,bundle)) continue;
if(name && !matchesName(a.localizedName,name)) continue;
return a;
[hits addObject:a];
}
return nil;
if(hits.count>1) {
NSMutableArray *desc=[NSMutableArray array];
for(NSRunningApplication *a in hits) [desc addObject:[NSString stringWithFormat:@"%@ (pid %d)",a.localizedName?:@"?",a.processIdentifier]];
@throw [NSException exceptionWithName:@"app" reason:[NSString stringWithFormat:@"several running applications match (%@); pass pid to choose one",[desc componentsJoinedByString:@", "]] userInfo:nil];
}
return hits.firstObject;
}
static CGEventRef textEvent(NSString *text, BOOL down) {
UniChar *chars=calloc(text.length,sizeof(UniChar)); [text getCharacters:chars range:NSMakeRange(0,text.length)];
@@ -105,12 +105,22 @@ export function create({ exec }) {
return { action_sent: true, strategy: "event", backend: "uitest" };
}
/**
* Re-find an observed element in a fresh dump. uitest indexes are
* positional and shift whenever a sibling appears, so the index alone can
* name a different control; the element is addressed by its observed tree
* path and must still carry the observed role and label, or nothing is sent.
*/
async function centerOf(target) {
rejectAppSelectors(target);
const stale = (why) => Object.assign(new ExecError(`element_stale — ${why}; re-run get_app_state (uitest indexes change with the UI)`), { code: "element_stale" });
if (!Array.isArray(target?.path)) throw stale("the element target carries no observed tree path");
const tree = await dumpLayout();
const els = flatten(tree);
const el = els[target.index];
if (!el || !el.bounds) throw new ExecError("element_stale — re-run get_app_state; uitest indexes change with the UI");
const key = JSON.stringify(target.path);
const el = flatten(tree).find((candidate) => JSON.stringify(candidate.path) === key);
if (!el || !el.bounds) throw stale("the observed element is gone");
if (target.role !== undefined && el.role !== target.role) throw stale(`role changed (${target.role} → ${el.role})`);
if (target.label !== undefined && el.label !== target.label) throw stale(`label changed (${target.label} → ${el.label})`);
return el.bounds;
}
@@ -191,6 +191,12 @@ function defaultConnect(url, { timeoutMs = 8_000 } = {}) {
});
}
/** Per-command CDP reply deadline; teardown uses its own shorter bound. */
const commandTimeoutMs = () => Number(process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS) || 30_000;
// Teardown ignores the (possibly already cancelled) request signal and gives
// an unanswered peer at most three seconds per command.
const teardown = () => ({ timeoutMs: Math.min(3_000, commandTimeoutMs()), signal: null });
/** id-matched JSON-RPC over the WebSocket, plus CDP event fan-out. */
function makeChannel(ws) {
let nextId = 0;
@@ -199,7 +205,7 @@ function makeChannel(ws) {
let closed = false;
const failAll = (reason) => {
closed = true;
for (const [, entry] of pending) entry.reject(Object.assign(new ExecError(reason), { code: "browser_not_running" }));
for (const entry of [...pending.values()]) entry.reject(Object.assign(new ExecError(reason), { code: "browser_not_running" }));
pending.clear();
};
ws.addEventListener("message", (event) => {
@@ -218,11 +224,29 @@ function makeChannel(ws) {
ws.addEventListener("error", () => {});
return {
get alive() { return !closed; },
send(method, params = {}, sessionId) {
/**
* One CDP command. Bounded: a peer that keeps the socket open but never
* answers must not hang the request (or a stop/teardown awaiting it)
* forever. After the command is written its effect is unknown, so a
* timeout or cancel carries requestDispatched; a late reply is dropped.
*/
send(method, params = {}, sessionId, { timeoutMs = commandTimeoutMs(), signal = currentSignal() } = {}) {
if (closed) return Promise.reject(Object.assign(new ExecError("the browser is no longer reachable (CDP connection closed)"), { code: "browser_not_running" }));
if (signal?.aborted) return Promise.reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
return new Promise((resolve, reject) => {
const id = ++nextId;
pending.set(id, { resolve, reject });
let timer = null;
const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); pending.delete(id); };
const onAbort = () => { finish(); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled", requestDispatched: true })); };
pending.set(id, {
resolve: (value) => { finish(); resolve(value); },
reject: (error) => { finish(); reject(error); },
});
timer = setTimeout(() => {
finish();
reject(Object.assign(new ExecError(`CDP ${method} got no reply within ${timeoutMs}ms`), { code: "timeout", requestDispatched: true }));
}, timeoutMs);
signal?.addEventListener("abort", onAbort, { once: true });
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
});
},
@@ -280,8 +304,8 @@ export function createBrowser({
const { targetInfo: info } = await state.channel.send("Target.getTargetInfo", { targetId });
return { url: info?.url ?? "", title: info?.title ?? "" };
}
async function listTabs() {
const { targetInfos } = await state.channel.send("Target.getTargets", {});
async function listTabs(options) {
const { targetInfos } = await state.channel.send("Target.getTargets", {}, undefined, options);
return (targetInfos ?? []).filter((t) => t.type === "page");
}
async function ensureDom() {
@@ -570,17 +594,17 @@ export function createBrowser({
if (!state.channel) return { running: false, note: "no browser session for this computer session" };
if (state.attached) {
// The person's browser: never close a tab or the browser, only detach.
if (state.sessionId) await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }).catch(() => {});
if (state.sessionId) await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }, undefined, teardown()).catch(() => {});
state.channel.close();
state.channel = null; state.targetId = null; state.sessionId = null; state.pageEnabled = false; state.domEnabled = false; state.attached = false;
return { running: false, detached: true, browser_closed: false, note: "detached from the shared browser; its window and tabs stay as they are" };
}
try { await state.channel.send("Target.closeTarget", { targetId: state.targetId }); } catch { /* the tab may already be gone */ }
try { await state.channel.send("Target.closeTarget", { targetId: state.targetId }, undefined, teardown()); } catch { /* the tab may already be gone */ }
let remaining = null;
try { remaining = (await listTabs()).length; } catch { remaining = null; }
try { remaining = (await listTabs(teardown())).length; } catch { remaining = null; }
let browserClosed = false;
if (remaining === 0) {
try { await state.channel.send("Browser.close"); browserClosed = true; } catch {}
try { await state.channel.send("Browser.close", {}, undefined, teardown()); browserClosed = true; } catch {}
await sleep(200);
}
state.channel.close();
+11 -7
View File
@@ -116,19 +116,23 @@ export async function spawnDockerComputer({ id, image = DEFAULT_IMAGE } = {}) {
}
/**
* Destroy a spawned container — but only one this plugin created. A docker
* computer whose container lacks our spawn label is left running and reported
* not_spawned; removing its registry entry is still the caller's choice.
* Destroy a spawned container — but only one this MCP process created. A
* docker computer whose container lacks our spawn label is left running and
* reported not_spawned; one spawned by another session (the registry is
* shared between MCP processes) is left running and reported other_session,
* so one task cannot tear down a desktop another task is driving.
*/
export async function destroyDockerComputer(computer) {
export async function destroyDockerComputer(computer, command = docker) {
const container = computer?.container;
if (!container || !CONTAINER_RE.test(container)) {
throw new SpawnError("invalid_container", "docker computer has no valid container name");
}
const insp = await docker(["container", "inspect", "--format", `{{index .Config.Labels "${SPAWN_LABEL}"}}`, container], { timeoutMs: 10_000, signal: null });
const insp = await command(["container", "inspect", "--format", `{{index .Config.Labels "${SPAWN_LABEL}"}}|{{index .Config.Labels "${SESSION_LABEL}"}}`, container], { timeoutMs: 10_000, signal: null });
if (insp.code !== 0) return { destroyed: false, reason: "container_gone" };
if (insp.stdout.trim() !== "1") return { destroyed: false, reason: "not_spawned" };
const r = await docker(["rm", "-f", container], { timeoutMs: 20_000, signal: null });
const [spawned, session] = insp.stdout.trim().split("|");
if (spawned !== "1") return { destroyed: false, reason: "not_spawned" };
if (session !== SESSION_ID) return { destroyed: false, reason: "other_session" };
const r = await command(["rm", "-f", container], { timeoutMs: 20_000, signal: null });
if (r.code !== 0) throw new SpawnError("cleanup_failed", `docker rm -f ${container} failed: ${trim(r.stderr)}`, r);
return { destroyed: true };
}
@@ -218,8 +218,10 @@ export function sshExec(computer, binding) {
const r = await run("ssh", [...base, "node", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
timeoutMs: opts.timeoutMs ?? 25_000,
});
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
if (r.timedOut) throw new ExecError(`ssh ${userHost}: timed out`, r);
// A spawned one-shot agent may have acted before it was cancelled or
// timed out; say so rather than inviting a blind retry.
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" }, r.spawned ? { requestDispatched: true } : {});
if (r.timedOut) throw Object.assign(new ExecError(`ssh ${userHost}: timed out`, r), { code: "remote_timeout", requestDispatched: true });
if (r.code !== 0) throw new ExecError(`ssh ${userHost} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
// The agent prints exactly one JSON line; anything before it is MOTD noise.
const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
@@ -254,8 +256,8 @@ export function dockerExec(computer, binding) {
const r = await run("docker", ["exec", container, "/bin/sh", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
timeoutMs: opts.timeoutMs ?? 25_000,
});
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
if (r.timedOut) throw new ExecError(`docker exec ${container}: timed out`, r);
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" }, r.spawned ? { requestDispatched: true } : {});
if (r.timedOut) throw Object.assign(new ExecError(`docker exec ${container}: timed out`, r), { code: "remote_timeout", requestDispatched: true });
if (r.code !== 0) throw new ExecError(`docker exec ${container} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
const reply = line ? JSON.parse(line) : null;
@@ -71,6 +71,17 @@ test('real handler/backend/native resolver never redirects an explicit app refer
assert.equal(reply.ok,false,`${tool} must reject ${JSON.stringify(app_ref)}`);
assert.match(reply.error.message,/application not found/);
}
// A shared name or bundle must never pick whichever process is listed first.
for(const app_ref of [{name:'Twin'},{bundle_id:'test.twin'},{name:'twin',bundle_id:'test.twin'}]) {
const reply=await call(tool,{app_ref});
assert.equal(reply.ok,false,`${tool} must refuse ambiguous ${JSON.stringify(app_ref)}`);
assert.equal(reply.error.code,'ambiguous_application',JSON.stringify(reply));
assert.match(reply.error.message,/Twin \(pid 200\), Twin \(pid 201\)/);
}
}
for(const pid of [200,201]) {
const reply=await call('list_windows',{app_ref:{pid,name:'Twin'}});
assert.equal(reply.ok,true,JSON.stringify(reply));assert.equal(reply.data.pid,pid);
}
} finally { delete process.env.CU_TARGETING_NATIVE; }
});
@@ -47,7 +47,7 @@ function harmonyFixtureExec(t) {
return fakeJpeg(168, 120);
},
};
return { exec, calls };
return { exec, calls, layout };
}
test("harmony: parseBounds handles uitest bounds strings", () => {
@@ -102,6 +102,24 @@ test("harmony: click routes through uitest uiInput with validated args", async (
assert.deepEqual(ui.args, ["uitest", "uiInput", "click", "124", "45"]);
});
test("harmony: element actions re-find the observed element and refuse a reordered tree", async () => {
const { exec, calls, layout } = harmonyFixtureExec();
const mod = await import("../src/backends/harmonyos.mjs");
const b = mod.create({ exec });
const ok = (await b.get_app_state({})).elements.find((e) => e.label === "OK");
const target = { index: ok.index, path: ok.path, role: ok.role, label: ok.label };
await b.perform_action({ target, action: "click" });
assert.deepEqual(calls.filter((c) => c.args?.[1] === "uiInput").at(-1).args, ["uitest", "uiInput", "click", "200", "230"]);
// A new sibling ahead of OK shifts every uitest index: the stored index now
// names "Delete". Nothing may be clicked.
layout.children.unshift({ attributes: { type: "Button", text: "Delete", bounds: "[0,300][100,360]" }, children: [] });
const before = calls.filter((c) => c.args?.[1] === "uiInput").length;
await assert.rejects(b.perform_action({ target, action: "click" }), (error) => error.code === "element_stale" && /label changed/.test(error.message));
await assert.rejects(b.set_value({ target, value: "x" }), (error) => error.code === "element_stale");
await assert.rejects(b.perform_action({ target: { index: ok.index }, action: "click" }), (error) => error.code === "element_stale", "a bare index has no identity to verify");
assert.equal(calls.filter((c) => c.args?.[1] === "uiInput").length, before, "no input after the tree changed");
});
test("harmony: clipboard and select_text fail closed with named reasons", async () => {
const { exec } = harmonyFixtureExec();
const mod = await import("../src/backends/harmonyos.mjs");
@@ -271,3 +271,19 @@ test('findBrowserApp uses actual Windows vendor folders and executable names', (
assert.equal(findBrowserApp('win32', { [root]: 'C:\\fixture' }, candidate => candidate === expected), expected);
}
});
test("an unanswered CDP command times out as outcome-unknown and stop still finishes", async (t) => {
const prior = process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS;
process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS = "60";
t.after(() => { if (prior === undefined) delete process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS; else process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS = prior; });
// An open peer that never answers these commands (a wedged renderer).
const script = { ...defaultScript(pageState()), "Input.insertText": undefined, "Target.closeTarget": undefined, "Browser.close": undefined };
const { browser, sockets } = harness(t, { script });
await browser.start({});
await assert.rejects(browser.type({ text: "hello" }), (error) => error.code === "timeout" && error.requestDispatched === true && /Input\.insertText/.test(error.message));
const stopped = await Promise.race([browser.stop(), new Promise((resolve) => setTimeout(() => resolve("hung"), 2_000))]);
assert.notEqual(stopped, "hung", "stop must not wait forever on an unanswered peer");
assert.equal(stopped.closed, true);
assert.equal(sockets[0].ws.sentOf("Target.closeTarget").length, 1);
assert.equal((await browser.status()).running, false);
});
@@ -7,7 +7,7 @@ import path from "node:path";
import net from "node:net";
import { run, runOk, runInputLease, ExecError, have, trim, withSignal } from "../src/exec.mjs";
import { safeRemotePath, b64, localExec, hdcExec, executorFor } from "../src/transport.mjs";
import { ensureApp, writeRegistration } from "../src/app-socket.mjs";
import { appRequest, appSessionRequest, ensureApp, writeRegistration } from "../src/app-socket.mjs";
test("a missing registered bundle gives a repair path without falling back to host input",async t=>{
const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-missing-app-"));
@@ -208,3 +208,30 @@ test("hdc pullFile rejects traversal and shell metacharacters before execution",
await assert.rejects(ex.pullFile(remote, "/unused-fixture-output"), /refusing unsafe remote path/);
}
});
test("a helper request that was written reports an unknown outcome when it times out or is cancelled", {skip:process.platform==="win32"}, async t => {
const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-silent-helper-"));
const saved=process.env.CODEWHALE_CU_APP_SOCKET;
process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"app.sock");
const received=[];
// Accepts and reads the request, then never answers (a wedged helper).
const server=net.createServer(socket=>socket.on("data",data=>received.push(String(data))));
await new Promise(resolve=>server.listen(process.env.CODEWHALE_CU_APP_SOCKET,resolve));
t.after(async()=>{
server.close();
if(saved===undefined) delete process.env.CODEWHALE_CU_APP_SOCKET; else process.env.CODEWHALE_CU_APP_SOCKET=saved;
fs.rmSync(dir,{recursive:true,force:true});
});
await assert.rejects(appRequest({tool:"left_click",args:{}},{timeoutMs:80,signal:null}),error=>error.code==="app_timeout"&&error.requestDispatched===true);
const controller=new AbortController();
const pending=appRequest({tool:"left_click",args:{}},{timeoutMs:5_000,signal:controller.signal});
while(received.length<2) await new Promise(resolve=>setTimeout(resolve,5));
controller.abort();
await assert.rejects(pending,error=>error.code==="cancelled"&&error.requestDispatched===true);
assert.equal(received.length,2,"each request reached the helper before its failure");
// Opening the session lease sends no input: its timeout is not outcome-unknown.
await assert.rejects(appSessionRequest({tool:"left_click",args:{},sessionId:"silent-lease"},{timeoutMs:80,signal:null}),error=>error.code==="app_timeout"&&error.requestDispatched===undefined);
// Nothing written: an unreachable helper is a plain failure, safe to retry.
process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"absent.sock");
await assert.rejects(appRequest({tool:"left_click",args:{}},{timeoutMs:1_000,signal:null}),error=>error.code==="app_unavailable"&&error.requestDispatched===undefined);
});
@@ -20,7 +20,12 @@
CUFixtureApplication *target=[CUFixtureApplication new], *front=[CUFixtureApplication new];
target.processIdentifier=123; target.localizedName=@"Fixture"; target.bundleIdentifier=@"test.fixture";
front.processIdentifier=999; front.localizedName=@"Other"; front.bundleIdentifier=@"test.other";
return @[front,target];
// Two running instances sharing one name and bundle (a second browser
// profile, an `open -n` copy): only a pid may choose between them.
CUFixtureApplication *twinA=[CUFixtureApplication new], *twinB=[CUFixtureApplication new];
twinA.processIdentifier=200; twinA.localizedName=@"Twin"; twinA.bundleIdentifier=@"test.twin";
twinB.processIdentifier=201; twinB.localizedName=@"Twin"; twinB.bundleIdentifier=@"test.twin";
return @[front,target,twinA,twinB];
}
- (NSRunningApplication *)frontmostApplication { return (NSRunningApplication *)self.runningApplications[0]; }
@end
@@ -50,7 +55,9 @@ int main(int argc, const char *argv[]) { @autoreleasepool {
return 0;
}
if(![@[@"list_windows",@"get_app_state",@"resolve_element",@"window_info"] containsObject:request[@"tool"]]) return 2;
NSRunningApplication *app=resolve(request[@"args"][@"app_ref"]);
NSRunningApplication *app=nil;
@try { app=resolve(request[@"args"][@"app_ref"]); }
@catch(NSException *error) { fprintf(stderr,"%s\n",error.reason.UTF8String); return 1; }
if(!app) { fputs("application not found\n",stderr); return 1; }
NSDictionary *receipt=@{@"found":@YES,@"pid":@(app.processIdentifier),@"name":app.localizedName,@"bundle_id":app.bundleIdentifier,@"windows":@[],@"elements":@[]};
NSData *json=[NSJSONSerialization dataWithJSONObject:receipt options:0 error:nil];
@@ -231,3 +231,17 @@ test("Docker desktop entrypoint survives repeated orderly restarts", { ...NEED_D
assert.equal(observed, true, `desktop unavailable after restart ${cycle}: ${last}`);
}
});
test("destroyDockerComputer never removes a desktop another session spawned", async () => {
const issued = [];
const labels = (value) => async (args) => {
issued.push(args);
return args[0] === "container" ? { code: 0, stdout: `${value}\n`, stderr: "" } : { code: 0, stdout: "", stderr: "" };
};
const computer = { container: "cu-spawn-other-ab12cd" };
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels("1|another-session")), { destroyed: false, reason: "other_session" });
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels("|")), { destroyed: false, reason: "not_spawned" });
assert.equal(issued.filter((args) => args[0] === "rm").length, 0, "no docker rm for a container this session does not own");
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels(`1|${SESSION_ID}`)), { destroyed: true });
assert.deepEqual(issued.at(-1), ["rm", "-f", "cu-spawn-other-ab12cd"]);
});
+9 -1
View File
@@ -90,7 +90,12 @@ class PetNative {
this.world.voices = [];
return this.world.frame.timeMs;
}
observeEngine(metadataJSON, timeMs) { this.engine.observe(JSON.parse(metadataJSON), timeMs); }
/** A rejected observation leaves the reducer exactly as it was, like a batch. */
observeEngine(metadataJSON, timeMs) {
const next = this.engine.clone();
next.observe(JSON.parse(metadataJSON), timeMs);
this.engine = next;
}
observeEngineBatch(metadataJSON, timeMs) {
const events = JSON.parse(metadataJSON);
if (!Array.isArray(events) || events.length > 64)
@@ -1846,6 +1851,9 @@ class PetEngineTelemetry {
next.completedTurns = new Set(this.completedTurns);
return next;
}
/** Mutates in place: clock, freshness and the event window change before
* type-specific fields are checked. Callers that continue after a throw must
* observe on a clone() and swap it in on success, as PetNative does. */
observe(value, at) {
if (!Number.isFinite(at) || at < this.lastTime || at > pet_sim_js_1.PET_MAX_SECONDS * 1000)
throw new Error('Invalid Engine pet clock.');
+71 -5
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { chmod, mkdir, open, readFile, rename, rm } from "node:fs/promises";
import path from "node:path";
import { randomBytes } from "node:crypto";
@@ -20,6 +20,39 @@ async function chmodBestEffort(filePath, mode) {
}
}
/**
* Replace `filePath` so a crash leaves either the old bytes or the new ones:
* a unique temporary name (two processes sharing a state dir never write the
* same temp file), fsync before the rename, and a best-effort directory fsync
* after it so the rename itself survives power loss where the OS allows.
*/
export async function writeFileDurable(filePath, contents, { mode = 0o600 } = {}) {
const dir = path.dirname(filePath);
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
let handle = null;
try {
handle = await open(tmp, "wx", mode);
await handle.writeFile(contents);
await handle.sync();
await handle.close();
handle = null;
await rename(tmp, filePath);
} catch (error) {
await handle?.close().catch(() => {});
await rm(tmp, { force: true }).catch(() => {});
throw error;
}
let dirHandle = null;
try {
dirHandle = await open(dir, "r");
await dirHandle.sync();
} catch {
// Windows and some filesystems cannot fsync a directory.
} finally {
await dirHandle?.close().catch(() => {});
}
}
export class ThreadStore {
static async open(filePath, options = {}) {
const store = new ThreadStore(filePath, options);
@@ -48,6 +81,9 @@ export class ThreadStore {
if (this.options.messageLimit > 0 && !Array.isArray(this.data.messages)) {
this.data.messages = [];
}
if (this.options.messageLimit > 0 && (!this.data.inflight || typeof this.data.inflight !== "object" || Array.isArray(this.data.inflight))) {
this.data.inflight = {};
}
if (this.options.actions && (!this.data.actions || typeof this.data.actions !== "object")) {
this.data.actions = {};
}
@@ -66,6 +102,39 @@ export class ThreadStore {
}
}
/**
* Claim one inbound message before acting on it. "new": handle it now (it
* is recorded as in flight first, durably). "done": a finished duplicate.
* "interrupted": an earlier process claimed it and stopped before
* completeMessage, so its effect is unknown; the caller reports that
* instead of running it again. Callers handle messages sequentially — a
* key still in flight in this process is not a restart.
*/
async claimMessage(messageKey) {
if (!messageKey || this.options.messageLimit <= 0) return "new";
this.ensureShape();
const inflight = this.data.inflight;
if (this.data.messages.includes(messageKey)) {
if (!Object.hasOwn(inflight, messageKey)) return "done";
delete inflight[messageKey];
await this.save();
return "interrupted";
}
this.data.messages.push(messageKey);
this.data.messages = this.data.messages.slice(-this.options.messageLimit);
for (const key of Object.keys(inflight)) if (!this.data.messages.includes(key)) delete inflight[key];
inflight[messageKey] = new Date().toISOString();
await this.save();
return "new";
}
/** Mark a claimed message handled (whether it succeeded or failed in-process). */
async completeMessage(messageKey) {
if (!messageKey || !this.data.inflight || !Object.hasOwn(this.data.inflight, messageKey)) return;
delete this.data.inflight[messageKey];
await this.save();
}
async recordMessage(messageKey) {
if (!messageKey || this.options.messageLimit <= 0) return false;
this.ensureShape();
@@ -180,10 +249,7 @@ export class ThreadStore {
const dir = path.dirname(this.filePath);
await mkdir(dir, { recursive: true, mode: 0o700 });
if (this.options.privateMode) await chmodBestEffort(dir, 0o700);
const tmp = `${this.filePath}.tmp`;
await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
if (this.options.privateMode) await chmodBestEffort(tmp, 0o600);
await rename(tmp, this.filePath);
await writeFileDurable(this.filePath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
if (this.options.privateMode) await chmodBestEffort(this.filePath, 0o600);
}
}
+35 -2
View File
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -19,7 +19,8 @@ import {
readSse,
splitMessage,
stripGroupPrefix,
ThreadStore
ThreadStore,
writeFileDurable
} from "../src/lib.mjs";
test("env and primitive parsers handle bridge env conventions", () => {
@@ -255,3 +256,35 @@ test("ThreadStore persists numeric cursors", async () => {
await rm(dir, { recursive: true, force: true });
}
});
test("writeFileDurable replaces the file through unique temp names and leaves none behind", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-"));
try {
const target = path.join(dir, "sync-buf.txt");
// Concurrent writers used to share one fixed `.tmp` path and race on it.
await Promise.all(Array.from({ length: 8 }, (_, index) => writeFileDurable(target, `cursor-${index}`)));
assert.match(await readFile(target, "utf8"), /^cursor-[0-7]$/);
assert.deepEqual(await readdir(dir), ["sync-buf.txt"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("ThreadStore claims survive a restart: finished messages skip, an in-flight one is reported", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "codewhale-bridge-core-"));
try {
const statePath = path.join(dir, "thread-map.json");
const store = await ThreadStore.open(statePath, { messageLimit: 10 });
assert.equal(await store.claimMessage("u:1"), "new");
await store.completeMessage("u:1");
assert.equal(await store.claimMessage("u:2"), "new");
// The process dies here, before completeMessage("u:2").
const restarted = await ThreadStore.open(statePath, { messageLimit: 10 });
assert.equal(await restarted.claimMessage("u:1"), "done");
assert.equal(await restarted.claimMessage("u:2"), "interrupted");
assert.equal(await restarted.claimMessage("u:2"), "done", "reported once, then an ordinary duplicate");
assert.equal(await restarted.claimMessage("u:3"), "new");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+72 -70
View File
@@ -23,9 +23,10 @@ import {
latestRunningTurn,
activeTurnBlock,
helpText,
processUpdateBatch,
} from "./lib.mjs";
import { renderQrToText } from "./qr.mjs";
import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs";
import { ThreadStore as CoreThreadStore, writeFileDurable } from "../../bridge-core/src/lib.mjs";
// ============================================================================
// ThreadStore — JSON 文件持久化(与 feishu/telegram/wechat bridge 一致)
@@ -59,11 +60,7 @@ async function loadAccount(stateDir) {
async function saveAccount(stateDir, account) {
const p = resolveAccountPath(stateDir);
await fs.mkdir(path.dirname(p), { recursive: true, mode: 0o700 });
const tmp = `${p}.tmp`;
await fs.writeFile(tmp, `${JSON.stringify(account, null, 2)}\n`, {
mode: 0o600,
});
await fs.rename(tmp, p);
await writeFileDurable(p, `${JSON.stringify(account, null, 2)}\n`, { mode: 0o600 });
}
// ============================================================================
@@ -718,9 +715,57 @@ async function saveSyncBuf(stateDir, buf) {
// The state dir may not exist yet on a first run whose first persisted write
// is the poll cursor rather than account.json.
await fs.mkdir(path.dirname(p), { recursive: true, mode: 0o700 });
const tmp = `${p}.tmp`;
await fs.writeFile(tmp, buf, { mode: 0o600 });
await fs.rename(tmp, p);
await writeFileDurable(p, buf, { mode: 0o600 });
}
/** One claimed inbound message: context token, text, allowlist, command. */
async function handleInbound(msg) {
const fromUser = msg.from_user_id || "";
// 保存 context_token
if (msg.context_token) {
await threadStore.patchChat(fromUser, {
contextToken: msg.context_token,
updatedAt: new Date().toISOString(),
});
}
// 提取文本
const text = extractText(msg.item_list);
if (!text) {
await sendText(
fromUser,
"仅支持文本消息。图片/语音/视频/文件暂不支持。"
);
return;
}
console.log(
`[inbound] from=${fromUser} text=${text.slice(0, 100)}`
);
// 白名单检查
if (!isAllowed(fromUser)) {
await sendText(
fromUser,
[
"This WeChat user is not in WEIXIN_CHAT_ALLOWLIST.",
`user_id=${fromUser}`,
"",
"For first pairing, add this user_id to WEIXIN_CHAT_ALLOWLIST, or temporarily set WEIXIN_ALLOW_UNLISTED=true.",
].join("\n")
);
return;
}
// 命令路由
const command = parseCommand(text);
await handleCommand(fromUser, command).catch((error) => {
console.error(
`failed to handle command from=${fromUser} text=${text.slice(0, 100)}`,
error
);
});
}
async function monitorLoop() {
@@ -775,69 +820,26 @@ async function monitorLoop() {
consecutiveFailures = 0;
// 保存游标
if (resp.get_updates_buf) {
getUpdatesBuf = resp.get_updates_buf;
await saveSyncBuf(config.stateDir, getUpdatesBuf);
}
// 处理消息
const msgs = resp.msgs || [];
for (const msg of msgs) {
const fromUser = msg.from_user_id || "";
const messageId = String(msg.message_id || "");
if (!fromUser) continue;
const msgKey = `${fromUser}:${messageId}`;
if (await threadStore.recordMessage(msgKey)) continue;
// 保存 context_token
if (msg.context_token) {
await threadStore.patchChat(fromUser, {
contextToken: msg.context_token,
updatedAt: new Date().toISOString(),
});
}
// 提取文本
const text = extractText(msg.item_list);
if (!text) {
// 先处理消息,再保存游标:进程崩溃时整批从 iLink 重放,
// 已完成的消息按 key 跳过,崩溃时正在处理的消息只提示、不重跑。
await processUpdateBatch({
messages: resp.msgs || [],
nextCursor: resp.get_updates_buf,
store: threadStore,
keyOf: (msg) => (msg.from_user_id ? `${msg.from_user_id}:${String(msg.message_id || "")}` : null),
interrupted: async (msg) => {
if (!isAllowed(msg.from_user_id)) return;
await sendText(
fromUser,
"仅支持文本消息。图片/语音/视频/文件暂不支持。"
msg.from_user_id,
"桥接进程在处理这条消息时重启,未重复执行;如仍需要请重新发送。\nThe bridge restarted while handling this message and did not run it again; send it again if you still need it."
);
continue;
}
console.log(
`[inbound] from=${fromUser} text=${text.slice(0, 100)}`
);
// 白名单检查
if (!isAllowed(fromUser)) {
await sendText(
fromUser,
[
"This WeChat user is not in WEIXIN_CHAT_ALLOWLIST.",
`user_id=${fromUser}`,
"",
"For first pairing, add this user_id to WEIXIN_CHAT_ALLOWLIST, or temporarily set WEIXIN_ALLOW_UNLISTED=true.",
].join("\n")
);
continue;
}
// 命令路由
const command = parseCommand(text);
await handleCommand(fromUser, command).catch((error) => {
console.error(
`failed to handle command from=${fromUser} text=${text.slice(0, 100)}`,
error
);
});
}
},
handle: handleInbound,
commitCursor: async (buf) => {
getUpdatesBuf = buf;
await saveSyncBuf(config.stateDir, buf);
},
});
} catch (error) {
if (error.name === "AbortError" || error.message?.includes("abort")) {
// 长轮询超时是正常的,立即重试
+27
View File
@@ -35,6 +35,33 @@ export {
splitMessage,
};
/**
* Handle one getUpdates batch, then advance the poll cursor. The cursor moves
* only after every message in the batch was handled, so a crash replays the
* batch from iLink instead of dropping a prompt. Each message is claimed
* durably first: on replay, finished messages are skipped, and one a crashed
* process was still handling is reported through `interrupted` rather than
* run twice (its effect is unknown).
*/
export async function processUpdateBatch({ messages, nextCursor, store, keyOf, handle, interrupted, commitCursor }) {
for (const msg of messages || []) {
const key = keyOf(msg);
if (!key) continue;
const claim = await store.claimMessage(key);
if (claim === "done") continue;
if (claim === "interrupted") {
await interrupted(msg);
continue;
}
try {
await handle(msg);
} finally {
await store.completeMessage(key);
}
}
if (nextCursor) await commitCursor(nextCursor);
}
export function randomUin() {
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
return Buffer.from(String(uint32), "utf-8").toString("base64");
@@ -1,5 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { ThreadStore } from "../../bridge-core/src/lib.mjs";
import {
activeTurnBlock,
@@ -10,6 +15,7 @@ import {
parseCommand,
parseList,
preservedChatStateFields,
processUpdateBatch,
splitMessage
} from "../src/lib.mjs";
@@ -51,3 +57,44 @@ test("shared state and runtime helpers preserve Weixin bridge behavior", () => {
message: "Thread already has active turn turn-1. Wait for it to finish or send /interrupt."
});
});
test("a crash mid-batch replays the batch without losing or re-running prompts", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "codewhale-weixin-bridge-"));
try {
const statePath = path.join(dir, "thread-map.json");
const batch = [1, 2, 3].map((id) => ({ from_user_id: "u", message_id: id }));
const keyOf = (msg) => `${msg.from_user_id}:${msg.message_id}`;
const handled = [];
const cursors = [];
const crash = new Error("process killed");
const first = await ThreadStore.open(statePath, { messageLimit: 50 });
let afterCrash = null;
await assert.rejects(processUpdateBatch({
messages: batch, nextCursor: "buf-2", store: first, keyOf,
handle: async (msg) => {
if (msg.message_id === 2) {
// What a restarted process finds on disk while message 2 runs.
afterCrash = await ThreadStore.open(statePath, { messageLimit: 50 });
throw crash;
}
handled.push(msg.message_id);
},
interrupted: async () => assert.fail("nothing is interrupted on the first pass"),
commitCursor: async (buf) => cursors.push(buf),
}), crash);
assert.deepEqual(cursors, [], "the cursor does not move past unhandled messages");
const reported = [];
await processUpdateBatch({
messages: batch, nextCursor: "buf-2", store: afterCrash, keyOf,
handle: async (msg) => handled.push(msg.message_id),
interrupted: async (msg) => reported.push(msg.message_id),
commitCursor: async (buf) => cursors.push(buf),
});
assert.deepEqual(handled, [1, 3], "message 1 is not re-run; message 3 is not lost");
assert.deepEqual(reported, [2], "the in-flight message is reported, not re-run");
assert.deepEqual(cursors, ["buf-2"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+5 -3
View File
@@ -88,8 +88,8 @@ The Rust particle implementation lives in the product's
`crates/tui/src/tui/ambient_life/pet_sim.rs`; this package's runner imports it.
Swift and Kotlin particle ports must match its conformance digests. Generated
native bundles are committed so the product Rust build needs no Node compiler.
Run `npm run sync` after changing core source; the generated-byte check in CI
rejects a stale bundle. Local Whalesong consumers use aliases to these same
Run `npm run sync` after changing core source; `npm run check` (run by CI)
rejects a stale bundle or `shared.html` copy. Local Whalesong consumers use aliases to these same
canonical files, not separately maintained source copies.
The original Whalesong importer, signal model, schema and browser storage code
@@ -129,7 +129,9 @@ Live recording continues in segments. At 216,000 buckets (24 hours) or 64 MiB,
the recorder syncs the completed file, preserves it as
`OUTPUT.segment-000001.jsonl` (then `000002`, etc.), and atomically replaces the
same live pathname. Each segment starts at sequence zero and replays independently.
Use `--segment-buckets=N` to rotate sooner. Followers establish a new baseline
Use `--segment-buckets=N` to rotate sooner. A stall or suspension shorter than
one segment is recorded as unknown buckets; a longer one starts a new segment
whose first bucket is unknown, as `--resume` does, and only reports its length. Followers establish a new baseline
after replacement, then accept subsequent appends as current observations.
The live importer retains unfinished lifetimes and 16 seconds of completed
@@ -46,25 +46,38 @@ class PetSharedClient(private val descriptor: File) {
fun poll(still: Boolean, sound: Boolean): PetScene {
val next = request("/v1/frame")
require(next.getInt("version") == 1 && next.getString("identity") == connection().getString("identity")) { "Shared identity changed; select its connection again." }
if (frame?.optString("epoch") != next.getString("epoch") || frame?.optLong("tick") != next.getLong("tick")) changed = SystemClock.elapsedRealtime()
frame = next; identity = "${next.getString("identity")} · tick ${next.getLong("tick")} · ${next.getString("digest")}"
// Validate every rendered field of both poses before this frame can
// refresh freshness, lease sound or flush a pending action. org.json
// parses "NaN"/"Infinity" strings through getDouble, so check finiteness.
val epoch = next.getString("epoch"); UUID.fromString(epoch)
val tick = next.getLong("tick"); val timeMs = next.getDouble("timeMs")
val surface = next.getDouble("surface"); val caustic = next.getDouble("caustic")
require(tick >= 0 && timeMs.isFinite() && timeMs >= 0 && surface.isFinite() && caustic.isFinite()
&& next.getString("digest").length == 16 && next.getString("behaviour") in BEHAVIOURS && next.getString("needs") in NEEDS) { "Invalid shared pet frame." }
val poses = listOf(next, next.getJSONObject("still")).map { pose ->
val points = pose.getJSONArray("points"); require(points.length() == 980) { "Invalid shared pet frame." }
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).also { require(it.length() == 2) }.getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
require(validState(state) && validStyle(style)) { "Invalid shared pet frame." }
Triple(dots, state, style)
}
val appearance = next.optJSONObject("appearance")?.let { a ->
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
val dotScale = a.getDouble("dotScale"); val glow = a.getDouble("glow"); require(dotScale.isFinite() && glow.isFinite())
PetAppearance(color("background"), color("backgroundTop"), dotScale.toFloat().coerceIn(.65f,1.8f), glow.toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
}
if (frame?.optString("epoch") != epoch || frame?.optLong("tick") != tick) changed = SystemClock.elapsedRealtime()
frame = next; identity = "${next.getString("identity")} · tick $tick · ${next.getString("digest")}"
val fresh = SystemClock.elapsedRealtime() - changed < 800
message = if (fresh && next.getBoolean("producerConnected")) "Following ${next.getString("source")}" else "${next.getString("source")} · telemetry unobserved"
if (!next.getBoolean("storageAvailable")) message += " · storage unavailable; previous recording kept"
if (next.getBoolean("audioUnavailable")) granted = false
if (SystemClock.elapsedRealtime() - lease >= 500) setSound(sound && fresh)
flush()
val pose = if (still) next.getJSONObject("still") else next
val points = pose.getJSONArray("points"); require(points.length() == 980)
val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
val (dots, state, style) = poses[if (still) 1 else 0]
if (!fresh || !next.getBoolean("producerConnected")) { style.hollow = true }
val appearance = next.optJSONObject("appearance")?.let { a ->
fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
PetAppearance(color("background"), color("backgroundTop"), a.getDouble("dotScale").toFloat().coerceIn(.65f,1.8f), a.getDouble("glow").toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
}
return PetScene(next.getDouble("timeMs"), state, style, dots, next.getString("behaviour"), next.getString("needs"),
next.getDouble("surface").toFloat(), if (still) 0f else next.getDouble("caustic").toFloat(), null, 0, next.getString("digest"), appearance,
return PetScene(timeMs, state, style, dots, next.getString("behaviour"), next.getString("needs"),
surface.toFloat(), if (still) 0f else caustic.toFloat(), null, 0, next.getString("digest"), appearance,
next.optJSONObject("activity")?.takeIf { it.optBoolean("observed") && fresh }?.let { a ->
a.getString("label").take(96) + (if (a.isNull("tool")) "" else " · " + a.getString("tool").take(96)) +
(if (a.optInt("parallel") > 0) " · ${a.getInt("parallel")} parallel agents" else "")
@@ -82,6 +95,13 @@ class PetSharedClient(private val descriptor: File) {
fun detach() { if (granted) runCatching { setSound(false) }; granted = false }
fun export(): ByteArray = request("/v1/export").toString().toByteArray(Charsets.UTF_8)
companion object {
private val BEHAVIOURS = setOf("swim", "dive", "roll", "breathe", "drift", "doze", "wake")
private val NEEDS = setOf("none", "orient", "approach", "call")
/** The shared core's validatePetState bounds. */
private fun validState(s: PetState) = listOf(s.activity, s.coherence, s.attention, s.observed, s.lit).all { it.isFinite() && it in 0.0..1.0 }
&& listOf(s.roamX, s.roamY, s.flip).all { it.isFinite() && kotlin.math.abs(it) <= 1.0 } && CHANNELS.any { it.key == s.channel }
private fun validStyle(f: Frame) = listOf(f.r, f.g, f.b).all { it.isFinite() && it in 0.0..255.0 } && f.alpha.isFinite() && f.alpha in 0.0..1.0
&& f.work.isFinite() && CHANNELS.any { it.key == f.channel }
fun validateConnection(d: JSONObject) {
require(d.getInt("version") == 1 && d.getInt("port") in 1..65535 && d.getString("token").matches(Regex("[a-f0-9]{64}"))) { "Invalid shared pet connection." }
UUID.fromString(d.getString("identity"))
+9 -1
View File
@@ -90,7 +90,12 @@ class PetNative {
this.world.voices = [];
return this.world.frame.timeMs;
}
observeEngine(metadataJSON, timeMs) { this.engine.observe(JSON.parse(metadataJSON), timeMs); }
/** A rejected observation leaves the reducer exactly as it was, like a batch. */
observeEngine(metadataJSON, timeMs) {
const next = this.engine.clone();
next.observe(JSON.parse(metadataJSON), timeMs);
this.engine = next;
}
observeEngineBatch(metadataJSON, timeMs) {
const events = JSON.parse(metadataJSON);
if (!Array.isArray(events) || events.length > 64)
@@ -1846,6 +1851,9 @@ class PetEngineTelemetry {
next.completedTurns = new Set(this.completedTurns);
return next;
}
/** Mutates in place: clock, freshness and the event window change before
* type-specific fields are checked. Callers that continue after a throw must
* observe on a clone() and swap it in on success, as PetNative does. */
observe(value, at) {
if (!Number.isFinite(at) || at < this.lastTime || at > pet_sim_js_1.PET_MAX_SECONDS * 1000)
throw new Error('Invalid Engine pet clock.');
+1 -1
View File
@@ -11,7 +11,7 @@
"build": "node scripts/build-pet.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test tests/*.test.mjs",
"check": "npm run typecheck && npm run build && npm test",
"check": "npm run typecheck && node scripts/build-pet.mjs --check && npm test",
"start": "python3 -m http.server 4632 --bind 127.0.0.1 --directory dist",
"sync": "node scripts/build-pet.mjs --tui --apple --study"
},
+4
View File
@@ -84,6 +84,10 @@
},
"payload": {},
"raw": {},
"observation": {
"$ref": "urn:whalesong:evidence:v1#/$defs/observation",
"description": "Boundary evidence record this event normalizes; the importer validates it and the JSONL exporter emits it."
},
"links": {
"type": "array",
"items": {
+26 -9
View File
@@ -2,6 +2,7 @@
/** Local TypeScript build plus deterministic embedded-runtime packaging. */
import { readFile, writeFile, cp, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { spawnSync } from 'node:child_process';
import ts from 'typescript';
const root = fileURLToPath(new URL('../', import.meta.url));
@@ -23,11 +24,15 @@ const bundle = `/* Generated from pet/src/core. Run npm --prefix pet run sync. *
+ [...modules].map(([id, js]) => `factories[${JSON.stringify(id)}]=function(exports,require){\n${js}\n};\n`).join('')
+ `function load(id){id=id.replace(/^\\.\\//,'').replace(/\\.js$/,'');if(cache[id])return cache[id];if(!factories[id])throw Error('Missing core module');const e=cache[id]={};factories[id](e,load);return e;}\nglobal.PetNative=load('pet-native').PetNative;\n})(globalThis);\n`;
await writeFile(root + 'dist/pet-native.js', bundle);
if (process.argv.includes('--tui')) {
// Committed native copies are generated from src/core and public/. `--check`
// regenerates them in memory and fails on any byte difference, so a green
// `npm run check` cannot sit beside a stale embedded TUI/Apple/Android core.
const check = process.argv.includes('--check');
const generated = [];
if (check || process.argv.includes('--tui')) {
const destination = root + '../crates/tui/src/tui/pet_watch/';
await mkdir(destination, { recursive: true });
await writeFile(destination + 'pet-native.js', bundle);
await cp(root + 'public/shared.html', destination + 'shared.html');
generated.push([destination + 'pet-native.js', Buffer.from(bundle)],
[destination + 'shared.html', await readFile(root + 'public/shared.html')]);
}
if (process.argv.includes('--study')) {
const study = root + 'world/';
@@ -44,11 +49,23 @@ if (process.argv.includes('--study')) {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 },
}).outputText.replace("'./PetSim.ts'", "'./PetSim.js'"));
}
if (process.argv.includes('--apple')) {
if (check || process.argv.includes('--apple')) {
const { petDemoEvents } = await import('../dist/core/pet-demo.js');
const { compilePetTelemetry, encodePetJSONL } = await import('../dist/core/pet-telemetry.js');
await mkdir(root + 'ios/Resources', { recursive: true });
await writeFile(root + 'ios/Resources/pet-native.js', bundle);
await writeFile(root + 'ios/Resources/demo.jsonl', encodePetJSONL(compilePetTelemetry(petDemoEvents(), 80_000)));
generated.push([root + 'ios/Resources/pet-native.js', Buffer.from(bundle)],
[root + 'ios/Resources/demo.jsonl', Buffer.from(encodePetJSONL(compilePetTelemetry(petDemoEvents(), 80_000)))]);
}
console.log(`Built pet viewer and native core (${modules.size} shared modules), entirely local.`);
if (check) {
const stale = [];
for (const [path, bytes] of generated) {
const current = await readFile(path).catch(error => { if (error.code === 'ENOENT') return undefined; throw error; });
if (!current || !bytes.equals(current)) stale.push(path.slice(root.length));
}
if (stale.length) {
console.error(`Generated native artifacts are stale: ${stale.join(', ')}. Run npm --prefix pet run sync and commit them.`);
process.exit(1);
}
} else {
for (const [path, bytes] of generated) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, bytes); }
}
console.log(`Built pet viewer and native core (${modules.size} shared modules), entirely local${check ? '; generated native artifacts match' : ''}.`);
+6
View File
@@ -95,6 +95,12 @@ export async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes =
expectedMtime ??= (await output.stat({ bigint: true })).mtimeNs;
} catch (error) { try { await output?.close(); } finally { lock.close(); } throw error; }
return {
/** The next append archives the current segment and starts a new one at
* sequence zero, exactly as --resume does after an outage. */
restart() {
if (!output || busy) throw new Error('Pet recorder is closed or already writing.');
restart = true;
},
async append(bucket) {
if (!output || busy) throw new Error('Pet recorder is closed or already writing.');
validatePetBucket(bucket);
+14 -3
View File
@@ -32,7 +32,8 @@ try {
return trace;
};
let trace = runtimeURL ? undefined : await load(), buckets = compilePetTelemetry(trace?.events ?? [], trace?.duration ?? 0);
if (live) recorder = await createPetRecorder(path, { resume: args.includes('--resume'), maxBuckets: option('segment-buckets') === undefined ? 216_000 : Number(option('segment-buckets')), report: text => console.error(text) });
const segmentBuckets = option('segment-buckets') === undefined ? 216_000 : Number(option('segment-buckets'));
if (live) recorder = await createPetRecorder(path, { resume: args.includes('--resume'), maxBuckets: segmentBuckets, report: text => console.error(text) });
else output = await open(path, 'wx', 0o600);
if (runtimeURL) runtime = await followRuntime({ baseUrl: runtimeURL, threadId: option('thread'),
token: process.env.CODEWHALE_RUNTIME_TOKEN, report: text => console.error(text) });
@@ -45,7 +46,7 @@ try {
const started = performance.now(), startedWall = Date.now();
const origin = trace && 'originTime' in trace && trace.originTime ? Date.parse(trace.originTime) : NaN;
const offset = Number.isFinite(origin) ? Math.max(0, Date.now() - origin) : trace?.duration ?? 0;
let dirty = false, running = false, sequence = 0, failed = false, stopping = false, lastBin = -1;
let dirty = false, running = false, sequence = 0, failed = false, stopping = false, lastBin = -1, outage = false;
const empty = compilePetTelemetry([])[0];
if (input) {
monitor = watch(dirname(input), (_event, filename) => { if (!filename || String(filename) === basename(input)) dirty = true; });
@@ -72,12 +73,22 @@ try {
}
// A stalled host records skipped intervals as unknown instead of silently
// compressing time. Never repeat onsets when timer jitter hits a source bin twice.
// At most one segment of them: a longer suspension would otherwise replay
// every missed 400 ms append (hours of I/O and archives of pure unknown)
// before observing again. It starts a new segment whose first bucket is
// unknown, as --resume does after an outage; the gap length is reported,
// not recorded.
if (target - sequence > segmentBuckets) {
console.error(`Recorder was suspended or stalled for ${Math.round((target - sequence) * 0.4)} s; starting a new segment instead of recording that many unknown buckets.`);
recorder.restart(); sequence = target; outage = true;
}
while (sequence < target && !stopping) {
await recorder.append({ ...empty, sequence, simTimeMs: sequence * 400 }); sequence++;
}
if (stopping) return;
let state = empty;
if (runtime) {
if (outage) outage = false;
else if (runtime) {
// Seal the preceding observation interval before recording its state.
// A fixed recorder origin survives imports discovering older starts.
// Accepting this state one bucket later matches the foreground host.
+16 -6
View File
@@ -147,6 +147,15 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
const events: WhaleEvent[] = [];
const pending = new Map<string, number>();
// One journal entry may hold any number of blocks, so bound every emitted
// event, not every entry. Identities stay unique like the generic importer:
// a repeated tool call id would otherwise replace the earlier call.
const identities = new Set<string>();
const push = (event: WhaleEvent): void => {
if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
if (identities.has(event.id)) throw new Error(`Duplicate event identity (${sessionId}, ${event.id}). Import cancelled.`);
identities.add(event.id); pushEvent(events, event);
};
let seq = 0;
const originWall = orderOnly ? undefined : sourceEntries.map(e => parseTime(e.created_at)).find((n): n is number => n !== undefined);
const agentId = 'parent';
@@ -161,7 +170,6 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
};
for (const entry of sourceEntries) {
if (events.length >= maxEvents) throw new Error(`Import exceeds the ${maxEvents.toLocaleString()} event limit.`);
const entryId = str(entry.id) ?? `${sessionId}/entry/${seq}`;
const message = obj(entry.message ?? (entry.kind === 'message' ? entry : {}));
const role = str(message.role) ?? (str(entry.kind) === 'user' ? 'user' : str(entry.kind) === 'assistant' ? 'assistant' : undefined);
@@ -190,21 +198,23 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
payload: { arguments: clip(block.input) }, raw,
};
pending.set(callId, events.length);
pushEvent(events, event);
push(event);
} else if (type === 'tool_result') {
const callId = str(block.tool_use_id);
const isError = block.is_error === true;
const target = callId !== undefined ? pending.get(callId) : undefined;
if (target !== undefined) {
const prior = events[target]!;
prior.endTime = t.start;
// Skewed or missing entry times never produce a negative duration.
if (t.start < prior.startTime) warnings.push(`Tool ${callId} result is timestamped before its call; its duration is recorded as zero.`);
prior.endTime = Math.max(prior.startTime, t.start);
prior.openEnded = false;
prior.status = statusOf('completed', isError);
prior.payload = { ...(obj(prior.payload)), result: clip(block.content) };
prior.attributes = { ...prior.attributes, 'codewhale.result_entry_id': entryId };
pending.delete(callId as string);
} else {
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: callId ?? parentEventId,
startTime: t.start, endTime: t.start, agentId,
name: 'tool_result', category: 'tool', model, provider,
@@ -214,7 +224,7 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
});
}
} else if (type === 'thinking') {
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
startTime: t.start, endTime: t.start, agentId, name: 'thinking', category: 'reasoning',
model, provider, status: 'success',
@@ -225,7 +235,7 @@ export function fromCodewhaleSession(document: unknown, filename = 'Codewhale se
const text = str(block.text) ?? '';
const operate = text.includes('codewhale:runtime_event');
const user = role === 'user' || role === 'User';
pushEvent(events, {
push({
schemaVersion: 1, id: idBase, traceId: sessionId, parentId: parentEventId,
startTime: t.start, endTime: t.start, agentId,
name: operate ? 'operate_contract' : user ? 'user_message' : 'assistant_message',
+3
View File
@@ -225,6 +225,9 @@ export class PetEngineTelemetry {
return next;
}
/** Mutates in place: clock, freshness and the event window change before
* type-specific fields are checked. Callers that continue after a throw must
* observe on a clone() and swap it in on success, as PetNative does. */
observe(value: unknown, at: number): void {
if (!Number.isFinite(at) || at < this.lastTime || at > PET_MAX_SECONDS * 1000)
throw new Error('Invalid Engine pet clock.');
+4 -1
View File
@@ -74,7 +74,10 @@ export class PetNative {
this.world.voices = [];
return this.world.frame.timeMs;
}
observeEngine(metadataJSON: string, timeMs: number): void { this.engine.observe(JSON.parse(metadataJSON), timeMs); }
/** A rejected observation leaves the reducer exactly as it was, like a batch. */
observeEngine(metadataJSON: string, timeMs: number): void {
const next = this.engine.clone(); next.observe(JSON.parse(metadataJSON), timeMs); this.engine = next;
}
observeEngineBatch(metadataJSON: string, timeMs: number): void {
const events: unknown = JSON.parse(metadataJSON);
if (!Array.isArray(events) || events.length > 64) throw new Error('Invalid Engine batch.');
+8 -1
View File
@@ -107,7 +107,14 @@ async function saveCurrent(archiveCurrent: boolean): Promise<boolean> {
get('persistence').textContent = 'Habitat saved on this device. Earlier recordings remain available.';
return true;
}
catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; return false; }
catch (error) {
// Only another tab's newer revision makes overwriting unsafe. Quota or a
// closed database aborts the transaction with the prior revision intact,
// so the next autosave or source change retries under the same check.
if ((error as { name?: unknown } | null)?.name === 'PetHabitatConflict') persistenceFailed = true;
get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.';
return false;
}
}
async function mayLeave(): Promise<boolean> {
return await persist(true) || window.confirm('This visit could not be saved. Cancel to keep it and export a replay, or leave without saving its latest progress.');
+3 -1
View File
@@ -145,7 +145,9 @@ export class TraceLibrary {
request.onsuccess = () => {
const previous: HabitatEntry | undefined = request.result;
if (previous?.revision !== expectedRevision) {
fail(new Error('Another pet tab saved this habitat. Save a replay file to keep this version, or reload the saved habitat.')); return;
// Named so the page stops autosaving only for this unrecoverable case.
fail(Object.assign(new Error('Another pet tab saved this habitat. Save a replay file to keep this version, or reload the saved habitat.'),
{ name: 'PetHabitatConflict' })); return;
}
const revision = (previous?.revision ?? 0) + 1;
if (archive) {
+25 -2
View File
@@ -37,12 +37,27 @@ public struct PetSharedFrame: Decodable {
public let appearance: PetAppearance?
public let activity: PetActionCue?
public let producerConnected: Bool, storageAvailable: Bool, audioOwner: String?, audioUnavailable: Bool
/// Every rendered field of both poses, not only the points: after the
/// owner exits, any local process can bind its loopback port and answer.
public func validate() throws {
guard version == 1, UUID(uuidString: identity) != nil, UUID(uuidString: epoch) != nil,
timeMs.isFinite, timeMs >= 0, digest.count == 16, appearance?.valid != false,
["swim", "dive", "roll", "breathe", "drift", "doze", "wake"].contains(behaviour),
["none", "orient", "approach", "call"].contains(needs),
activity.map({ $0.label.count <= 256 && ($0.tool?.count ?? 0) <= 256 && (0...256).contains($0.parallel) }) != false,
[state, still.state].allSatisfy(Self.valid), [style, still.style].allSatisfy(Self.valid),
[points, still.points].allSatisfy({ $0.count == 980 && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { $0.isFinite && abs($0) <= 8 } } })
else { throw PetCoreError.invalid("Invalid shared pet frame.") }
}
/// The shared core's validatePetState bounds.
static func valid(_ s: PetState) -> Bool {
[s.activity, s.coherence, s.attention, s.observed, s.lit].allSatisfy { $0.isFinite && (0...1).contains($0) }
&& [s.roamX, s.roamY, s.flip].allSatisfy { $0.isFinite && abs($0) <= 1 } && channelIndex(s.channel) != nil
}
static func valid(_ f: Frame) -> Bool {
[f.r, f.g, f.b].allSatisfy { $0.isFinite && (0...255).contains($0) } && f.alpha.isFinite && (0...1).contains(f.alpha)
&& f.work.isFinite && channelIndex(f.channel) != nil
}
}
/// A view, never a world. Its task polls immutable owner snapshots. Reopening
@@ -135,8 +150,16 @@ public struct PetSharedFrame: Decodable {
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(connection.port)\(path)")!)
request.setValue("Bearer " + connection.token, forHTTPHeaderField: "Authorization")
if let body { request.httpMethod = "POST"; request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
let (data, response) = try await session.data(for: request)
guard data.count <= (path == "/v1/export" ? 64 : 8) * 1024 * 1024, (response as? HTTPURLResponse)?.statusCode == 200 else {
// Stream against the bound; never buffer an oversized body before checking it.
let limit = (path == "/v1/export" ? 64 : 8) * 1024 * 1024
let (bytes, response) = try await session.bytes(for: request)
guard response.expectedContentLength <= Int64(limit) else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
var data = Data(); data.reserveCapacity(Int(max(0, response.expectedContentLength)))
for try await byte in bytes {
guard data.count < limit else { bytes.task.cancel(); throw PetCoreError.invalid("Shared pet response exceeds its bound.") }
data.append(byte)
}
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
let error = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let message = error?["error"] as? String ?? "Shared pet unavailable."
if path == "/v1/action", (response as? HTTPURLResponse)?.statusCode == 409, !message.contains("storage") { pending = nil }
+21 -5
View File
@@ -12,7 +12,7 @@ import * as audio from '../dist/core/pet-audio.js';
/** Run the compiled browser controller and real world with minimal DOM sinks.
* Storage and file-read completion are controlled: this tests async caller
* contracts, not IndexedDB transactions, file-picker grants or rendering. */
async function browser({ deferFirstSave = true, handle } = {}) {
async function browser({ deferFirstSave = true, handle, later = n => Promise.resolve(n) } = {}) {
const nodes = new Map(), saves = [], intervals = [], timers = [], listeners = new Map();
let complete, fail, confirmations = 0;
const context = new Proxy({}, { get: (object, key) => object[key] ?? (() => {}) });
@@ -29,7 +29,7 @@ async function browser({ deferFirstSave = true, handle } = {}) {
saveHabitat(habitat, revision, archive) {
saves.push(structuredClone({ habitat, revision, archive }));
if (deferFirstSave && saves.length === 1) return new Promise((resolve, reject) => { complete = resolve; fail = reject; });
return Promise.resolve(saves.length);
return later(saves.length);
}
}
// Windows checkouts can use CRLF; exercise that asset in the actual controller.
@@ -47,7 +47,7 @@ async function browser({ deferFirstSave = true, handle } = {}) {
setTimeout, clearTimeout, structuredClone, TextEncoder, devicePixelRatio: 1 };
const inspect = await vm.runInNewContext(`(async () => { ${controller}\n return () => world.recording(true); })()`, environment);
assert.equal(intervals.length, 1, 'Controller starts its autosave after loading');
return { node, saves, autosave: intervals[0], complete: () => complete(1), fail: () => fail(new Error('Disk unavailable')),
return { node, saves, autosave: intervals[0], complete: () => complete(1), fail: (error = new Error('Disk unavailable')) => fail(error),
confirmations: () => confirmations, recording: () => structuredClone(inspect()),
poll: () => { assert.ok(timers.length); return timers.shift()(); },
visibility: hidden => { environment.document.hidden = hidden; listeners.get('visibilitychange')(); } };
@@ -75,13 +75,29 @@ test('browser source change waits for autosave and archives inputs accepted duri
});
test('browser failed autosave asks before leaving and cancel preserves the current source', async () => {
const page = await browser();
const page = await browser({ later: () => Promise.reject(new Error('Disk unavailable')) });
page.autosave(); page.node('mode').value = 'demo';
const changing = page.node('mode').onchange();
page.fail(); await changing;
assert.equal(page.confirmations(), 1);
assert.equal(page.node('mode').value, 'wild');
assert.equal(page.saves.length, 1, 'No archive or replacement after the failed write');
assert.equal(page.saves.length, 2, 'The archive is retried once; its failure replaces nothing');
});
test('browser retries a failed habitat save but never overwrites another tab after a revision conflict', async () => {
const settle = () => new Promise(resolve => setImmediate(resolve));
const page = await browser();
page.autosave(); page.fail(); await settle();
assert.doesNotMatch(page.node('persistence').textContent, /Habitat saved/);
page.autosave(); await settle();
assert.equal(page.saves.length, 2, 'A storage failure does not disable later saves');
assert.equal(page.saves[1].revision, undefined, 'The retry keeps the last committed revision');
assert.match(page.node('persistence').textContent, /Habitat saved/);
const conflicted = await browser();
conflicted.autosave();
conflicted.fail(Object.assign(new Error('Another pet tab saved this habitat.'), { name: 'PetHabitatConflict' })); await settle();
conflicted.autosave(); await settle();
assert.equal(conflicted.saves.length, 1, 'A newer revision from another tab stops autosave');
});
test('browser live follow discards a read crossing suspension and primes the resumed file', async () => {
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { importTrace } from '../dist/core/ingest.js';
import { compilePetTelemetry } from '../dist/core/pet-telemetry.js';
const session = entries => JSON.stringify({ metadata: { id: 'session-1' }, journal: { entries } });
const at = seconds => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
test('Codewhale session import bounds emitted events, keeps call identities and never ends a call before it starts', () => {
// One entry, many blocks: the limit applies per event, not per entry.
const blocks = Array.from({ length: 5 }, (_, i) => ({ type: 'text', text: `block ${i}` }));
assert.throws(() => importTrace(session([{ id: 'e1', message: { role: 'assistant', content: blocks } }]), 's', { maxEvents: 3 }),
/3 event limit/);
// A repeated tool call id would otherwise silently replace the first call.
const call = id => ({ id, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: {} }] } });
assert.throws(() => importTrace(session([call('e1'), call('e2')]), 's'), /Duplicate event identity \(session-1, call-1\)/);
// A result timestamped before its call is clamped, reported, and still compiles.
const [trace] = importTrace(session([
{ id: 'e1', created_at: at(10), message: { role: 'assistant', content: [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: {} }] } },
{ id: 'e2', created_at: at(4), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call-1', content: 'ok' }] } },
]), 's');
const tool = trace.events.find(e => e.id === 'call-1');
assert.equal(tool.openEnded, false);
assert.ok(tool.endTime >= tool.startTime);
assert.ok(trace.warnings.some(w => w.includes('timestamped before its call')));
assert.ok(compilePetTelemetry(trace.events, trace.duration).length > 0);
});
+14
View File
@@ -61,3 +61,17 @@ test('native live delivery journals only newly observed packets across resume',
for (let i = 0; i < 12; i++) pet.step(1 / 30, true);
assert.equal(JSON.parse(pet.snapshot()).state.channel, 'human');
});
test('a rejected single Engine observation cannot refresh or prune the owner reducer', () => {
const pet = new PetNative(points, '', '[]', true);
pet.observeEngine(JSON.stringify({ event: 'turn_started', turn_id: 't' }), 0);
pet.observeEngine(JSON.stringify({ event: 'operation_activity_started', span_id: 's', activity_kind: 'editing' }), 10);
const before = JSON.parse(pet.presentation()).activity;
// Passes field validation, then fails the type-specific turn_id requirement.
assert.throws(() => pet.observeEngine(JSON.stringify({ event: 'turn_started' }), 60_000), /Missing Engine turn_id/);
const after = JSON.parse(pet.presentation()).activity;
assert.equal(after.observedAtMs, 10, 'The rejected packet is not a fresh observation');
assert.deepEqual(after, before);
pet.observeEngine(JSON.stringify({ event: 'tool_call_heartbeat' }), 20);
assert.equal(JSON.parse(pet.presentation()).activity.observedAtMs, 20, 'The clock was not advanced by the rejected packet');
});
+30 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, writeFile, stat, readdir, rename, symlink, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { join, dirname, basename } from 'node:path';
import { tmpdir } from 'node:os';
import { createPetRecorder } from '../scripts/lib/pet-recorder.mjs';
import { compilePetTelemetry, encodePetJSONL, decodePetJSONL, PetLiveTape } from '../dist/core/pet-telemetry.js';
@@ -224,3 +224,32 @@ test('the actual CLI resumes after process death at the same path without reclai
third.stopRecorder(); assert.equal((await third.exited)[0], 0, third.log);
assert.equal(decodePetJSONL(await readFile(part(path, 2), 'utf8')).length, buckets.length);
});
test('the actual watch CLI starts a new segment after a suspension instead of replaying every missed bucket', { timeout: 20_000 }, async t => {
const { once } = await import('node:events');
const { setTimeout: delay } = await import('node:timers/promises');
const path = await destination(), input = `${path}.source.json`;
await writeFile(input, JSON.stringify({ schemaVersion: 1, id: 'old', traceId: 'fixture', name: 'bash', category: 'code', startTime: 0, endTime: 1, attributes: {} }));
// Test-only preload: after one second the monotonic clock jumps 100 s
// (250 buckets), as a resumed laptop would. The shipped recorder is unchanged.
const jump = 'const now=performance.now.bind(performance),t0=Date.now();performance.now=()=>now()+(Date.now()-t0>1000?100000:0);';
const env = { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=data:text/javascript,${encodeURIComponent(jump)}`.trim() };
const child = spawnRecorder([`--input=${input}`, `--output=${path}`, '--watch', '--segment-buckets=50'], env);
let log = ''; child.stdout.on('data', b => log += b); child.stderr.on('data', b => log += b);
const exited = once(child, 'exit');
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
const deadline = Date.now() + 12_000;
while (Date.now() < deadline && child.exitCode === null) {
// The archive link precedes replacement; the report follows it.
try { if (/Archived pet recording/.test(log) && decodePetJSONL(await readFile(path, 'utf8')).length >= 2) break; } catch { /* Wait for the post-gap segment. */ }
await delay(25);
}
child.stopRecorder(); const [code] = await exited; assert.equal(code, 0, log);
const names = (await readdir(dirname(path))).filter(name => name.startsWith(`${basename(path)}.segment-`));
assert.deepEqual(names, [`${basename(path)}.segment-000001.jsonl`], 'One outage archives one segment, not 250 replayed buckets');
const before = decodePetJSONL(await readFile(part(path, 1), 'utf8')), after = decodePetJSONL(await readFile(path, 'utf8'));
assert.ok(before.length >= 1 && before.length < 50, `${before.length} pre-suspension rows`);
assert.ok(after.length >= 2 && after.length < 50, `${after.length} post-suspension rows`);
assert.equal(after[0].observed, 0, 'The first bucket after the outage is unknown');
assert.match(log, /suspended or stalled for 10\d s; starting a new segment/);
});
+30
View File
@@ -163,6 +163,36 @@ exec /bin/ln "$@"
f.untouched();
});
test(`${kind}: a failed second publication removes the command this run published`, t => {
const f = fixture(t, kind);
// Another writer claims codew between preflight and its publication.
executable(path.join(f.bin, "ln"), `#!/bin/sh
case "$(basename "$1")" in codew) printf 'another writer' > "$2codew" ;; esac
exec /bin/ln "$@"
`);
const result = f.run();
assert.notEqual(result.status, 0, result.stdout);
assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false, "no half-installed pair is left behind");
assert.equal(fs.readFileSync(path.join(f.destination, "codew"), "utf8"), "another writer");
assert.match(result.stderr, /did not complete/);
assert.equal(fs.readdirSync(f.destination).some(name => name.startsWith(".codewhale-install.")), false);
f.untouched();
});
test(`${kind}: rollback never removes a command that was already installed`, t => {
const f = fixture(t, kind);
assert.equal(f.run().status, 0);
fs.unlinkSync(path.join(f.destination, "codew"));
const kept = fs.statSync(path.join(f.destination, "codewhale")).ino;
executable(path.join(f.bin, "ln"), `#!/bin/sh
case "$(basename "$1")" in codew) printf 'another writer' > "$2codew" ;; esac
exec /bin/ln "$@"
`);
assert.notEqual(f.run().status, 0);
assert.equal(fs.statSync(path.join(f.destination, "codewhale")).ino, kept);
f.untouched();
});
for (const collision of ["directory", "directory symlink"]) {
test(`${kind}: a raced ${collision} cannot redirect publication`, t => {
const f = fixture(t, kind);
+23 -1
View File
@@ -136,7 +136,28 @@ fi
stage=""
stage_dir=""
trap 'if [[ -n "$stage" ]]; then rm -f "$stage"; fi; if [[ -n "$stage_dir" ]]; then rmdir "$stage_dir"; fi' EXIT
# Commands this run published, as "source<TAB>destination" lines. If a later
# publication fails, they are removed again (only while each is still the
# exact file this run wrote), so a failed install leaves no half-installed
# pair; files that were already installed are never touched.
published=""
rollback_published() {
local src dst
while IFS=$'\t' read -r src dst; do
[[ -n "$dst" ]] || continue
if [[ ! -L "$dst" && -f "$dst" ]] && cmp -s "$src" "$dst"; then
rm -f "$dst"
echo "Removed $dst: this install did not complete." >&2
fi
done <<< "$published"
}
on_exit() {
local status=$?
if [[ -n "$stage" ]]; then rm -f "$stage"; fi
if [[ -n "$stage_dir" ]]; then rmdir "$stage_dir"; fi
if [[ "$status" -ne 0 && -n "$published" ]]; then rollback_published; fi
}
trap on_exit EXIT
install_binary() {
local src="$1" dst="$2"
check_destination "$src" "$dst"
@@ -157,6 +178,7 @@ install_binary() {
echo "ERROR: installed path changed during publication: $dst" >&2
return 1
}
published+="$src"$'\t'"$dst"$'\n'
rm -f "$stage"
rmdir "$stage_dir"
stage=""
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
// The GitHub Release asset set, checked as a whole.
//
// release.yml uploads into a DRAFT release, then runs this with
// --draft --asset-dir artifacts --publish
// which requires the draft to carry exactly the authoritative inventory
// (allReleaseAssetNames), every asset fully uploaded with the local byte
// size, and only then flips draft -> published. The whole set becomes public
// at once; an upload that dies halfway leaves a draft nobody can install from,
// not a public partial release.
//
// release-republish.yml runs it with --manifest: an older tag may predate the
// current inventory, so there the release's own checksum manifest is the
// inventory, and the release must carry exactly the manifest's assets plus
// the manifest itself before any channel is derived from it.
//
// Set GH_BIN to choose the GitHub CLI binary (tests use a fake).
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { allReleaseAssetNames, CHECKSUM_MANIFEST } = require("../../npm/codewhale/scripts/artifacts");
const { validateTarget } = require("./ensure-release-assets-absent");
function usage() {
return [
"Usage:",
" node scripts/release/verify-release-inventory.js [--draft] [--asset-dir DIR] [--publish] OWNER/REPO vX.Y.Z",
" node scripts/release/verify-release-inventory.js --manifest OWNER/REPO vX.Y.Z",
].join("\n");
}
function ghRunner(ghBin = process.env.GH_BIN || "gh", exec = execFileSync) {
return (args) => {
try {
return exec(ghBin, args, {
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const detail = String(error && error.stderr ? error.stderr : error && error.message).trim();
throw new Error(`gh ${args.slice(0, 2).join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
};
}
/** The draft (listed, since drafts are invisible on the tags endpoint) or the published release. */
function findRelease(repo, tag, { draft }, gh) {
validateTarget(repo, tag);
if (!draft) {
const release = JSON.parse(gh(["api", `repos/${repo}/releases/tags/${encodeURIComponent(tag)}`]));
if (!release || release.draft) throw new Error(`GitHub Release ${tag} is not published`);
return release;
}
const listed = JSON.parse(gh(["api", `repos/${repo}/releases?per_page=100`]));
if (!Array.isArray(listed)) throw new Error("GitHub did not return a release list");
const drafts = listed.filter((release) => release && release.draft === true && release.tag_name === tag);
if (drafts.length !== 1) {
throw new Error(
`Expected exactly one draft release for ${tag}; found ${drafts.length}. ` +
"A stale draft from an earlier failed run must be reviewed and deleted by a maintainer before rerunning.",
);
}
return drafts[0];
}
/** Exact names, each fully uploaded, and (with a local directory) the same byte size. */
function assertInventory(release, tag, expectedNames, assetDir) {
if (!release || !Array.isArray(release.assets)) {
throw new Error(`GitHub Release ${tag} did not provide an asset inventory`);
}
const byName = new Map();
for (const asset of release.assets) {
if (!asset || typeof asset.name !== "string" || byName.has(asset.name)) {
throw new Error(`GitHub Release ${tag} has a malformed or duplicate asset entry`);
}
byName.set(asset.name, asset);
}
const expected = new Set(expectedNames);
const missing = expectedNames.filter((name) => !byName.has(name));
const unexpected = [...byName.keys()].filter((name) => !expected.has(name));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`GitHub Release ${tag} does not carry the exact asset inventory` +
`${missing.length > 0 ? `; missing: ${missing.join(", ")}` : ""}` +
`${unexpected.length > 0 ? `; unexpected: ${unexpected.join(", ")}` : ""}`,
);
}
for (const name of expectedNames) {
const asset = byName.get(name);
if (asset.state !== "uploaded" || !(asset.size > 0)) {
throw new Error(`GitHub Release ${tag} asset ${name} is not fully uploaded (state ${asset.state}, ${asset.size} bytes)`);
}
if (assetDir) {
const local = fs.statSync(path.join(assetDir, name)).size;
if (asset.size !== local) {
throw new Error(`GitHub Release ${tag} asset ${name} has ${asset.size} bytes; the verified local asset has ${local}`);
}
}
}
return expectedNames.length;
}
/** Names the release's own checksum manifest lists, plus the manifest. */
function manifestInventory(repo, tag, gh) {
const text = gh(["release", "download", tag, "--repo", repo, "--pattern", CHECKSUM_MANIFEST, "--output", "-"]);
const names = [];
for (const line of String(text).split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^[a-fA-F0-9]{64}\s+\*?(.+)$/);
if (!match) throw new Error(`${CHECKSUM_MANIFEST} contains an invalid row: ${trimmed}`);
names.push(match[1]);
}
if (names.length === 0) throw new Error(`${CHECKSUM_MANIFEST} for ${tag} lists no assets`);
return [...names, CHECKSUM_MANIFEST];
}
function run(argv, gh = ghRunner(), log = console.log) {
const flags = { draft: false, publish: false, manifest: false, assetDir: null };
const positional = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--draft") flags.draft = true;
else if (arg === "--publish") flags.publish = true;
else if (arg === "--manifest") flags.manifest = true;
else if (arg === "--asset-dir") flags.assetDir = argv[++i];
else positional.push(arg);
}
if (positional.length !== 2 || (flags.publish && !flags.draft) || (flags.manifest && (flags.draft || flags.assetDir))) {
throw new Error(usage());
}
const [repo, tag] = positional;
const release = findRelease(repo, tag, { draft: flags.draft }, gh);
const expected = flags.manifest ? manifestInventory(repo, tag, gh) : allReleaseAssetNames();
const count = assertInventory(release, tag, expected, flags.assetDir);
log(`Verified ${count} assets on the ${flags.draft ? "draft" : "published"} release ${tag}`);
if (!flags.publish) return;
gh(["api", "-X", "PATCH", `repos/${repo}/releases/${release.id}`, "-F", "draft=false"]);
const published = findRelease(repo, tag, { draft: false }, gh);
if (published.id !== release.id) {
throw new Error(`Published release for ${tag} is ${published.id}, not the verified draft ${release.id}`);
}
assertInventory(published, tag, expected, flags.assetDir);
log(`Published ${tag} with its ${count} verified assets`);
}
if (require.main === module) {
try {
run(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
module.exports = { assertInventory, findRelease, manifestInventory, run };
@@ -0,0 +1,98 @@
#!/usr/bin/env node
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { allReleaseAssetNames, CHECKSUM_MANIFEST } = require("../../npm/codewhale/scripts/artifacts");
const { run } = require("./verify-release-inventory");
const REPO = "Hmbown/CodeWhale";
const TAG = "v0.10.1";
function localAssets(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-release-inventory-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
for (const [index, name] of allReleaseAssetNames().entries()) fs.writeFileSync(path.join(dir, name), "x".repeat(index + 1));
return dir;
}
function uploaded(names, sizeOf = (index) => index + 1) {
return names.map((name, index) => ({ name, state: "uploaded", size: sizeOf(index) }));
}
/** A gh double over one repository's releases; records every call. */
function fakeGh(releases, { manifest = "" } = {}) {
const calls = [];
const gh = (args) => {
calls.push(args.join(" "));
if (args[0] === "release" && args[1] === "download") return manifest;
const [, method, endpoint] = args[1] === "-X" ? [null, args[2], args[3]] : [null, "GET", args[1]];
if (method === "PATCH") {
const id = Number(endpoint.split("/").pop());
for (const release of releases) if (release.id === id) release.draft = false;
return "{}";
}
if (endpoint.startsWith(`repos/${REPO}/releases/tags/`)) {
const found = releases.find((release) => release.tag_name === TAG && !release.draft);
if (!found) throw new Error("gh api failed: HTTP 404");
return JSON.stringify(found);
}
if (endpoint.startsWith(`repos/${REPO}/releases?`)) return JSON.stringify(releases);
throw new Error(`unexpected gh call: ${args.join(" ")}`);
};
return { gh, calls };
}
test("a complete draft is published at once, only after its inventory verifies", (t) => {
const dir = localAssets(t);
const releases = [{ id: 7, tag_name: TAG, draft: true, assets: uploaded(allReleaseAssetNames()) }];
const { gh, calls } = fakeGh(releases);
run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {});
assert.equal(releases[0].draft, false);
const patch = calls.findIndex((call) => call.startsWith("api -X PATCH"));
assert.ok(patch > 0, "publishing follows the draft lookup");
assert.equal(calls[patch], `api -X PATCH repos/${REPO}/releases/7 -F draft=false`);
});
test("a partial, unfinished or resized draft is never published", (t) => {
const dir = localAssets(t);
const names = allReleaseAssetNames();
const cases = [
["missing", uploaded(names.slice(0, -1)), /missing: /],
["starter", uploaded(names).map((asset, index) => (index === 3 ? { ...asset, state: "starter" } : asset)), /not fully uploaded/],
["size", uploaded(names, () => 1), /bytes; the verified local asset has/],
["unexpected", [...uploaded(names), { name: "stray.txt", state: "uploaded", size: 1 }], /unexpected: stray\.txt/],
];
for (const [label, assets, message] of cases) {
const releases = [{ id: 7, tag_name: TAG, draft: true, assets }];
const { gh, calls } = fakeGh(releases);
assert.throws(() => run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {}), message, label);
assert.equal(releases[0].draft, true, label);
assert.equal(calls.some((call) => call.includes("PATCH")), false, label);
}
});
test("zero or several drafts for the tag need a maintainer, not a guess", () => {
for (const drafts of [[], [1, 2]]) {
const releases = drafts.map((id) => ({ id, tag_name: TAG, draft: true, assets: uploaded(allReleaseAssetNames()) }));
const { gh } = fakeGh(releases);
assert.throws(() => run(["--draft", "--publish", REPO, TAG], gh, () => {}), /Expected exactly one draft release/);
}
});
test("republish derives only from a release carrying exactly its own checksum manifest", () => {
const names = ["codewhale-linux-x64", "codewhale-macos-arm64"];
const manifest = names.map((name) => `${"a".repeat(64)} ${name}`).join("\n");
const complete = [{ id: 3, tag_name: TAG, draft: false, assets: uploaded([...names, CHECKSUM_MANIFEST]) }];
assert.doesNotThrow(() => run(["--manifest", REPO, TAG], fakeGh(complete, { manifest }).gh, () => {}));
const partial = [{ id: 3, tag_name: TAG, draft: false, assets: uploaded([names[0], CHECKSUM_MANIFEST]) }];
assert.throws(() => run(["--manifest", REPO, TAG], fakeGh(partial, { manifest }).gh, () => {}), /missing: codewhale-macos-arm64/);
});
test("usage refuses publish without draft and manifest with a local directory", () => {
assert.throws(() => run(["--publish", REPO, TAG], () => "[]", () => {}), /Usage/);
assert.throws(() => run(["--manifest", "--asset-dir", "x", REPO, TAG], () => "[]", () => {}), /Usage/);
});
+38 -1
View File
@@ -109,7 +109,44 @@ EOF
chmod 0640 /etc/codewhale/feishu-bridge.env
fi
ufw allow OpenSSH
# SSH_ALLOWED_CIDRS (comma or space separated IPv4/IPv6 CIDRs) limits who can
# reach SSH. Every entry is validated before the firewall changes, the narrow
# rules are added before the open one is removed (so the current session is
# never cut off), and without the variable SSH stays open to every source
# with a loud warning; the Lighthouse console firewall is the other layer.
valid_cidr() {
local cidr="$1" ip bits octet
[[ "${cidr}" == */* ]] || return 1
ip="${cidr%/*}"
bits="${cidr##*/}"
[[ "${bits}" =~ ^[0-9]{1,3}$ ]] || return 1
if [[ "${ip}" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
(( 10#${bits} >= 8 && 10#${bits} <= 32 )) || return 1
IFS=. read -r -a octets <<< "${ip}"
for octet in "${octets[@]}"; do (( 10#${octet} <= 255 )) || return 1; done
return 0
fi
[[ "${ip}" =~ ^[0-9A-Fa-f:]+$ && "${ip}" == *:* ]] || return 1
(( 10#${bits} >= 16 && 10#${bits} <= 128 ))
}
ssh_cidrs="${SSH_ALLOWED_CIDRS:-}"
ssh_cidrs="${ssh_cidrs//,/ }"
if [[ -n "${ssh_cidrs// /}" ]]; then
for cidr in ${ssh_cidrs}; do
if ! valid_cidr "${cidr}"; then
echo "SSH_ALLOWED_CIDRS entry '${cidr}' is not an IPv4 /8-/32 or IPv6 /16-/128 CIDR; the firewall was not changed." >&2
exit 1
fi
done
for cidr in ${ssh_cidrs}; do
ufw allow from "${cidr}" to any app OpenSSH
done
ufw delete allow OpenSSH >/dev/null 2>&1 || true
else
echo "WARNING: SSH is reachable from every source. Set SSH_ALLOWED_CIDRS (e.g. 203.0.113.4/32) and rerun to restrict it." >&2
ufw allow OpenSSH
fi
ufw --force enable
cat <<EOF
+27 -7
View File
@@ -38,7 +38,7 @@ esac
install -d -m 0750 -o root -g "${CODEWHALE_USER}" /etc/codewhale
install -d -m 0700 -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${BRIDGE_STATE_DIR}"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${BRIDGE_DST}"
mkdir -p "$(dirname "${BRIDGE_DST}")"
if [[ ! -f /etc/codewhale/runtime.env && -f "${REPO_ROOT}/deploy/tencent-lighthouse/examples/runtime.env.example" ]]; then
install -m 0640 -o root -g "${CODEWHALE_USER}" \
@@ -51,18 +51,38 @@ if [[ ! -f "${BRIDGE_ENV}" && -f "${REPO_ROOT}/${BRIDGE_ENV_EXAMPLE}" ]]; then
"${REPO_ROOT}/${BRIDGE_ENV_EXAMPLE}" \
"${BRIDGE_ENV}"
fi
rsync -a --delete \
# Build the new bridge tree beside the live one and swap it in only once its
# dependencies installed. A failed copy or `npm ci` (which empties
# node_modules first) leaves the running bridge's tree exactly as it was.
stage="$(mktemp -d "${BRIDGE_DST}.stage.XXXXXX")"
previous=""
trap 'if [[ -n "${stage}" ]]; then rm -rf "${stage}"; fi' EXIT
chmod 0755 "${stage}"
rsync -a \
--exclude node_modules \
"${REPO_ROOT}/${BRIDGE_SRC}/" \
"${BRIDGE_DST}/"
chown -R "${CODEWHALE_USER}:${CODEWHALE_USER}" "${BRIDGE_DST}"
"${stage}/"
chown -R "${CODEWHALE_USER}:${CODEWHALE_USER}" "${stage}"
if [[ -f "${BRIDGE_DST}/package-lock.json" ]]; then
sudo -u "${CODEWHALE_USER}" npm --prefix "${BRIDGE_DST}" ci --omit=dev
if [[ -f "${stage}/package-lock.json" ]]; then
sudo -u "${CODEWHALE_USER}" npm --prefix "${stage}" ci --omit=dev
else
sudo -u "${CODEWHALE_USER}" npm --prefix "${BRIDGE_DST}" install --omit=dev
sudo -u "${CODEWHALE_USER}" npm --prefix "${stage}" install --omit=dev
fi
if [[ -e "${BRIDGE_DST}" ]]; then
previous="${BRIDGE_DST}.previous.$$"
mv "${BRIDGE_DST}" "${previous}"
fi
if ! mv "${stage}" "${BRIDGE_DST}"; then
# Put the old tree back rather than leave the service without one.
if [[ -n "${previous}" ]]; then mv "${previous}" "${BRIDGE_DST}"; fi
echo "Could not move the new bridge into ${BRIDGE_DST}; the previous tree was restored." >&2
exit 1
fi
stage=""
if [[ -n "${previous}" ]]; then rm -rf "${previous}"; fi
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/codewhale-runtime.service" /etc/systemd/system/codewhale-runtime.service
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/${BRIDGE_UNIT}" "/etc/systemd/system/${BRIDGE_UNIT}"
+10 -3
View File
@@ -26,8 +26,13 @@ suffix over the finding's full identity, max 80 chars — so unchanged findings
dedup and changed findings land as new drafts. Semantic-drift model output is
validated and capped (10 drafts/run) before any KV writes.
Usage logged to:
usage:<YYYY-MM-DD>
A post whose GitHub outcome was unknown (network error, 5xx, 408, 429) leaves
draft-post-unknown:<type>:<id> (30 days; the next post of that
draft first looks on GitHub for
the earlier attempt's post)
Usage logged to (one record per model call; sum the day's prefix):
usage:<YYYY-MM-DD>:<timestamp>:<uuid>
```
## Cron schedule
@@ -57,7 +62,9 @@ All drafts follow these rules:
- Each cron invocation caps at ~30k input tokens and ~2k output tokens.
- Issue/PR bodies are truncated to 1000–4000 chars before sending to the model.
- Deduplication: `hasFreshDraft` checks if a draft already exists that's newer than the item's `updated_at`. Skips if so.
- Token usage is logged to `usage:<YYYY-MM-DD>` KV keys (retained 90 days).
- Token usage is logged as one `usage:<YYYY-MM-DD>:…` KV record per model call
(retained 90 days); list the day's prefix and sum `calls`/`inputTokens`/
`outputTokens`. Records are append-only because KV has no atomic increment.
- If `DEEPSEEK_API_KEY` is missing or the API errors, the cron returns 200 with `{ skipped: true, reason }` — never crashes, never retry-loops.
## Maintainer review surface
+12 -7
View File
@@ -27,7 +27,12 @@ interface FaqItem {
sources?: string[];
}
const faqEn: FaqItem[] = [
/**
* English answers render for every non-Chinese locale (the page body has no
* other translation yet), so their internal links follow the reader's route
* locale instead of silently moving them to `/en`.
*/
const faqEn = (locale: string): FaqItem[] => [
{
q: "What is Codewhale?",
a: (
@@ -62,7 +67,7 @@ brew install Hmbown/deepseek-tui/codewhale
Run <code className="inline">codewhale</code> to start. First run creates <code className="inline">~/.codewhale/</code> automatically. Legacy <code className="inline">~/.deepseek/</code> is still read as a compatibility fallback.
Android arm64 / Termux is preview support: npm works only when the
selected package version has matching Android assets in its GitHub Release.
See the <Link href="/en/install" className="body-link">full install guide</Link> for China mirrors, Docker, and troubleshooting.
See the <Link href={`/${locale}/install`} className="body-link">full install guide</Link> for China mirrors, Docker, and troubleshooting.
</p>
</>
),
@@ -123,7 +128,7 @@ codewhale doctor # full connectivity check`}
<ul className="list-disc pl-5 space-y-1 text-sm text-ink-soft mb-3">
<li><strong>DeepSeek</strong> — bundled default with a native API route, reasoning streaming, cache metrics, and thinking effort control.</li>
<li><strong>OpenRouter</strong> — unified API for DeepSeek models and other open-model routes.</li>
<li><strong>{FACTS.providers.length - 2} more routes</strong> — including OpenAI-compatible, Anthropic, Mistral AI, OpenAI Codex, xAI, Moonshot/Kimi, Z.ai, MiniMax, StepFun, Volcengine Ark, Baidu Qianfan, Model Studio, NVIDIA NIM, Fireworks AI, Together AI, DeepInfra, SiliconFlow, Novita AI, Hugging Face, Arcee AI, AtlasCloud, and the keyless local endpoints SGLang, vLLM, and Ollama. <Link href="/en/models" className="body-link">The full list is generated from the provider registry</Link>.</li>
<li><strong>{FACTS.providers.length - 2} more routes</strong> — including OpenAI-compatible, Anthropic, Mistral AI, OpenAI Codex, xAI, Moonshot/Kimi, Z.ai, MiniMax, StepFun, Volcengine Ark, Baidu Qianfan, Model Studio, NVIDIA NIM, Fireworks AI, Together AI, DeepInfra, SiliconFlow, Novita AI, Hugging Face, Arcee AI, AtlasCloud, and the keyless local endpoints SGLang, vLLM, and Ollama. <Link href={`/${locale}/models`} className="body-link">The full list is generated from the provider registry</Link>.</li>
</ul>
<p>
Set the corresponding env var (e.g. <code className="inline">OPENROUTER_API_KEY</code>) and your provider in <code className="inline">~/.codewhale/config.toml</code>.
@@ -258,7 +263,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
<>
Codewhale is a bidirectional MCP client and server. Define servers in <code className="inline">~/.codewhale/mcp.json</code>.
Tools appear as <code className="inline">mcp_&lt;server&gt;_&lt;tool&gt;</code>. You can also expose Codewhale as an MCP server with <code className="inline">codewhale mcp</code>.
See the <Link href="/en/docs/mcp" className="body-link">docs page</Link> for configuration examples.
See the <Link href={`/${locale}/docs/mcp`} className="body-link">docs page</Link> for configuration examples.
</>
),
sources: ["docs/MCP.md"],
@@ -269,7 +274,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
<>
No CLA required. Fork, branch with conventional commits (<code className="inline">feat:</code>, <code className="inline">fix:</code>, etc.), run the local checks, open a PR.
The maintainer reads everything personally. Start with issues labeled <code className="inline">good first issue</code>.
See the <Link href="/en/contribute" className="body-link">contribute page</Link> and <a href="https://github.com/Hmbown/CodeWhale/blob/main/CONTRIBUTING.md" className="body-link">CONTRIBUTING.md</a>.
See the <Link href={`/${locale}/contribute`} className="body-link">contribute page</Link> and <a href="https://github.com/Hmbown/CodeWhale/blob/main/CONTRIBUTING.md" className="body-link">CONTRIBUTING.md</a>.
</>
),
sources: ["CONTRIBUTING.md"],
@@ -317,7 +322,7 @@ registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"`}
</p>
<p className="mb-2">
A CNB mirror is maintained for users who cannot reliably reach GitHub
(<Link href="/en/install" className="body-link">docs/CNB_MIRROR.md</Link>).
(<Link href={`/${locale}/install`} className="body-link">docs/CNB_MIRROR.md</Link>).
Cargo users can use the TUNA mirror for faster downloads in China.
</p>
<p>
@@ -748,7 +753,7 @@ brew update && brew upgrade codewhale`}
export default async function FaqPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = getFaq(locale);
const items = { en: faqEn, zh: faqZh }[pickTextLocale(locale)];
const items = pickTextLocale(locale) === "zh" ? faqZh : faqEn(locale);
const canonicalLocale = canonicalLocaleForPath("/faq", locale);
const jsonLd = buildFaqPageJsonLd({
items,
+81 -11
View File
@@ -5,6 +5,9 @@ import {
approveDigestRecord,
claimDraft,
clearDraftResolution,
clearPostOutcomeUnknown,
getPostOutcomeUnknown,
markPostOutcomeUnknown,
deleteDigestRecord,
deleteDraft,
getAgentEnv,
@@ -237,6 +240,38 @@ export async function POST(req: Request) {
const commentBody = editedBody ?? originalBody;
// GitHub has no idempotency key. If an earlier attempt's outcome was
// unknown, the post it may have created is looked for (from shortly
// before that attempt) instead of posting blind a second time.
const unknownAt = await getPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => null);
const lookSince = unknownAt ? new Date(Date.parse(unknownAt) - 10 * 60 * 1000).toISOString() : null;
const githubList = async (url: string, authScheme: "token" | "Bearer"): Promise<Record<string, unknown>[]> => {
const res = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GitHub ${res.status}`);
const list: unknown = await res.json();
if (!Array.isArray(list)) throw new Error("GitHub returned no list");
return list.filter((item): item is Record<string, unknown> => !!item && typeof item === "object");
};
// This request posted nothing, so the draft is free again.
const lookupFailed = async () => {
try {
await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
await releaseDraftClaim(env.CURATED_KV, heldClaim);
} catch { /* the claim expires on its own */ }
return NextResponse.json(
{ error: "could not check GitHub for the post an earlier attempt may have created; nothing was posted, retry" },
{ status: 502 }
);
};
const alreadyPosted = "An earlier attempt whose outcome was unknown had already posted this; it was not posted again.";
// After GitHub accepted the post, bookkeeping failures must not turn into
// an error the maintainer would "fix" by posting again.
const recordPosted = async (): Promise<string | undefined> => {
@@ -245,6 +280,7 @@ export async function POST(req: Request) {
// The marker now carries the decision, so a later claim sees it; a
// reopened draft (new activity clears the marker) is postable again.
await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined);
await clearPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 });
return undefined;
} catch (e) {
@@ -270,11 +306,14 @@ export async function POST(req: Request) {
return null;
}
};
const unknownOutcome = (detail: string) =>
NextResponse.json(
const unknownOutcome = async (detail: string) => {
// Remembered past the claim, so a later retry looks before posting.
await markPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
return NextResponse.json(
{ error: `${detail}; check GitHub before retrying (retry unlocks in 15 minutes)` },
{ status: 502 }
);
};
const githubFailed = async (res: Response) => {
const text = await res.text().catch(() => "");
if (!githubDefinitelyRejected(res.status)) {
@@ -296,16 +335,36 @@ export async function POST(req: Request) {
const digestRepo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
const issuesUrl = `https://api.github.com/repos/${digestRepo}/issues`;
const digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token");
if (!digestRes) return unknownOutcome("GitHub request failed");
if (!digestRes.ok) return githubFailed(digestRes);
const issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string };
let issue: { number?: number; html_url?: string } | undefined;
if (lookSince) {
try {
const listUrl = `${issuesUrl}?labels=digest&state=all&since=${encodeURIComponent(lookSince)}&per_page=100`;
const found = (await githubList(listUrl, "token")).find(
(item) => !item.pull_request && item.title === title && item.body === digestBody
);
if (found) {
issue = {
number: typeof found.number === "number" ? found.number : undefined,
html_url: typeof found.html_url === "string" ? found.html_url : undefined,
};
}
} catch {
return lookupFailed();
}
}
const reconciled = issue !== undefined;
if (!issue) {
const digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token");
if (!digestRes) return unknownOutcome("GitHub request failed");
if (!digestRes.ok) return githubFailed(digestRes);
issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string };
}
draft.posted = true;
if (typeof issue.number === "number") draft.targetNumber = issue.number;
if (typeof issue.html_url === "string") draft.targetUrl = issue.html_url;
let warning = await recordPosted();
if (reconciled) warning ??= alreadyPosted;
// Publishing to /digest is the approval, for the language shown to the
// maintainer only, and only of the record that renders to exactly the
@@ -342,13 +401,24 @@ export async function POST(req: Request) {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
const commentUrl = `https://api.github.com/repos/${repo}/issues/${draft.targetNumber}/comments`;
const ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer");
if (!ghRes) return unknownOutcome("GitHub request failed");
if (!ghRes.ok) return githubFailed(ghRes);
let reconciled = false;
if (lookSince) {
try {
const listUrl = `${commentUrl}?since=${encodeURIComponent(lookSince)}&per_page=100`;
reconciled = (await githubList(listUrl, "Bearer")).some((comment) => comment.body === commentBody);
} catch {
return lookupFailed();
}
}
if (!reconciled) {
const ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer");
if (!ghRes) return unknownOutcome("GitHub request failed");
if (!ghRes.ok) return githubFailed(ghRes);
}
// Mark as posted
draft.posted = true;
const warning = await recordPosted();
const warning = (await recordPosted()) ?? (reconciled ? alreadyPosted : undefined);
return NextResponse.json({ ok: true, action: "posted", ...(warning ? { warning } : {}) });
}
+4 -1
View File
@@ -60,6 +60,9 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
const poster = asset.poster!;
const video = asset.video!;
const captions = asset.captions ?? [];
// The track matching the page's language is the default; English only
// when the page's language has no captions.
const defaultCaption = captions.some((track) => track.srclang === locale) ? locale : "en";
return (
<figure
@@ -84,7 +87,7 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
src={`/${track.src}`}
srcLang={track.srclang}
label={track.label}
default={track.srclang === "en"}
default={track.srclang === defaultCaption}
/>
))}
</video>
+32
View File
@@ -43,3 +43,35 @@ export async function readBoundedBody(request: { body: ReadableStream<Uint8Array
}
return bytes;
}
/** Budget for background (cron) reads of other hosts. */
export interface OutboundBudget {
/** Deadline for the whole exchange, body included. */
timeoutMs?: number;
/** Largest body read; a larger one is an error, never a silent truncation. */
maxBytes?: number;
}
export const OUTBOUND_TIMEOUT_MS = 15_000;
export const OUTBOUND_MAX_BYTES = 2 * 1024 * 1024;
/**
* One outbound GET-style read under a deadline and a byte cap. A slow or
* endless peer cannot hold a cron invocation until the platform kills it, and
* a huge body cannot exhaust isolate memory. Non-2xx answers return
* `{ ok: false }` with the body discarded; transport, timeout and size
* failures throw, so callers keep their own fallbacks.
*/
export async function fetchBoundedText(
url: string,
init: RequestInit = {},
{ timeoutMs = OUTBOUND_TIMEOUT_MS, maxBytes = OUTBOUND_MAX_BYTES }: OutboundBudget = {},
): Promise<{ ok: boolean; status: number; text: string }> {
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* The status is the answer. */ }
return { ok: false, status: response.status, text: "" };
}
const bytes = await readBoundedBody(response, maxBytes);
return { ok: true, status: response.status, text: new TextDecoder().decode(bytes) };
}
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchBoundedText } from "./bounded-body";
import { runDigest, runDupes, runPrReview, runStale, runTriage } from "./community-agent-tasks";
import { runLinkCheck } from "./content-watch";
import { deriveFactsFromRemote } from "./facts-drift";
afterEach(() => vi.unstubAllGlobals());
/** A peer that accepts the request and never answers until aborted. */
function hangingFetch() {
return vi.fn((_: unknown, init?: RequestInit) => new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true });
}));
}
describe("bounded outbound reads", () => {
it("gives up on a silent peer at the deadline and refuses an oversized body", async () => {
vi.stubGlobal("fetch", hangingFetch());
const started = Date.now();
await expect(fetchBoundedText("https://slow.test", {}, { timeoutMs: 50 })).rejects.toThrow();
expect(Date.now() - started).toBeLessThan(2_000);
vi.stubGlobal("fetch", vi.fn(async () => new Response("x".repeat(64))));
await expect(fetchBoundedText("https://big.test", {}, { maxBytes: 16 })).rejects.toThrow("payload too large");
vi.stubGlobal("fetch", vi.fn(async () => new Response("gone", { status: 404 })));
expect(await fetchBoundedText("https://missing.test")).toEqual({ ok: false, status: 404, text: "" });
});
it("puts every cron read of other hosts under a deadline", async () => {
// Callers swallow transport errors, so record rather than assert inside.
const seen = vi.fn(async () => new Response("{}", { status: 500 }));
vi.stubGlobal("fetch", seen);
await deriveFactsFromRemote();
await runLinkCheck({ CURATED_KV: { get: async () => null, put: async () => undefined } } as never);
const kv = { get: async () => null, put: async () => undefined, delete: async () => undefined, list: async () => ({ keys: [], list_complete: true }) };
for (const task of [runTriage, runPrReview, runStale, runDupes, runDigest]) {
await task({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" } as never).catch(() => undefined);
}
expect(seen.mock.calls.length).toBeGreaterThan(1);
const unbounded = seen.mock.calls
.filter((call) => !((call as unknown[])[1] as RequestInit | undefined)?.signal)
.map((call) => String((call as unknown[])[0]));
expect(unbounded).toEqual([]);
});
});
+57 -1
View File
@@ -11,7 +11,7 @@ import {
} from "./cloud-facts";
import { TRUSTED_KEYS, type TrustedKey } from "./cloud-facts/keys";
import { GET, HEAD } from "../app/api/facts/v1/[channel]/route";
import { activePublishingKey, emitSql, readBoundedFile, readBoundedResponse, verifyEnvelope as verifyForPublisher } from "../scripts/facts-publish.mjs";
import { activePublishingKey, emitSql, publishRelease, readBoundedFile, readBoundedResponse, revokeRelease, verifyEnvelope as verifyForPublisher } from "../scripts/facts-publish.mjs";
import { parseRustKeys, parseTsKeys } from "../scripts/check-cloud-facts.mjs";
const fixturePath = new URL("../../docs/cloud-facts/fixtures/envelope-stable-v7.json", import.meta.url);
@@ -225,6 +225,21 @@ describe("cloud facts transport", () => {
expect((await resolveCloudFacts("stable", { ...env, CURATED_KV: kv }, { keys: [EPHEMERAL_KEY], now: () => NOW, fetchImpl: failing })).kind).toBe("unavailable");
});
it("never resurrects a revoked head through the stale cache after an outage", async () => {
const kv = new MemKV();
const ttls: unknown[] = [];
const put = kv.put.bind(kv);
kv.put = async (key: string, value: string, options?: { expirationTtl?: number }) => { ttls.push(options?.expirationTtl); await put(key, value); };
expect((await resolveCloudFacts("stable", { ...env, CURATED_KV: kv }, { ...opts, fetchImpl: supabaseFetch([rowFrom(fixture)]) })).kind).toBe("ok");
// The operator revokes the head: facts_current returns no row.
expect((await resolveCloudFacts("stable", { ...env, CURATED_KV: kv }, { ...opts, fetchImpl: supabaseFetch([]) })).kind).toBe("none");
// Supabase then goes down. The revoked envelope must not come back.
expect(await resolveCloudFacts("stable", { ...env, CURATED_KV: kv }, { ...opts, fetchImpl: failing })).toMatchObject({ kind: "unavailable" });
// The bridge lasts no longer than a client's own 6 h staleness bound.
expect(ttls.length).toBeGreaterThan(0);
expect(ttls.every((ttl) => ttl === 6 * 60 * 60)).toBe(true);
});
it("caps and cancels PostgREST streaming bodies before JSON parsing", async () => {
const oversized = overflowingStream();
const fetchImpl = vi.fn(async () => new Response(oversized.stream));
@@ -301,6 +316,47 @@ describe("facts response protocol", () => {
describe("facts publisher boundaries", () => {
const script = fileURLToPath(new URL("../scripts/facts-publish.mjs", import.meta.url));
/** A PostgREST double over one channel and its releases. */
function fakePostgrest(head: number, releases: { facts_version: number; status: string; payload_sha256: string; revoked_at?: string; revoke_reason?: string }[]) {
const calls: string[] = [];
const request = async (path: string, init: { method?: string; body?: Record<string, unknown> } = {}) => {
calls.push(`${init.method ?? "GET"} ${path}`);
if (path.startsWith("facts_channel")) return [{ id: "c1", max_facts_version: head }];
if (path === "facts_key") return null;
if (path === "facts_release") {
if (Number(init.body?.facts_version) <= head) throw Object.assign(new Error("PostgREST request failed (HTTP 409)"), { status: 409 });
head = Number(init.body?.facts_version);
const stored = { id: "r-new", facts_version: head, status: "published", payload_sha256: fixture.sha256 };
releases.push(stored);
return [stored];
}
const version = Number(/facts_version=eq\.(\d+)/.exec(path)?.[1]);
const matches = releases.filter((r) => r.facts_version === version && (!path.includes("status=eq.published") || r.status === "published"));
if (init.method === "PATCH") for (const r of matches) Object.assign(r, init.body);
return matches.map((r) => ({ id: `r-${r.facts_version}`, ...r }));
};
return { request, calls, releases };
}
it("revokes once, keeps the first revocation on a re-run, and refuses an unknown version", async () => {
const pg = fakePostgrest(3, [{ facts_version: 3, status: "published", payload_sha256: "a" }]);
expect(await revokeRelease(pg.request, { channel: "stable", version: 3, reason: "bad price", at: "2026-09-30T00:00:00Z" }))
.toMatchObject({ revoked: 1, already_revoked: false, head: true });
const again = await revokeRelease(pg.request, { channel: "stable", version: 3, reason: "second run", at: "2026-10-01T00:00:00Z" });
expect(again).toMatchObject({ revoked: 0, already_revoked: true, revoked_at: "2026-09-30T00:00:00Z", revoke_reason: "bad price" });
expect(pg.releases[0]).toMatchObject({ revoked_at: "2026-09-30T00:00:00Z", revoke_reason: "bad price" });
await expect(revokeRelease(pg.request, { channel: "stable", version: 9, reason: "typo", at: "2026-10-01T00:00:00Z" })).rejects.toThrow("no published facts_version 9");
});
it("publishes exactly the envelope and treats a replay of the same bytes as already published", async () => {
const pg = fakePostgrest(fixture.facts_version - 1, []);
const row = { facts_version: fixture.facts_version };
expect(await publishRelease(pg.request, fixture, row, "pub")).toMatchObject({ published: true, payload_sha256: fixture.sha256 });
expect(await publishRelease(pg.request, fixture, row, "pub")).toMatchObject({ published: false, already_published: true, release_id: "r-new" });
const other = { ...fixture, sha256: "f".repeat(64) };
await expect(publishRelease(pg.request, other, row, "pub")).rejects.toThrow("high-water mark");
});
it("requires an active pinned primary key and cannot publish with an explicit fixture public key", () => {
expect(() => activePublishingKey(fixture, [])).toThrow("not pinned and active");
expect(() => activePublishingKey(fixture, [{ ...TEST_KEY, status: "retired" }])).toThrow("not pinned and active");
+18 -2
View File
@@ -8,7 +8,11 @@ export const CHANNEL_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
export const KV_PREFIX = "facts:cloud:";
export const SUPABASE_TIMEOUT_MS = 3000;
export const MAX_ENVELOPE_BYTES = 768 * 1024;
const KV_TTL_SECS = 60 * 60 * 24 * 30;
// The last-good copy only bridges a transport outage. It lives no longer than
// a client keeps applying its own cached envelope (payload ttl_secs, 6 h by
// default, docs/CLOUD_FACTS.md), so an outage cannot extend a withdrawn
// release beyond the staleness every client already accepts.
const KV_TTL_SECS = 60 * 60 * 6;
const KEY_ID_RE = /^cwf-[a-z0-9-]{1,32}$/;
const VERSION_REQ_RE = /^(\*|(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\s*,\s*(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?)*)$/;
const MAX_SIGNATURES = 7; // Plus the primary signature: eight candidates total.
@@ -286,6 +290,13 @@ async function kvGet(env: CloudFactsEnv, channel: string): Promise<unknown> {
} catch { return null; }
}
async function retireLastGood(env: CloudFactsEnv, channel: string): Promise<void> {
if (!env.CURATED_KV || !isEnvelope(await kvGet(env, channel))) return;
try {
await env.CURATED_KV.put(`${KV_PREFIX}${channel}`, JSON.stringify({ retired: "no-current-head" }), { expirationTtl: KV_TTL_SECS });
} catch { /* Best effort, like the write; the copy still expires on its TTL. */ }
}
export async function resolveCloudFacts(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<CloudFactsResult> {
if (!isValidChannel(channel)) return { kind: "none" };
const keys = opts.keys ?? TRUSTED_KEYS;
@@ -295,7 +306,12 @@ export async function resolveCloudFacts(channel: string, env: CloudFactsEnv, opt
let source: "supabase" | "kv-stale" = "supabase";
try {
const row = await fetchCurrentRow(channel, env, opts);
if (!row) return { kind: "none" };
if (!row) {
// The head was revoked, expired or future-dated. Retire the last-good
// copy so a later outage cannot resurrect it through the stale path.
await retireLastGood(env, channel);
return { kind: "none" };
}
envelope = envelopeFromRow(row);
} catch {
source = "kv-stale";
+66 -3
View File
@@ -149,11 +149,15 @@ function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) {
});
}
/** GitHub stub that records every comment/issue creation. */
function stubGitHub(status = 201) {
/**
* GitHub stub that records every comment/issue creation. Reads (the lookup
* for an earlier unknown-outcome post) answer `existing` and are not posts.
*/
function stubGitHub(status = 201, existing: unknown[] = []) {
const posts: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = inputUrl(input);
if ((init?.method ?? "GET") === "GET") return jsonResponse(existing);
posts.push(url);
if (url.endsWith("/issues")) {
return jsonResponse({ number: 900, html_url: "https://github.com/Hmbown/CodeWhale/issues/900" }, status);
@@ -311,6 +315,26 @@ describe("weekly digest publication requires maintainer approval", () => {
expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(false);
});
it("finds a digest issue an unknown-outcome attempt created and publishes without a second issue", async () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
const draftKey = onlyKey(kv, "draft:digest:");
stubGitHub(504);
expect((await act(kv, { action: "post", draftKey, lang: "en" })).status).toBe(502);
// The claim and "posting" marker lapse (15 minutes in production).
for (const key of [...kv.values.keys()]) if (/^draft-(claim|resolved):digest:/.test(key)) kv.values.delete(key);
const body = JSON.parse(kv.values.get(draftKey)!).bodyEn as string;
const title = body.split("\n")[0].replace(/^#+\s*/, "").trim();
const posts = stubGitHub(201, [{ number: 901, html_url: "https://github.com/Hmbown/CodeWhale/issues/901", title, body }]);
const retry = await act(kv, { action: "post", draftKey, lang: "en" });
await expect(retry.json()).resolves.toMatchObject({ ok: true, number: 901, published: true });
expect(posts).toEqual([]);
});
it("never publishes an older record when a later run saved its draft but not its record", async () => {
const kv = new FakeKv();
stubDigestSources();
@@ -843,6 +867,45 @@ describe("admin claims with the DRAFT_CLAIM_LOCK Durable Object bound", () => {
expect(retryPosts).toHaveLength(1);
});
it("looks for the earlier attempt's comment after an unknown outcome instead of posting it twice", async () => {
const { kv, lock } = await lockedAdmin(502);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
// GitHub did create it: the late retry records it and posts nothing.
const posts = stubGitHub(201, [{ id: 7, body: "English body" }]);
const retry = await act(kv, { action: "post", draftKey: KEY });
expect(retry.status).toBe(200);
await expect(retry.json()).resolves.toMatchObject({ ok: true, action: "posted", warning: expect.stringContaining("not posted again") });
expect(posts).toEqual([]);
expect(JSON.parse(kv.values.get("draft-resolved:triage:42")!).state).toBe("posted");
expect(kv.values.has("draft-post-unknown:triage:42")).toBe(false);
});
it("posts nothing when the lookup for an earlier attempt fails, and frees the draft", async () => {
const { kv, lock } = await lockedAdmin(502);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
const posts: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if ((init?.method ?? "GET") === "GET") return jsonResponse({ message: "down" }, 503);
posts.push(inputUrl(input));
return jsonResponse({ id: 1 }, 201);
}));
const blocked = await act(kv, { action: "post", draftKey: KEY });
expect(blocked.status).toBe(502);
await expect(blocked.json()).resolves.toMatchObject({ error: expect.stringContaining("nothing was posted") });
expect(posts).toEqual([]);
// Once GitHub answers and has no such comment, the retry posts once.
const retryPosts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
expect(retryPosts).toHaveLength(1);
});
it("frees the draft at once when GitHub definitely rejected the post", async () => {
const { kv } = await lockedAdmin(422);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
+8
View File
@@ -1,3 +1,4 @@
import { OUTBOUND_TIMEOUT_MS } from "@/lib/bounded-body";
import { fetchFeed, fetchRepoStats } from "@/lib/github";
import { curate } from "@/lib/deepseek";
import { putDispatchWithKv } from "@/lib/kv";
@@ -87,6 +88,7 @@ export async function runTriage(env: AgentEnv): Promise<Record<string, unknown>>
const res = await fetch(
`https://api.github.com/repos/${repo}/issues?state=open&sort=created&direction=desc&per_page=30`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -153,6 +155,7 @@ export async function runPrReview(env: AgentEnv): Promise<Record<string, unknown
const res = await fetch(
`https://api.github.com/repos/${repo}/pulls?state=open&sort=created&direction=desc&per_page=20`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -177,6 +180,7 @@ export async function runPrReview(env: AgentEnv): Promise<Record<string, unknown
if (!pr.changed_files) {
try {
const diffRes = await fetch(`https://api.github.com/repos/${repo}/pulls/${pr.number}`, {
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -237,6 +241,7 @@ export async function runStale(env: AgentEnv): Promise<Record<string, unknown>>
const res = await fetch(
`https://api.github.com/search/issues?q=${encodeURIComponent(`repo:${repo} is:issue is:open updated:<${thirtyDaysAgo}`)}&sort=updated&per_page=20`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -303,6 +308,7 @@ export async function runDupes(env: AgentEnv): Promise<Record<string, unknown>>
const res = await fetch(
`https://api.github.com/repos/${repo}/issues?state=open&per_page=100`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -376,6 +382,7 @@ export async function runDigest(env: AgentEnv): Promise<Record<string, unknown>>
fetch(
`https://api.github.com/repos/${repo}/issues?state=all&since=${weekAgo}&per_page=50&sort=updated&direction=desc`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -387,6 +394,7 @@ export async function runDigest(env: AgentEnv): Promise<Record<string, unknown>>
fetch(
`https://api.github.com/repos/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=50`,
{
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { validateSession } from "./community-agent";
import { logUsage, validateSession } from "./community-agent";
describe("validateSession", () => {
let mockKV: {
@@ -81,3 +81,20 @@ describe("validateSession", () => {
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + sid);
});
});
describe("logUsage", () => {
it("keeps every call when cron tasks log at the same time", async () => {
// A KV whose reads resolve before either write lands, as overlapping
// Worker invocations see it.
const store = new Map<string, string>();
const kv = {
get: async (key: string) => { await new Promise((resolve) => setTimeout(resolve, 5)); return store.get(key) ?? null; },
put: async (key: string, value: string) => { await new Promise((resolve) => setTimeout(resolve, 5)); store.set(key, value); },
} as unknown as Parameters<typeof logUsage>[0];
await Promise.all([logUsage(kv, 100, 10), logUsage(kv, 200, 20), logUsage(kv, 300, 30)]);
const records = [...store.values()].map((value) => JSON.parse(value) as { calls: number; inputTokens: number });
expect(records.reduce((sum, record) => sum + record.calls, 0)).toBe(3);
expect(records.reduce((sum, record) => sum + record.inputTokens, 0)).toBe(600);
expect([...store.keys()].every((key) => key.startsWith(`usage:${new Date().toISOString().slice(0, 10)}`))).toBe(true);
});
});
+50 -10
View File
@@ -99,6 +99,8 @@ export function isAgentDraft(value: unknown): value is AgentDraft {
);
}
const MODEL_TIMEOUT_MS = 180_000;
export async function agentChat(
messages: ChatMessage[],
apiKey: string,
@@ -109,6 +111,9 @@ export async function agentChat(
const model = dsEnv?.model ?? process.env.DEEPSEEK_MODEL ?? FALLBACK_MODEL;
const res = await fetch(`${base}/v1/chat/completions`, {
method: "POST",
// Bounded so a stalled provider cannot hold a cron run until the
// platform kills it; generous because high-effort reasoning is slow.
signal: AbortSignal.timeout(MODEL_TIMEOUT_MS),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
@@ -556,6 +561,42 @@ export async function clearDraftResolution(
await kv.delete(resolutionKey(type, id));
}
// A post whose GitHub outcome was unknown (network error, 5xx, 408, 429).
// GitHub has no idempotency key, so this outlives the 15-minute claim: the
// next attempt looks for the post the earlier one may have created before
// posting again, however late the retry comes.
const POST_UNKNOWN_PREFIX = "draft-post-unknown:";
const POST_UNKNOWN_TTL_SEC = 60 * 60 * 24 * 30;
function postUnknownKey(type: AgentDraftType, id: string): string {
return POST_UNKNOWN_PREFIX + draftKey(type, id).slice("draft:".length);
}
export async function markPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<void> {
if (!kv) return;
await kv.put(postUnknownKey(type, id), JSON.stringify({ at: new Date().toISOString() }), {
expirationTtl: POST_UNKNOWN_TTL_SEC,
});
}
/** When the earliest unresolved unknown-outcome attempt happened, if any. */
export async function getPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<string | null> {
if (!kv) return null;
const raw = await kv.get(postUnknownKey(type, id));
if (!raw) return null;
try {
const at = (JSON.parse(raw) as { at?: unknown }).at;
if (typeof at === "string" && Number.isFinite(Date.parse(at))) return at;
} catch { /* fall through */ }
// An unreadable marker still means "look first"; look back its full life.
return new Date(Date.now() - POST_UNKNOWN_TTL_SEC * 1000).toISOString();
}
export async function clearPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<void> {
if (!kv) return;
await kv.delete(postUnknownKey(type, id));
}
// --- Public weekly digest records ---
//
// The cron writes the structured digest unapproved; only the maintainer's
@@ -810,16 +851,15 @@ export async function logUsage(
outputTokens: number
): Promise<void> {
if (!kv) return;
const date = new Date().toISOString().slice(0, 10);
const key = `usage:${date}`;
const raw = await kv.get(key);
const existing: UsageLog = raw
? JSON.parse(raw)
: { date, calls: 0, inputTokens: 0, outputTokens: 0 };
existing.calls += 1;
existing.inputTokens += inputTokens;
existing.outputTokens += outputTokens;
await kv.put(key, JSON.stringify(existing), { expirationTtl: 60 * 60 * 24 * 90 }); // 90 days
// One record per model call under the day's prefix. KV has no atomic
// increment, so a shared daily counter that overlapping cron tasks read,
// bumped and wrote back lost calls; an append-only record cannot.
const at = new Date().toISOString();
const date = at.slice(0, 10);
const record: UsageLog = { date, calls: 1, inputTokens, outputTokens };
await kv.put(`usage:${date}:${at}:${crypto.randomUUID()}`, JSON.stringify(record), {
expirationTtl: 60 * 60 * 24 * 90, // 90 days
});
}
/**
+12 -6
View File
@@ -13,6 +13,7 @@
* Both surface as drafts in CURATED_KV under `draft:linkcheck:<...>` and
* `draft:semantic-drift:<...>`, picked up by the existing /admin listing.
*/
import { fetchBoundedText } from "./bounded-body";
import { agentChat, draftStorageKey, getDraft, saveDraft, type AgentDraft, type DeepSeekEnv, VOICE_CONSTRAINTS } from "./community-agent";
interface KVNamespace {
@@ -95,15 +96,20 @@ export interface LinkCheckResult {
ms: number;
}
const PROBE_TIMEOUT_MS = 10_000;
async function probe(target: { url: string; label: string }): Promise<LinkCheckResult> {
const start = Date.now();
try {
// Use HEAD where possible; fall back to GET on 405/403 since some hosts
// (e.g. Cloudflare-protected) reject HEAD.
let r = await fetch(target.url, { method: "HEAD", redirect: "follow" });
// Each request has its own deadline; only the status matters, so a GET
// body is discarded unread.
let r = await fetch(target.url, { method: "HEAD", redirect: "follow", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
if (r.status === 405 || r.status === 403 || r.status === 404) {
// Some sites return 404 to HEAD but 200 to GET (e.g. NPM)
r = await fetch(target.url, { method: "GET", redirect: "follow" });
r = await fetch(target.url, { method: "GET", redirect: "follow", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
await r.body?.cancel().catch(() => undefined);
}
return { url: target.url, label: target.label, status: r.status, ok: r.ok, ms: Date.now() - start };
} catch {
@@ -291,10 +297,10 @@ export async function runSemanticDrift(env: WatchEnv): Promise<{ ok: boolean; dr
// Fetch CHANGELOG (truncated), recent commits, and live homepage HTML.
const [changelog, commits, homepageHtml, docsHtml] = await Promise.all([
fetch("https://raw.githubusercontent.com/Hmbown/CodeWhale/main/CHANGELOG.md", { headers: ghHeaders }).then((r) => r.ok ? r.text() : "").catch(() => ""),
fetch("https://api.github.com/repos/Hmbown/CodeWhale/commits?per_page=30", { headers: ghHeaders }).then((r) => r.ok ? r.json() as Promise<{ commit: { message: string }; sha: string }[]> : []).catch(() => []),
fetch("https://codewhale.net/en", { headers: { "User-Agent": "codewhale-watch" } }).then((r) => r.ok ? r.text() : "").catch(() => ""),
fetch("https://codewhale.net/en/docs", { headers: { "User-Agent": "codewhale-watch" } }).then((r) => r.ok ? r.text() : "").catch(() => ""),
fetchBoundedText("https://raw.githubusercontent.com/Hmbown/CodeWhale/main/CHANGELOG.md", { headers: ghHeaders }).then((r) => r.text).catch(() => ""),
fetchBoundedText("https://api.github.com/repos/Hmbown/CodeWhale/commits?per_page=30", { headers: ghHeaders }).then((r) => r.ok ? JSON.parse(r.text) as { commit: { message: string }; sha: string }[] : []).catch(() => []),
fetchBoundedText("https://codewhale.net/en", { headers: { "User-Agent": "codewhale-watch" } }).then((r) => r.text).catch(() => ""),
fetchBoundedText("https://codewhale.net/en/docs", { headers: { "User-Agent": "codewhale-watch" } }).then((r) => r.text).catch(() => ""),
]);
if (!changelog && (!commits || commits.length === 0)) {
+2
View File
@@ -27,6 +27,8 @@ export async function chat(
const model = dsEnv?.model ?? process.env.DEEPSEEK_MODEL ?? FALLBACK_MODEL;
const res = await fetch(`${base}/v1/chat/completions`, {
method: "POST",
// Same bound as agentChat: a stalled provider must not hold the cron run.
signal: AbortSignal.timeout(180_000),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
+6 -5
View File
@@ -13,7 +13,6 @@ import buildSitemap from "../app/sitemap";
import { DOC_TOPICS, docTopicHref, getTopic } from "./docs-map";
import { docsTopicIsCurrent } from "./docs-navigation";
import { locales } from "./i18n/config";
import { contentLocalesForPath } from "./i18n/content-locales";
import { getChrome, getHome } from "./i18n/dictionaries";
import {
currentNavHref,
@@ -91,9 +90,10 @@ describe("sitemap and hreflang preservation", () => {
});
it("keeps sitemap and hreflang output aligned with real translation coverage", () => {
// 18 home locales + 10 guide locales + English-only install + (en, zh) for every other route
// (including /product, /plugins, and /changelog, whose bodies ship en/zh only).
expect(sitemapEntries).toHaveLength(99);
// 18 home locales + 18 /computer-use locales + English-only install + (en, zh) for every
// other route (including /docs/guide, /product, /plugins, and /changelog, whose bodies
// ship en/zh only).
expect(sitemapEntries).toHaveLength(107);
expect(sitemapEntries.filter(entry => entry.url.endsWith("/install")).map(entry => entry.url))
.toEqual([`${SITE_URL}/en/install`]);
expect(sitemapEntries.some(entry => entry.url.endsWith("/pricing"))).toBe(false);
@@ -105,7 +105,8 @@ describe("sitemap and hreflang preservation", () => {
}
for (const [path, expectedLocales] of [
["/", locales],
["/docs/guide", contentLocalesForPath("/docs/guide")],
["/docs/guide", ["en", "zh"]],
["/computer-use", locales],
["/install", ["en"]],
["/docs", ["en", "zh"]],
] as const) {
+14
View File
@@ -151,6 +151,20 @@ describe("runFactsDrift", () => {
expect(isRepoFacts(JSON.parse(store.get("facts:current") ?? "null"))).toBe(true);
});
it("never replaces a snapshot from a newer source commit with an older one", async () => {
installGitHubFixture(VALID_GENERATED_FACTS);
const newer = JSON.stringify({ sourceCommittedAt: "2026-07-22T00:00:00Z", version: "9.9.9" });
const store = new Map<string, string>([["facts:current", newer]]);
const kv = {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => { store.set(key, value); },
};
// The fixture's source commit is 2026-07-21T23:00:00Z: an overlapping,
// slower run finishing after a newer one.
expect(await runFactsDrift({ CURATED_KV: kv })).toEqual({ ok: true, changed: false });
expect(store.get("facts:current")).toBe(newer);
});
// The scheduled handler discards the result, so the log line is the only
// signal that the cron stopped refreshing KV.
it("warns and writes nothing when the derived facts fail validation", async () => {
+15 -7
View File
@@ -12,6 +12,7 @@
* fixes itself within one cron tick — no redeploy. Semantic drift (a new
* feature should be advertised on the homepage) is still left to humans.
*/
import { fetchBoundedText } from "./bounded-body";
import type {
PublishedReleaseFact,
RepoFacts,
@@ -46,9 +47,8 @@ async function fetchText(
};
if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
try {
const r = await fetch(`${RAW_ROOT}/${revision}/${path}`, { headers });
if (!r.ok) return null;
return await r.text();
const r = await fetchBoundedText(`${RAW_ROOT}/${revision}/${path}`, { headers });
return r.ok ? r.text : null;
} catch {
return null;
}
@@ -62,12 +62,12 @@ async function fetchSourceMarker(ghToken?: string): Promise<SourceMarker | null>
};
if (ghToken) headers.Authorization = `Bearer ${ghToken}`;
try {
const response = await fetch(
const response = await fetchBoundedText(
"https://api.github.com/repos/Hmbown/CodeWhale/commits/main",
{ headers },
);
if (!response.ok) return null;
const json = (await response.json()) as {
const json = JSON.parse(response.text) as {
sha?: string;
commit?: { committer?: { date?: string } };
};
@@ -208,9 +208,9 @@ async function fetchLatestPublishedRelease(
};
if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
try {
const r = await fetch("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest", { headers });
const r = await fetchBoundedText("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest", { headers });
if (!r.ok) return null;
const j = (await r.json()) as {
const j = JSON.parse(r.text) as {
tag_name?: string;
published_at?: string;
};
@@ -417,6 +417,14 @@ export async function runFactsDrift(env: { CURATED_KV?: KVNamespace; GITHUB_TOKE
}
}
// Overlapping runs can finish out of order; a snapshot from an older source
// commit never replaces a newer one (KV has no compare-and-set, so this
// narrows the race to the read-to-write window rather than closing it).
const cachedAt = Date.parse(String(cached.sourceCommittedAt ?? ""));
if (cachedRaw && Number.isFinite(cachedAt) && cachedAt > Date.parse(String(remote.sourceCommittedAt))) {
return { ok: true, changed: false };
}
const diffs = diff(cached, remote);
if (diffs.length === 0) {
return { ok: true, changed: false };
+8 -7
View File
@@ -1,3 +1,4 @@
import { OUTBOUND_TIMEOUT_MS } from "./bounded-body";
import type { FeedItem, RepoStats } from "./types";
const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale";
@@ -34,12 +35,12 @@ export async function fetchRepoStats(token?: string): Promise<RepoStats> {
}
const [repoRes, contribRes, releaseRes] = await Promise.all([
fetch(`${GH}/repos/${REPO}`, { headers: headers(token), next: { revalidate: 1800 } }),
fetch(`${GH}/repos/${REPO}`, { headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), next: { revalidate: 1800 } }),
fetch(`${GH}/repos/${REPO}/contributors?per_page=1&anon=true`, {
headers: headers(token),
headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
next: { revalidate: 3600 },
}),
fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), next: { revalidate: 3600 } }),
fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), next: { revalidate: 3600 } }),
]);
const repo = repoRes.ok ? await repoRes.json().catch(() => null) : null;
@@ -52,7 +53,7 @@ export async function fetchRepoStats(token?: string): Promise<RepoStats> {
// Open PRs: cheapest path is the search API.
const prRes = await fetch(
`${GH}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`,
{ headers: headers(token), next: { revalidate: 1800 } }
{ headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), next: { revalidate: 1800 } }
);
const prJson = prRes.ok ? ((await prRes.json().catch(() => null)) as { total_count?: number } | null) : null;
const openPulls = typeof prJson?.total_count === "number" ? prJson.total_count : 0;
@@ -209,14 +210,14 @@ export async function loadFeed(token?: string, limit = 30): Promise<FeedLoad> {
const [issuesRes, pullsRes, releasesRes] = await Promise.all([
fetch(
`${GH}/repos/${REPO}/issues?state=all&per_page=${limit}&sort=updated&direction=desc`,
{ headers: headers(token), next: { revalidate: 600 } }
{ headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), next: { revalidate: 600 } }
),
fetch(
`${GH}/repos/${REPO}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`,
{ headers: headers(token), next: { revalidate: 600 } }
{ headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS), next: { revalidate: 600 } }
),
fetch(`${GH}/repos/${REPO}/releases?per_page=${RELEASE_WINDOW}`, {
headers: headers(token),
headers: headers(token), signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
next: { revalidate: 3600 },
}),
]);
+11 -2
View File
@@ -1,10 +1,19 @@
import { defaultLocale, locales } from "./config";
import { hasComputerUseTranslation } from "./dictionaries";
/** Routes whose page-body translation coverage differs from English/Chinese. */
/**
* Routes whose page-body translation coverage differs from English/Chinese.
*
* `/docs/guide` is not listed: its French/German/Catalan/Hindi/Turkish/
* Italian/Polish/Arabic dictionaries translate only the overview and headings,
* while the getting-started steps that make up the body ship in English and
* Chinese, so those variants canonicalize to English like any other partial
* page.
*/
const ROUTE_CONTENT_LOCALES: Readonly<Record<string, readonly string[]>> = {
"/": locales,
"/install": ["en"],
"/docs/guide": ["en", "zh", "fr", "de", "ca", "hi", "tr", "it", "pl", "ar"],
"/computer-use": locales.filter(hasComputerUseTranslation),
};
/** Most first-party page bodies currently ship in English and Chinese. */
+9
View File
@@ -445,6 +445,15 @@ export function getComputerUse(locale: string): ComputerUseDict {
return COMPUTER_USE[locale] ?? enComputerUse;
}
/**
* Locales whose /computer-use page body is translated (the page renders only
* from this dictionary). The content-locale registry derives canonical,
* hreflang and sitemap coverage from it, so they cannot drift apart.
*/
export function hasComputerUseTranslation(locale: string): boolean {
return locale === "en" || Object.hasOwn(COMPUTER_USE, locale);
}
export function getStates(locale: string): StatesDict {
return STATES[locale] ?? enStates;
}
+16 -6
View File
@@ -7,38 +7,48 @@ import { middleware } from "../middleware";
* custom domains, so without a canonical-host redirect the whole site is
* reachable — and indexable — twice.
*/
function request(url: string, host: string, headers: Record<string, string> = {}) {
function request(url: string, host: string, headers: Record<string, string> = {}, method = "GET") {
return new NextRequest(new URL(url), {
method,
headers: new Headers({ host, ...headers }),
});
}
describe("canonical host", () => {
it("301s www to the apex, preserving path and query", () => {
it("permanently redirects www to the apex, preserving path and query", () => {
const res = middleware(
request("https://www.codewhale.net/en/docs/hooks?x=1", "www.codewhale.net"),
);
expect(res.status).toBe(301);
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("https://codewhale.net/en/docs/hooks?x=1");
});
it("redirects assets and API routes too, so a moved document stops pulling from www", () => {
for (const path of ["/_next/static/chunk.js", "/api/curated", "/opengraph-image"]) {
const res = middleware(request(`https://www.codewhale.net${path}`, "www.codewhale.net"));
expect(res.status, path).toBe(301);
expect(res.status, path).toBe(308);
expect(res.headers.get("location"), path).toBe(`https://codewhale.net${path}`);
}
});
it("keeps an admin form POST a POST across the host move", () => {
// A 301 lets clients replay a POST as a body-less GET (#6561 W01-07).
for (const path of ["/api/admin/login", "/api/admin/post"]) {
const res = middleware(request(`https://www.codewhale.net${path}`, "www.codewhale.net", {}, "POST"));
expect(res.status, path).toBe(308);
expect(res.headers.get("location"), path).toBe(`https://codewhale.net${path}`);
}
});
it("leaves the apex host alone", () => {
const res = middleware(request("https://codewhale.net/en", "codewhale.net"));
expect(res.status).not.toBe(301);
expect(res.status).not.toBe(308);
});
it("leaves localhost and preview hosts alone", () => {
for (const host of ["localhost:3000", "codewhale-web.pages.dev"]) {
const res = middleware(request("https://example.test/en", host));
expect(res.status, host).not.toBe(301);
expect(res.status, host).not.toBe(308);
}
});
+5 -6
View File
@@ -71,12 +71,11 @@ describe("page metadata", () => {
it("advertises only genuine page-body translations", () => {
for (const [path, locale, expectedLocales, expectedCanonical] of [
["/", "ja", locales, `${SITE_URL}/ja`],
[
"/docs/guide",
"fr",
["en", "zh", "fr", "de", "ca", "hi", "tr", "it", "pl", "ar"],
`${SITE_URL}/fr/docs/guide`,
],
// The guide's steps (its body) ship en/zh only; a French overview
// dictionary does not make the page a French translation.
["/docs/guide", "fr", ["en", "zh"], `${SITE_URL}/en/docs/guide`],
// /computer-use renders only from its per-locale dictionary.
["/computer-use", "ja", locales, `${SITE_URL}/ja/computer-use`],
[
"/docs/guide",
"ja",
+2 -1
View File
@@ -12,6 +12,7 @@
* Categories that come back empty fall through to the page's static items —
* the maintainer can adopt label-driven roadmap incrementally.
*/
import { OUTBOUND_TIMEOUT_MS } from "./bounded-body";
import { truncateChars } from "./truncate";
const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale";
@@ -48,7 +49,7 @@ async function gh<T>(url: string, ghToken?: string): Promise<T | null> {
};
if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
try {
const r = await fetch(url, { headers });
const r = await fetch(url, { headers, signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS) });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
+52
View File
@@ -0,0 +1,52 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const script = fileURLToPath(new URL("../scripts/sync-latest-release.mjs", import.meta.url));
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
const OLD = { tag: "v0.10.0", version: "0.10.0", publishedAt: "2026-09-01T00:00:00Z", url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.10.0" };
/** The script in a throwaway web/ + docs/ tree, with GitHub stubbed. */
function run(args: string[], { status = 200, cloud = { release: { latest: "0.10.0" } } }: { status?: number; cloud?: unknown } = {}) {
const root = mkdtempSync(join(tmpdir(), "cw-sync-release-"));
roots.push(root);
mkdirSync(join(root, "web", "scripts"), { recursive: true });
mkdirSync(join(root, "web", "data"), { recursive: true });
mkdirSync(join(root, "docs", "cloud-facts"), { recursive: true });
copyFileSync(script, join(root, "web", "scripts", "sync-latest-release.mjs"));
writeFileSync(join(root, "web", "data", "latest-published-release.json"), `${JSON.stringify(OLD, null, 2)}\n`);
writeFileSync(join(root, "docs", "public-surface-facts.json"), `${JSON.stringify({ latestPublishedRelease: { ...OLD, sources: ["x"] } }, null, 2)}\n`);
writeFileSync(join(root, "docs", "cloud-facts", "stable.json"), `${JSON.stringify(cloud, null, 2)}\n`);
const release = JSON.stringify({ tag_name: "v0.10.1", published_at: "2026-09-02T00:00:00Z" });
const stub = `globalThis.fetch = async () => new Response(${JSON.stringify(release)}, { status: ${status} });`;
const result = spawnSync(process.execPath, ["--import", `data:text/javascript,${encodeURIComponent(stub)}`, join(root, "web", "scripts", "sync-latest-release.mjs"), ...args], { encoding: "utf8", env: { ...process.env, GITHUB_TOKEN: "" } });
const read = (...parts: string[]) => JSON.parse(readFileSync(join(root, ...parts), "utf8"));
return { result, root, target: () => read("web", "data", "latest-published-release.json"), mirror: () => read("docs", "public-surface-facts.json") };
}
describe("sync-latest-release", () => {
it("fails --check when GitHub cannot be read instead of reporting current", () => {
const { result } = run(["--check"], { status: 503 });
expect(result.status).toBe(1);
expect(result.stderr).toContain("could not read the latest release");
});
it("writes nothing when any mirror is unusable, then moves all three together", () => {
const broken = run([], { cloud: { notRelease: true } });
expect(broken.result.status).toBe(1);
expect(broken.target()).toEqual(OLD);
expect(broken.mirror().latestPublishedRelease.tag).toBe("v0.10.0");
expect(readdirSync(join(broken.root, "web", "data"))).toEqual(["latest-published-release.json"]);
const ok = run([]);
expect(ok.result.status, ok.result.stderr).toBe(0);
expect(ok.target().tag).toBe("v0.10.1");
expect(ok.mirror().latestPublishedRelease).toMatchObject({ tag: "v0.10.1", sources: ["x"] });
expect(JSON.parse(readFileSync(join(ok.root, "docs", "cloud-facts", "stable.json"), "utf8")).release).toMatchObject({ latest: "0.10.1" });
});
});
+4 -1
View File
@@ -41,7 +41,10 @@ function canonicalHostRedirect(req: NextRequest): NextResponse | null {
const url = req.nextUrl.clone();
url.host = CANONICAL_HOST;
url.port = "";
return NextResponse.redirect(url, 301);
// 308, not 301: clients may turn a 301'd POST (the admin login and post
// forms) into a GET and drop its body. 308 keeps the method and body and is
// still a permanent move for crawlers.
return NextResponse.redirect(url, 308);
}
/**
+30 -1
View File
@@ -293,7 +293,32 @@ fi
stage=""
stage_dir=""
trap 'rm -rf "$tmpdir"; if [ -n "$stage" ]; then rm -f "$stage"; fi; if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi' EXIT INT TERM
# Commands this run published. If a later publication fails they are removed
# again, but only while each is still the exact file this run wrote, so a
# failed install leaves no half-installed pair and never touches a file that
# was already installed.
published_codewhale=""
published_codew=""
rollback_published() {
if [ -n "$published_codewhale" ] && [ ! -L "$published_codewhale" ] && [ -f "$published_codewhale" ] && cmp -s "$tmpdir/codewhale" "$published_codewhale"; then
rm -f "$published_codewhale"
say "Removed $published_codewhale: this install did not complete." >&2
fi
if [ -n "$published_codew" ] && [ ! -L "$published_codew" ] && [ -f "$published_codew" ] && cmp -s "$tmpdir/codew" "$published_codew"; then
rm -f "$published_codew"
say "Removed $published_codew: this install did not complete." >&2
fi
}
on_exit() {
status=$?
if [ -n "$stage" ]; then rm -f "$stage"; fi
if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi
if [ "$status" -ne 0 ]; then rollback_published; fi
rm -rf "$tmpdir"
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
install_binary() {
source="$1"
destination="$2"
@@ -312,6 +337,10 @@ install_binary() {
# itself would make ln treat a raced-in directory/symlink as a container.
ln "$stage" "$install_dir/" || fail "destination appeared during install: $destination; it was not replaced"
[ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$stage" "$destination" || fail "installed path changed during publication: $destination"
case "$destination" in
*/codewhale) published_codewhale="$destination" ;;
*/codew) published_codew="$destination" ;;
esac
rm -f "$stage"
rmdir "$stage_dir"
stage=""
+3
View File
@@ -11,3 +11,6 @@ export function emitSql(envelope: unknown, options: { publishedBy?: string; publ
export function readBoundedFile(path: string, maxBytes?: number): Buffer;
export function readBoundedResponse(response: Response, maxBytes?: number): Promise<string>;
export function buildEnvelope(options: { privateKey: KeyObject; keyId: string; payload: Record<string, unknown> }): CloudFactsEnvelope;
export type PostgrestRequest = (path: string, init?: { method?: string; body?: Record<string, unknown>; prefer?: string }) => Promise<unknown>;
export function publishRelease(request: PostgrestRequest, envelope: CloudFactsEnvelope, row: Record<string, unknown>, publicKey: string): Promise<Record<string, unknown>>;
export function revokeRelease(request: PostgrestRequest, options: { channel: string; version: number; reason: string; at: string }): Promise<Record<string, unknown>>;
+56 -14
View File
@@ -463,11 +463,64 @@ async function postgrest(path, { method = "GET", body, prefer } = {}) {
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) { await res.body?.cancel(); throw new Error(`PostgREST request failed (HTTP ${res.status})`); }
if (!res.ok) { await res.body?.cancel(); throw Object.assign(new Error(`PostgREST request failed (HTTP ${res.status})`), { status: res.status }); }
const text = await readBoundedResponse(res);
return text ? JSON.parse(text) : null;
}
/**
* Insert one signed release and prove what landed. A re-run of the same
* envelope (the first run's reply was lost) reports the stored row instead of
* failing on the version high-water mark; different bytes at that version
* stay an error. `request` is postgrest() or a test double.
*/
export async function publishRelease(request, envelope, row, publicKey) {
const channels = await request(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(envelope.channel)}&select=id`);
if (channels?.length !== 1) throw new Error(`channel ${envelope.channel} does not exist`);
const channelId = channels[0].id;
await request("facts_key", { method: "POST", body: { key_id: envelope.key_id, scope: "global", algorithm: "ed25519", public_key: publicKey, status: "active" }, prefer: "resolution=ignore-duplicates,return=minimal" });
let inserted;
try {
inserted = await request("facts_release", { method: "POST", body: { ...row, channel_id: channelId }, prefer: "return=representation" });
} catch (error) {
if (error?.status !== 409) throw error;
const existing = await request(`facts_release?channel_id=eq.${channelId}&facts_version=eq.${envelope.facts_version}&select=id,payload_sha256,status`);
if (existing?.length === 1 && existing[0].payload_sha256 === envelope.sha256) {
return { published: false, already_published: true, channel: envelope.channel, facts_version: envelope.facts_version, release_id: existing[0].id, payload_sha256: existing[0].payload_sha256, status: existing[0].status };
}
throw new Error(`facts_version ${envelope.facts_version} is at or below channel ${envelope.channel}'s high-water mark and is not this envelope; publish a higher facts_version`);
}
if (inserted?.length !== 1 || inserted[0].payload_sha256 !== envelope.sha256) {
throw new Error(`facts_release insert did not return exactly this envelope (rows: ${inserted?.length ?? 0}); inspect channel ${envelope.channel} before retrying`);
}
return { published: true, channel: envelope.channel, facts_version: envelope.facts_version, release_id: inserted[0].id, payload_sha256: inserted[0].payload_sha256 };
}
/**
* Revoke one published release. Only a `published` row flips, so a re-run
* keeps the first revocation's time and reason; a version that matches
* nothing is an error, never a silent `revoked: 0`. `head` says whether the
* channel now serves nothing (revoking an older version changes no delivery).
*/
export async function revokeRelease(request, { channel, version, reason, at }) {
const channels = await request(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(channel)}&select=id,max_facts_version`);
if (channels?.length !== 1) throw new Error(`channel ${channel} does not exist`);
const head = Number(channels[0].max_facts_version) === version;
const release = `facts_release?channel_id=eq.${channels[0].id}&facts_version=eq.${version}`;
const updated = await request(`${release}&status=eq.published`, {
method: "PATCH",
body: { status: "revoked", revoked_at: at, revoke_reason: reason },
prefer: "return=representation",
});
if (updated?.length === 1) return { revoked: 1, already_revoked: false, head, channel, facts_version: version };
if ((updated?.length ?? 0) > 1) throw new Error(`revoke matched ${updated.length} releases for ${channel} v${version}; inspect the channel`);
const existing = await request(`${release}&select=status,revoked_at,revoke_reason`);
if (existing?.length === 1 && existing[0].status === "revoked") {
return { revoked: 0, already_revoked: true, head, channel, facts_version: version, revoked_at: existing[0].revoked_at, revoke_reason: existing[0].revoke_reason };
}
throw new Error(`channel ${channel} has no published facts_version ${version} to revoke`);
}
export async function readBoundedResponse(response, maxBytes = MAX_ENVELOPE_BYTES) {
const length = response.headers.get("content-length");
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) {
@@ -591,11 +644,7 @@ async function main(argv) {
console.log(JSON.stringify({ dry_run: true, channel: envelope.channel, facts_key: { key_id: envelope.key_id, public_key: pub }, facts_release: { ...row, payload_b64: `<${envelope.payload_b64.length} chars>` } }, null, 2));
return 0;
}
const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(envelope.channel)}&select=id`);
if (!channels?.length) throw new Error(`channel ${envelope.channel} does not exist`);
await postgrest("facts_key", { method: "POST", body: { key_id: envelope.key_id, scope: "global", algorithm: "ed25519", public_key: pub, status: "active" }, prefer: "resolution=ignore-duplicates,return=minimal" });
const inserted = await postgrest("facts_release", { method: "POST", body: { ...row, channel_id: channels[0].id }, prefer: "return=representation" });
console.log(JSON.stringify({ published: true, channel: envelope.channel, facts_version: envelope.facts_version, release_id: inserted?.[0]?.id ?? null, payload_sha256: inserted?.[0]?.payload_sha256 ?? null }, null, 2));
console.log(JSON.stringify(await publishRelease(postgrest, envelope, row, pub), null, 2));
return 0;
}
if (cmd === "revoke") {
@@ -607,14 +656,7 @@ async function main(argv) {
console.log(JSON.stringify({ dry_run: true, channel, facts_version: version, status: "revoked", revoke_reason: reason }, null, 2));
return 0;
}
const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(channel)}&select=id`);
if (!channels?.length) throw new Error(`channel ${channel} does not exist`);
const updated = await postgrest(`facts_release?channel_id=eq.${channels[0].id}&facts_version=eq.${version}`, {
method: "PATCH",
body: { status: "revoked", revoked_at: nowIso(), revoke_reason: reason },
prefer: "return=representation",
});
console.log(JSON.stringify({ revoked: updated?.length ?? 0, channel, facts_version: version }, null, 2));
console.log(JSON.stringify(await revokeRelease(postgrest, { channel, version, reason, at: nowIso() }), null, 2));
return 0;
}
throw new Error(`unknown command ${cmd}`);
+46 -20
View File
@@ -31,9 +31,14 @@
// one release behind a release published under 24h ago: that is the window in
// which release.yml's sync-release-record PR is waiting to merge, and neither a
// PR author nor an unrelated push to main can fix it. Past 24h, or more than one
// release behind, it fails again.
// release behind, it fails again. An unreachable or failing GitHub API fails
// --check too: "could not look" is not "current".
//
// A write validates all three files first and then replaces each one through a
// unique temporary file and a rename, so a bad mirror never leaves the first
// file moved on its own and a crash never leaves a half-written JSON file.
import { readFileSync, writeFileSync } from "node:fs";
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -51,12 +56,18 @@ const headers = {
};
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers });
if (!response.ok) {
console.error(`[sync-latest-release] GitHub returned ${response.status}; leaving the file alone.`);
process.exit(checkOnly ? 0 : 1);
let release;
try {
const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers,
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
release = await response.json();
} catch (error) {
console.error(`[sync-latest-release] could not read the latest release (${error.message}); leaving the files alone.`);
process.exit(1);
}
const release = await response.json();
const tag = String(release.tag_name || "");
const version = tag.startsWith("v") ? tag.slice(1) : "";
@@ -95,7 +106,10 @@ async function isFreshlyOneBehind(recordedTag) {
const age = Date.now() - Date.parse(next.publishedAt);
if (!recordedTag || !(age >= 0 && age < GRACE_MS)) return false;
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=20`, { headers });
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=20`, {
headers,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return false;
const tags = (await res.json())
.filter((r) => !r.draft && !r.prerelease && Number.isFinite(Date.parse(r.published_at)))
@@ -138,26 +152,38 @@ if (checkOnly) {
process.exit(1);
}
writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`);
// Every input must be usable before anything moves: the three files change
// together or not at all.
if (!matrix) {
console.error(`[sync-latest-release] could not read ${mirror}; the mirror is now stale.`);
console.error(`[sync-latest-release] could not read ${mirror}; nothing was written.`);
process.exit(1);
}
// stable.json is the unsigned cloud-facts authoring source; only the two
// release pointers move here. yanked/min_supported/notice stay human calls.
if (!cloud?.release || typeof cloud.release !== "object") {
console.error(`[sync-latest-release] could not read release in ${cloudFacts}; nothing was written.`);
process.exit(1);
}
// Preserve every key the matrix carries beyond the four synced fields (notably
// `sources`), so this stays a fact refresh and not a schema rewrite.
matrix.latestPublishedRelease = { ...currentMirror, ...next };
writeFileSync(mirror, `${JSON.stringify(matrix, null, 2)}\n`);
// stable.json is the unsigned cloud-facts authoring source; only the two
// release pointers move here. yanked/min_supported/notice stay human calls.
if (!cloud?.release) {
console.error(`[sync-latest-release] could not read release in ${cloudFacts}; it is now stale.`);
process.exit(1);
}
cloud.release.latest = next.version;
cloud.release.release_url = next.url;
writeFileSync(cloudFacts, `${JSON.stringify(cloud, null, 2)}\n`);
const staged = [
[target, next],
[mirror, matrix],
[cloudFacts, cloud],
].map(([path, value]) => {
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
return [temporary, path];
});
try {
for (const [temporary, path] of staged) renameSync(temporary, path);
} finally {
for (const [temporary] of staged) rmSync(temporary, { force: true });
}
console.log(`[sync-latest-release] wrote ${next.tag} (${next.publishedAt}) to all three facts`);
+3 -1
View File
@@ -1,7 +1,9 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
// Dictionaries carry per-locale class names (e.g. the Arabic and Hindi
// docs guide's `leading-loose`); unscanned, those utilities never exist.
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/i18n/dictionaries/**/*.ts"],
theme: {
extend: {
colors: {