mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
commit
6352eb2754
79 files changed
+1840
-306
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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")) {
|
||||
// 长轮询超时是正常的,立即重试
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' : ''}.`);
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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_<server>_<tool></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,
|
||||
|
||||
@@ -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 } : {}) });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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=""
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user