web(tideline): address the slice-2 review wave

Eight fixes from the #5743 review threads:

1. Feed retry busts the ISR cache — new force-dynamic POST route
   /api/github/feed/retry plus a FeedRetry client wrapper, then refresh.
   A plain refresh re-served the cached failure.
2. Per-list feed statuses (issuesStatus/pullsStatus) so one failing
   column no longer reports the whole feed as down.
3. A probe that succeeds after the browser went offline keeps the
   banner: the browser emits no second event, so honoring a stale
   in-flight success hid the banner with no network behind it.
4. Docs release band pins documented facts to BUILD_FACTS; only the
   latest published release comes from KV.
5. Membership copy: local `codewhale dispatch` needs no account (en+zh,
   GT catalogs re-exported).
6. Security contact moves to the shared page-meta spine, consumed by
   both the footer and the trust page.
7. 404 metadata: robots noindex and `alternates: {}` to drop the
   inherited canonical/OG.
8. Admin not-configured title renders as h1.

web: 384 passed (384) across 45 files; eslint clean; tsc --noEmit clean.
The connection-state regression test fails without fix 3 (1 failed |
4 passed) and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmeqaZAesoHjT8N9PR7S2c
This commit is contained in:
CodeWhale Bot
2026-09-01 11:32:32 -07:00
parent 53d76e36c4
commit 352079a69b
19 changed files with 185 additions and 60 deletions

View File

@@ -82,6 +82,7 @@ export default async function AdminPage({
<ErrorState
locale={locale}
title={isZh ? "未配置" : "Not configured"}
titleAs="h1"
body={
isZh
? "MAINTAINER_TOKEN 未设置。请在部署前配置此环境变量。"

View File

@@ -4,7 +4,7 @@ import { DocsHelp } from "@/components/docs-help";
import { DocsSidebar } from "@/components/docs-sidebar";
import { ReleaseTruth } from "@/components/release-truth";
import { Whale } from "@/components/whale";
import { getFacts } from "@/lib/facts";
import { BUILD_FACTS, getFactsWithProvenance } from "@/lib/facts";
import { getDocsShell } from "@/lib/i18n/dictionaries";
/* ------------------------------------------------------------------ */
@@ -26,7 +26,15 @@ export default async function DocsLayout({
}) {
const { locale } = await params;
const t = getDocsShell(locale);
const facts = await getFacts();
// These pages describe THIS build: pin the documented facts to the build
// snapshot even when the KV snapshot was written by a newer source (a
// rollback, or any deployment sharing it). Only the latest published
// release is the KV snapshot's to speak for.
const resolution = await getFactsWithProvenance();
const facts = {
...BUILD_FACTS,
latestPublishedRelease: resolution.facts.latestPublishedRelease,
};
return (
<div className="docs-theme docs-portal min-h-screen">

View File

@@ -1,6 +1,6 @@
import { RefRows, withCodeSpans } from "@/components/code-spans";
import { getChrome, getDocsTrust } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
import { buildPageMetadata, SITE_SECURITY_EMAIL } from "@/lib/page-meta";
/** Code-owned literals from docs/SANDBOX.md and docs/TELEMETRY.md. Not copy. */
const SPANS: Record<string, string> = {
@@ -17,8 +17,8 @@ const SPANS: Record<string, string> = {
auditLog: "$CODEWHALE_HOME/audit.log",
};
/** The same security contact the footer publishes (components/footer.tsx). */
const SECURITY_MAILTO = "mailto:hunter@codewhale.net";
/** The one security contact the spine publishes (lib/page-meta.ts). */
const SECURITY_MAILTO = `mailto:${SITE_SECURITY_EMAIL}`;
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { Seal } from "@/components/seal";
import { FeedCard } from "@/components/feed-card";
import { RetryAction } from "@/components/retry-action";
import { FeedRetry } from "@/components/feed-retry";
import { EmptyState, ErrorState, UnavailableState } from "@/components/surface-state";
import { loadFeed, type FeedLoadStatus } from "@/lib/github";
import { getEnv } from "@/lib/kv";
@@ -33,25 +33,30 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
// Four honest answers for an empty column: GitHub answered and had nothing
// (`ok` → empty), GitHub was not asked or refused (`skipped` / `unavailable`
// → not loaded, retry), or the fetch itself threw (`failed` → error, retry).
let status: FeedLoadStatus | "failed" = "ok";
// Each column answers for itself: one refused endpoint must not tell the
// other column that its source did not answer.
let issuesStatus: FeedLoadStatus | "failed" = "ok";
let pullsStatus: FeedLoadStatus | "failed" = "ok";
try {
const load = await loadFeed(env.GITHUB_TOKEN, 50);
feed = load.items;
status = load.status;
issuesStatus = load.issuesStatus;
pullsStatus = load.pullsStatus;
} catch (e) {
status = "failed";
issuesStatus = "failed";
pullsStatus = "failed";
console.error("feed fetch failed", e);
}
const issues = feed.filter((f) => f.kind === "issue");
const pulls = feed.filter((f) => f.kind === "pull");
const eyebrow = isZh ? "动态" : "Activity";
// One shared state per empty column, chosen by what actually happened.
// `RetryAction` without a handler re-runs this server render, which on an
// ISR page picks up the next revalidation — the real fix for "not loaded".
const states = getStates(locale);
const retry = <RetryAction label={states.retry} />;
const feedState =
// FeedRetry first busts this route's ISR entry, because a bare server
// re-render would serve the same cached `skipped`/`unavailable` record for
// up to ten minutes.
const retry = <FeedRetry label={states.retry} />;
const columnState = (status: FeedLoadStatus | "failed") =>
status === "failed" ? (
<ErrorState locale={locale} compact action={retry} />
) : status === "ok" ? (
@@ -90,7 +95,7 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
{pulls.length > 0 ? (
pulls.map((p) => <FeedCard key={p.url} item={p} />)
) : (
<div className="py-4">{feedState}</div>
<div className="py-4">{columnState(pullsStatus)}</div>
)}
</div>
</div>
@@ -106,7 +111,7 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
{issues.length > 0 ? (
issues.map((i) => <FeedCard key={i.url} item={i} />)
) : (
<div className="py-4">{feedState}</div>
<div className="py-4">{columnState(issuesStatus)}</div>
)}
</div>
</div>
@@ -158,7 +163,7 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
{pulls.length > 0 ? (
pulls.map((p) => <FeedCard key={p.url} item={p} />)
) : (
<div className="py-4">{feedState}</div>
<div className="py-4">{columnState(pullsStatus)}</div>
)}
</div>
</div>
@@ -174,7 +179,7 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
{issues.length > 0 ? (
issues.map((i) => <FeedCard key={i.url} item={i} />)
) : (
<div className="py-4">{feedState}</div>
<div className="py-4">{columnState(issuesStatus)}</div>
)}
</div>
</div>

View File

@@ -2,8 +2,15 @@ import type { Metadata } from "next";
import { NotFoundRoute } from "@/components/route-state";
// A not-found boundary receives no params, so the title is locale-neutral;
// without it the 404 would carry the home page's <title>.
export const metadata: Metadata = { title: "Not found · Codewhale" };
// without it the 404 would carry the home page's <title>. The rest of the
// object replaces — not extends — the inherited home metadata: a 404 must
// not advertise the home page as its canonical, hreflang, or share target.
export const metadata: Metadata = {
title: "Not found · Codewhale",
robots: { index: false, follow: true },
alternates: {},
openGraph: { title: "Not found · Codewhale" },
};
/**
* Not-found boundary inside the locale shell, so a `notFound()` thrown by

View File

@@ -0,0 +1,17 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/**
* The uncached half of the feed page's "Try again": the page itself is ISR,
* so its client retry must invalidate the cached entry before refreshing,
* or the click serves the same unavailable record for up to ten minutes.
* Idempotent and side-effect-free beyond the revalidation; no GitHub token
* is spent on behalf of the caller.
*/
export async function POST() {
revalidatePath("/[locale]/feed", "page");
revalidatePath("/feed", "page");
return NextResponse.json({ revalidated: true, at: new Date().toISOString() });
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { RetryAction } from "@/components/retry-action";
/**
* The feed page's retry. `/feed` is ISR (600s), so a bare server re-render
* would serve the same cached `skipped`/`unavailable` record the visitor is
* looking at; POST to the retry endpoint first to bust the cached route,
* then refresh so the server render happens against fresh data.
*/
export function FeedRetry({ label }: { label: string }) {
const router = useRouter();
return (
<RetryAction
label={label}
onRetry={async () => {
try {
await fetch("/api/github/feed/retry", { method: "POST", cache: "no-store" });
} finally {
router.refresh();
}
}}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
REPO_RELEASES_URL,
REPO_URL,
} from "@/lib/i18n/links";
import { SITE_CONTACT_EMAIL, SITE_SECURITY_EMAIL } from "@/lib/page-meta";
import { Whale } from "./whale";
/**
@@ -58,8 +59,8 @@ export function Footer({ locale = "en" }: { locale?: Locale }) {
{GITEE_ENABLED && <a href="https://gitee.com/Hmbown/CodeWhale">Gitee</a>}
<a href="https://cnb.cool/codewhale.net/codewhale">CNB</a>
<a href="https://npmmirror.com/package/codewhale">npmmirror</a>
<a href="mailto:help@codewhale.net">help@codewhale.net</a>
<a href="mailto:hunter@codewhale.net">{chrome.footerSecurity}</a>
<a href={`mailto:${SITE_CONTACT_EMAIL}`}>{SITE_CONTACT_EMAIL}</a>
<a href={`mailto:${SITE_SECURITY_EMAIL}`}>{chrome.footerSecurity}</a>
</div>
<span>© {new Date().getFullYear()} Codewhale</span>
</div>

View File

@@ -598,7 +598,7 @@
]
],
"membershipTitle": "Who can dispatch",
"membershipLead": "Every Codewhale Agent surface authenticates to the same Codewhale membership — the {login} account session. Membership gates cloud agents; provider brands stay internal. Installing and running the local runtime needs no account at all.",
"membershipLead": "Managed Agent surfaces authenticate to the same Codewhale membership — the {login} account session. Membership gates cloud agents, not local dispatch: `codewhale dispatch` with Daytona and forge credentials needs no account. Provider brands stay internal, and installing or running the local runtime needs no account at all.",
"leftoverTitle": "Not built yet",
"leftover": [
[

View File

@@ -598,7 +598,7 @@
]
],
"membershipTitle": "谁可以派发",
"membershipLead": "Codewhale Agent 的每个界面使用同一个 Codewhale 会员身份认证——即 {login} 的账户会话。会员资格决定能否使用云端 Agent提供商品牌保持内部不可见安装和运行本地 Runtime 完全不需要账户。",
"membershipLead": "托管 Agent 界面使用同一个 Codewhale 会员身份认证——即 {login} 的账户会话。会员资格决定云端 Agent,而不是本地派发:只要有 Daytona 与托管平台凭证,`codewhale dispatch` 无需账户即可运行。提供商品牌保持内部不可见安装和运行本地 Runtime 完全不需要账户。",
"leftoverTitle": "尚未实现",
"leftover": [
[

View File

@@ -27,23 +27,40 @@ export const CHANGELOG: ChangelogRelease[] = [
"unreleased": true,
"compareUrl": "https://github.com/Hmbown/CodeWhale/compare/v0.9.11...HEAD",
"sections": [
{
"heading": "Changed",
"items": [
"First run paints the composer immediately: the Welcome/language/provider/ trust gates no longer precede the first keystroke. Missing-key and workspace-trust recovery stay for returning users; language and provider remain in /setup.",
"The canonical whale-tile mark propagates beyond web to the exact-raster surfaces that can carry it (#5738).",
"Dead-code sweep: delete proven-unreferenced helpers (unused builders, wrappers, and leftover identifiers) and drop stale #[allow(dead_code)] where the item is production-called or test-exercised. No runtime behavior change (#5791, #5587).",
"Remote-control recovery gives every fresh pre-dispatch attempt a new lease generation while preserving that generation across its typed start (#5605).",
"Public roster language is Pod. /pod is the customer surface; fleet remains the internal wire, storage, and migration name (#5776).",
"Compaction replacement history keeps a bounded last user round (assistant + tool results) instead of dropping them behind a summary. /context names the compaction path and /anchor survival. Failed compact still does not replace live history (#4394).",
"Provider catalogs: compatible hosts (Baseten, Groq, Cerebras, SenseNova, Command Code) no longer compile a frozen model roster. Descriptors name the wire, URL, and env; live GET /v1/models and a Codewhale-owned catalog layer are the offering list. Command Code is a catalog/descriptor row, not a ProviderKind. Catalog presence is not an availability, entitlement, or provider-acceptance claim (#5783).",
"Provider selection no longer probes or adopts an external CLI credential on ordinary picker use. Reuse requires an explicit \"Use external CLI credentials\" choice, exact-path confirmation, and Codewhale-owned revoke (#5772).",
"TUI: startup no longer presents an approximate ASCII or block-glyph whale as the product mark. It keeps the direct Tideline prompt while exact-raster surfaces remain responsible for the canonical asset.",
"TUI: the active-session composer paints the same three-cell [↑] send target as Startup and clicks it through the existing Enter submit dispatcher (#5771). Compact/quiet composers still omit the control.",
"TUI/CLI: Pod is the public roster surface. User-facing Fleet wording moves to Pod; durable receipt keys stay compatible (#5776)."
],
"itemCount": 11
},
{
"heading": "Added",
"items": [
"TUI: scheduled automations project into the top strip (⏱ N scheduled · M running, compact ⏱ N·M) with typed HistoryCell::Automation receipts when a run this session watched settle. /automation acknowledges failures. The merged footer does not carry the work fact (#5748).",
"The app-server can listen on a unix domain socket and advertise a daemon/attach handshake, so a local client can attach to an already-running engine instead of spawning its own. The socket is created with owner-only permissions and stale sockets are reclaimed on start. Non-unix hosts return a typed unsupported-platform refusal; the Windows named-pipe endpoint is named but not yet implemented (#5749).",
"The engine's internal Op/Event types and the wire protocol's Op/ EventMsg now carry a compile-enforced twin for every variant: adding an engine variant without a protocol counterpart fails the build instead of drifting silently. Internal durability work — no user-visible surface change yet (#5751).",
"Machine tokens: with CODEWHALE_API_KEY set, the CLI authenticates as the Codewhale account with no local session file and no browser — the CI authentication path, with a typed token shape and redaction (#5721).",
"Compaction publishes a structured survival contract for session-tree journal entry types (crates/tui/src/compaction/SURVIVAL_CONTRACT.md) and fails closed when the last user round, tool results, /anchor text, or checkpoint receipt would vanish (#4394).",
"Internal: codewhale-config gains RouteAuthoritySnapshot, one immutable authority that owns a compiled provider catalog together with the route resolver projected from it, so a picker, a readiness view, and an execution path can no longer resolve against different catalog snapshots without a type-level signal. Resolution still goes through the sole resolver; the returned receipt distinguishes an exact catalog row, a custom-endpoint route whose provider facts are deliberately…",
"Computer session records now count only time a provider actually accepted the session as active, at per-second granularity. Idle, queued, stopped, and teardown time are excluded, and a session whose allocation does not match a standard profile is refused rather than recorded approximately. Covered by hermetic fixtures; no live provider call and no deploy (#5781).",
"Website: the public site moves to the Tideline deep-ocean design language (dark by default with an opt-in light documentation sheet, palette grounded in the TUI's WHALE_* tokens) and the new whale brand mark across the favicon, app icons, web manifest, nav wordmark, and social card (#5573).",
"Add codewhale dispatch / /dispatch so a local session can propose a Codewhale cloud agent against an explicit github, cnb, or gitee remote. Confirmation is required; missing credentials fail closed; cloud jobs share the existing /jobs surface as kind=cloud. See DAYTONA_CLOUD_DISPATCH.md.",
"/login reports the Codewhale account session and provider-key next steps. The internal cloud-agent credential is not user surface: there is no auth set-slot/auth clear-slot command, no hint, and no completion entry for it — membership (codewhale login) is the only door.",
"Add the Tideline component family from the ratatui translation spec (#5698's screens, riding the #5699 work-strip layout): hero startup surface with quick actions and option strip, composer restyle with the fluke cap, notifications inbox, merged footer band, pod ledger, receipt stream, theme list with motion toggles, live preview, settings rail, and the left rail — each a standalone render module pinned by 28 new byte-exact golden buffers. Frame wiring follows the Tideline…",
"Route Contract Phase 1: RouteResolver is the runtime path for resolve_runtime_options; codewhale providers export --json ships the owned descriptor catalog; CLI --provider accepts any catalog route id (the closed ProviderArg enum is deleted). Catalog layers are bundled → models.dev → provider /v1/models → config.toml → user, with policy DENY last and never overridden.",
"Add provider-native web search for documented Xiaomi MiMo 2.5 Pro and 2.5 chat routes while keeping neighboring models and custom gateways fail-closed.",
"Add structured provider-native web search for exact Z.AI global and Zhipu China general API routes, using each site's documented search engine value. Existing open.bigmodel.cn configurations now share Z.AI's official model namespace and credential scope; unknown model IDs still pass through.",
"Add provider-native web search for documented Qwen models on ModelStudio Token Plan's Responses Harness, without enabling Coding Plan or Anthropic routes.",
"Added /copy to place the latest completed assistant response on the clipboard without copying tools, system messages, hidden reasoning, or active partial output (#5668).",
"Add provider-native web search for documented DeepSeek V4 routes through the Responses API, with fail-closed capability gating for compatible custom endpoints.",
"Add provider-native search for exact Moonshot K3 Formula, legacy K2.6 built-in search, and Kimi Code membership /search routes. Treat the exact Moonshot China endpoint as a first-party direct route.",
"Z.ai GLM-5.3-Flash and OpenRouter z-ai/glm-5.3-flash are first-class picker rows (/model GLM-5.3-Flash). Flash is the faster/explore sibling of GLM-5.3; the Z.ai default stays GLM-5.3. List price is $0.15/$0.50 per 1M (durable; the 50% promo through 2026-09-09 is not the catalog row)."
"/login reports the Codewhale account session and provider-key next steps. The internal cloud-agent credential is not user surface: there is no auth set-slot/auth clear-slot command, no hint, and no completion entry for it — signing in with codewhale login is the only door.",
"Add the Tideline component family from the ratatui translation spec (#5698's screens, riding the #5699 work-strip layout): hero startup surface with quick actions and option strip, notifications inbox, merged footer band, pod ledger, receipt stream, theme list with motion toggles, live preview, settings rail, and the left rail — each a standalone render module pinned by 28 new byte-exact golden buffers. Frame wiring follows the Tideline acceptance gate; NO_COLOR is now…",
"Route Contract Phase 1: RouteResolver is the runtime path for resolve_runtime_options; codewhale providers export --json ships the owned descriptor catalog; CLI --provider accepts any catalog route id (the closed ProviderArg enum is deleted). Catalog layers are bundled → models.dev → provider /v1/models → config.toml → user, with policy DENY last and never overridden."
],
"itemCount": 28
"itemCount": 35
},
{
"heading": "Changed",
@@ -66,6 +83,10 @@ export const CHANGELOG: ChangelogRelease[] = [
{
"heading": "Fixed",
"items": [
"Fast typing no longer corrupts the composer. The paste-burst heuristic ran on every session until a real bracketed paste arrived, holding, buffering, retro-grabbing, and absorbing Enter on timing guesses; it is now fallback-only (gated off when the terminal provides bracketed paste), the retro-grab is deleted, and Enter on held command text flushes and submits.",
"Ctrl+C works on the pre-session launch menu and speaks everywhere: the first press arms the two-second exit window with a visible localized \"Press Ctrl+C again to quit\" hint (previously silent), the second exits. The worktree name input keeps Ctrl+C as cancel-input.",
"Fresh interactive sessions no longer leave a phantom one-message duplicate behind. The TUI claimed one session id (Runtime store lock, turn-start crash checkpoint) while the engine minted a second one; the first SessionUpdated re-keyed the App, the completion commit cleared only the engine id's checkpoint, and codewhale --continue later \"recovered\" the orphaned checkpoint as a duplicate session instead of the real one. The engine now adopts the host-owned id at spawn…",
"Website: /signin, /signup, and /auth/callback are locale-aware public routes instead of localized 404s. Sign-in and create-account send the person to the CWC app; OAuth callbacks hop to app.codewhale.net with the query intact; /login and /register are aliases. Local CLI use is not presented as requiring an account (#5767).",
"The sandbox read deny-list matches a rule's resolved path as well as its literal spelling. On macOS /etc and /var are symlinks into /private, so a read of /private/etc/sudoers walked around the /etc/sudoers rule, and a rule written against a symlinked directory never fired for the real path that canonicalize and the process cwd hand back.",
"Background shells are first-class work-strip rows (▾ Shells N) you can open, watch, and cancel by the shell_* id on the row. /jobs cancel all cancels running shells; it no longer looks up a task named all. The composer hourglass crumb no longer stands in for a shell surface.",
"codewhale logout and /logout now clear the Codewhale account session and the Daytona secret slot, not only provider API keys. The TUI crate's leftover login --api-key path no longer claims to save a key.",
@@ -73,13 +94,9 @@ export const CHANGELOG: ChangelogRelease[] = [
"Hardened the dispatcher-side config parse the same way: ConfigStore loads and project-config parsing now deserialize ConfigToml on a dedicated 16 MiB-stack thread (the guided-setup save path could overflow a 2 MiB worker stack the same way the TUI's ConfigFile parse did), and the #5585 setup-confirm toast test runs its runtime on an equally sized thread instead of overflowing the default libtest stack.",
"Fixed detached interactive agents reporting worker usage after the parent turn ends with the usage missing from the session/live /cost total (#5597): interactive turns acquire an owner-scoped runtime usage lease, late usage enters the session cost pool without reopening the sealed mailbox, and worker/session/reload accounting share one hashed response identity so retried deliveries stay exactly-once.",
"Fixed the sub-agent fiasco class: in-workspace absolute git -C no longer trips the read-only child shell gate with a coherent bounded gate (#5595), turn end parks turn-owned children resumably instead of silently cancelling them (#5596), stale write-claims are released by liveness with coordinate release (#5562), and the verifier role description matches its real surface (#5562).",
"Fixed workflow responseSchema failure handling (#5583): bounded repair with typed receipts (kind + attempt), raw-output receipts persisted as artifacts that survive reload, and failures surfaced instead of null success (#5528). Degraded owner snapshots no longer project as ordinary Completed runs (#5582), typed task error kinds enable fail-fast parallel/pipeline (R9), and /workflow confirm finds the draft despite interleaved messages.",
"Fixed the DeepSeek session that never auto-compacted at 842k/1M (#5577): the exact billed_842k_on_a_1m_window_compacts_despite_a_small_estimate regression pins the trigger.",
"Fixed sandbox escapes and authority gaps: workspace-write tools resolve hard-linked files before writing (S2, #5569), an opt-in read deny-list bounds full-disk reads in any posture (S1, #5568), and session grants match the command family instead of widening the whole tool (R2).",
"Fixed provider-transport robustness: non-streaming HTTP requests are bounded by a read timeout (R4), and Chat-Completions mid-stream error frames surface instead of hanging (R3). MCP OAuth expiry now reacts to 401/403 with a named recovery path and login hint instead of looking like a broken server (T4, #5572).",
"Fixed Fleet lifecycle gaps: detached workers are reaped when their manager dies (R7) and the per-task wall-clock timeout is enforced (R5); the local Fleet host compiles on non-Unix targets; detached schema repair stays within the runtime budget."
"Fixed workflow responseSchema failure handling (#5583): bounded repair with typed receipts (kind + attempt), raw-output receipts persisted as artifacts that survive reload, and failures surfaced instead of null success (#5528). Degraded owner snapshots no longer project as ordinary Completed runs (#5582), typed task error kinds enable fail-fast parallel/pipeline (R9), and /workflow confirm finds the draft despite interleaved messages."
],
"itemCount": 20
"itemCount": 24
}
]
},

View File

@@ -30,6 +30,13 @@ describe("connection state", () => {
expect(still.status).toBe("offline");
expect(still.attempt).toBe(0);
expect(still.lastCheckedAt).toBe(5);
// The same for a probe that was already in flight when the browser went
// offline and lands successfully afterward: the browser may emit no
// second event, so a late success must not hide the banner.
const staleOk = nextConnectionState(offline, { type: "probe-ok", at: 6 });
expect(staleOk.status).toBe("offline");
expect(staleOk.attempt).toBe(0);
expect(staleOk.lastCheckedAt).toBe(6);
});
it("reconnects through the server, not on the browser's word alone", () => {

View File

@@ -79,6 +79,13 @@ export function nextConnectionState(
};
case "probe-ok": {
// A success that lands after the browser went `offline` belongs to a
// stale probe: the browser emits no second event, so honoring it would
// hide the banner with no network to back it. Record the check, keep
// the banner.
if (state.status === "offline") {
return { ...state, lastCheckedAt: event.at };
}
const wasDown = state.status !== "online";
return {
status: "online",

View File

@@ -141,15 +141,15 @@ export const DOC_TASKS: DocTask[] = [
keywords: { en: "hook lifecycle pre post event", zh: "钩子 生命周期 事件" },
},
{
id: "fleet",
label: { en: "Write a Workflow for a Fleet", zh: "为 Fleet 编写 Workflow" },
id: "pod",
label: { en: "Write a Workflow for a Pod", zh: "为 Pod 编写 Workflow" },
description: {
en: "Durable task execution, roster management, and Workflow authoring.",
zh: "持久任务执行、成员管理和 Workflow 编写。",
},
href: "/docs/fleet",
topicId: "fleet",
keywords: { en: "fleet workflow lane operate durable", zh: "编排 持久 工作流" },
href: "/docs/pod",
topicId: "pod",
keywords: { en: "pod workflow lane operate durable", zh: "编排 持久 工作流" },
},
{
id: "browser-client",

View File

@@ -15,7 +15,11 @@ describe("production-build fallback", () => {
expect(await fetchFeed(undefined, 10)).toEqual([]);
// A page that is the feed needs to know nothing was asked for, so
// the prerender does not pass as an honest empty record.
expect(await loadFeed(undefined, 10)).toEqual({ items: [], status: "skipped" });
expect(await loadFeed(undefined, 10)).toEqual({
items: [],
issuesStatus: "skipped",
pullsStatus: "skipped",
});
expect(await fetchRepoStats()).toMatchObject({
stars: 0,
forks: 0,
@@ -337,7 +341,11 @@ describe("fetchFeed", () => {
"fetch",
vi.fn(async () => new Response("rate limited", { status: 403 })),
);
expect(await loadFeed(undefined, 10)).toEqual({ items: [], status: "unavailable" });
expect(await loadFeed(undefined, 10)).toEqual({
items: [],
issuesStatus: "unavailable",
pullsStatus: "unavailable",
});
});
it("keeps what arrived and still flags the load when one list call fails", async () => {
@@ -350,13 +358,20 @@ describe("fetchFeed", () => {
}),
);
const load = await loadFeed(undefined, 10);
expect(load.status).toBe("unavailable");
// Each column answers for itself: the issues list answered, so the
// issues column must not be told "the source did not answer".
expect(load.issuesStatus).toBe("ok");
expect(load.pullsStatus).toBe("unavailable");
expect(load.items.map((i) => i.number)).toEqual([4901, 4880]);
});
it("reports ok — so an empty list means empty — when every list call answered", async () => {
vi.stubGlobal("fetch", vi.fn(async () => json([])));
expect(await loadFeed(undefined, 10)).toEqual({ items: [], status: "ok" });
expect(await loadFeed(undefined, 10)).toEqual({
items: [],
issuesStatus: "ok",
pullsStatus: "ok",
});
});
});

View File

@@ -186,20 +186,25 @@ export async function fetchFeed(token?: string, limit = 30): Promise<FeedItem[]>
* asked" or "GitHub refused" must render differently, or a rate limit and a
* build-time prerender both masquerade as an honest empty record.
*
* - `ok` — every list endpoint answered; an empty list is real.
* - `ok` — that list endpoint answered; an empty list is real.
* - `skipped` — static generation; nothing was fetched.
* - `unavailable` — an issues/pulls call came back non-ok (rate limit,
* outage); `items` holds whatever did arrive.
* - `unavailable` — that call came back non-ok (rate limit, outage);
* `items` holds whatever did arrive.
*
* Availability is tracked per list: when exactly one endpoint refuses, the
* other column must not be told that "the source did not answer".
*/
export type FeedLoadStatus = "ok" | "skipped" | "unavailable";
export interface FeedLoad {
items: FeedItem[];
status: FeedLoadStatus;
issuesStatus: FeedLoadStatus;
pullsStatus: FeedLoadStatus;
}
export async function loadFeed(token?: string, limit = 30): Promise<FeedLoad> {
if (isProductionBuild()) return { items: [], status: "skipped" };
if (isProductionBuild())
return { items: [], issuesStatus: "skipped", pullsStatus: "skipped" };
const [issuesRes, pullsRes, releasesRes] = await Promise.all([
fetch(
@@ -216,8 +221,10 @@ export async function loadFeed(token?: string, limit = 30): Promise<FeedLoad> {
}),
]);
// Releases are a garnish on the feed; the two list calls are the record.
const status: FeedLoadStatus = issuesRes.ok && pullsRes.ok ? "ok" : "unavailable";
// Releases are a garnish on the feed; the two list calls are the record,
// and each answers for itself.
const issuesStatus: FeedLoadStatus = issuesRes.ok ? "ok" : "unavailable";
const pullsStatus: FeedLoadStatus = pullsRes.ok ? "ok" : "unavailable";
const issues = await responseArray<RawIssue>(issuesRes);
const pulls = await responseArray<RawIssue & { merged_at?: string | null }>(pullsRes);
const releases = await responseArray<RawRelease>(releasesRes);
@@ -315,7 +322,7 @@ export async function loadFeed(token?: string, limit = 30): Promise<FeedLoad> {
kept[kept.length - 1] = newestRelease;
}
return { items: kept, status };
return { items: kept, issuesStatus, pullsStatus };
}
async function responseArray<T>(res: Response): Promise<T[]> {

View File

@@ -55,7 +55,7 @@ export const docsComputers: DocsComputersDict = {
],
membershipTitle: "Who can dispatch",
membershipLead:
"Every Codewhale Agent surface authenticates to the same Codewhale membership — the {login} account session. Membership gates cloud agents; provider brands stay internal. Installing and running the local runtime needs no account at all.",
"Managed Agent surfaces authenticate to the same Codewhale membership — the {login} account session. Membership gates cloud agents, not local dispatch: `codewhale dispatch` with Daytona and forge credentials needs no account. Provider brands stay internal, and installing or running the local runtime needs no account at all.",
leftoverTitle: "Not built yet",
leftover: [
["Live watch", "A log tail of a running sandbox."],

View File

@@ -41,7 +41,7 @@ export const docsComputers: DocsComputersDict = {
],
membershipTitle: "谁可以派发",
membershipLead:
"Codewhale Agent 的每个界面使用同一个 Codewhale 会员身份认证——即 {login} 的账户会话。会员资格决定能否使用云端 Agent提供商品牌保持内部不可见安装和运行本地 Runtime 完全不需要账户。",
"托管 Agent 界面使用同一个 Codewhale 会员身份认证——即 {login} 的账户会话。会员资格决定云端 Agent,而不是本地派发:只要有 Daytona 与托管平台凭证,`codewhale dispatch` 无需账户即可运行。提供商品牌保持内部不可见安装和运行本地 Runtime 完全不需要账户。",
leftoverTitle: "尚未实现",
leftover: [
["实时查看", "正在运行的沙箱的日志跟随。"],

View File

@@ -6,6 +6,13 @@ export const SITE_URL = "https://codewhale.net";
export const SITE_NAME = "Codewhale";
/**
* The project's public mailboxes, in one copy: the footer renders them and
* the trust page links the security one, so the two surfaces cannot drift.
*/
export const SITE_CONTACT_EMAIL = "help@codewhale.net";
export const SITE_SECURITY_EMAIL = "hunter@codewhale.net";
/** The one-line product identity, used as the default OG image alt text. */
export const IDENTITY_PHRASE = "Codewhale dives into the deep so you don't have to.";