From 24d5d8cf09039a43c1bcad6c6fa2a0214fec5ba7 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sat, 20 Jun 2026 22:21:17 +0200 Subject: [PATCH 1/5] feat(coder): Gemini as bridge-default provider; consistent qwen fallback Generalize the env-fallback (company-free) coder path so the bridge- default provider is configurable and prefers Gemini when a Google key is present, falling back to OpenRouter (qwen free) / Anthropic / OpenAI. Pin explicitly with MIND_DEFAULT_PROVIDER. Per-provider default model (google -> gemini-2.0-flash, openrouter -> qwen/qwen3-coder:free). BYOK (user-pref) stays the optional override and runs unmetered; bridge per-run MIND metering is unchanged. Also align env.ts MIND_AGENT_MODEL default to qwen/qwen3-coder:free (matched store.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/ai-providers/store.ts | 96 ++++++++++++++++++++++++++++------- src/lib/env.ts | 6 ++- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/lib/ai-providers/store.ts b/src/lib/ai-providers/store.ts index 995049e..6540368 100644 --- a/src/lib/ai-providers/store.ts +++ b/src/lib/ai-providers/store.ts @@ -193,15 +193,76 @@ export type CoderConfig = { apiKey: string; }; +/** + * Per-provider default model for the bridge-default (company-free) path, + * used when `MIND_AGENT_MODEL` is unset. Gemini's free tier is the house + * default; OpenRouter stays on the free coder-tuned Qwen. + */ +const ENV_FALLBACK_DEFAULT_MODEL: Record = { + google: "gemini-2.0-flash", + openrouter: "qwen/qwen3-coder:free", + anthropic: "claude-haiku-4-5", + openai: "gpt-4o-mini", +}; + +/** Provider preference order for the env-fallback when none is pinned. */ +const ENV_FALLBACK_ORDER: ProviderName[] = [ + "google", + "openrouter", + "anthropic", + "openai", +]; + +/** First non-empty container env var holding `provider`'s key, or null. */ +function envKeyForProvider(provider: ProviderName): string | null { + const spec = getProvider(provider); + if (!spec) return null; + for (const name of spec.containerEnvNames) { + const v = process.env[name]?.trim(); + if (v) return v; + } + return null; +} + +/** + * The bridge-default ("env-fallback") provider — the company-free path + * shared by everyone who hasn't brought their own key. Prefers Gemini + * (its key configured as a Secret) so Builder's free runs use the same + * metered Gemini path as Slides; falls back to whichever provider key is + * present. Pin explicitly with `MIND_DEFAULT_PROVIDER`. The single + * `MIND_AGENT_MODEL` override should match the chosen provider; unset = + * the per-provider default above. + */ +function resolveEnvFallback(): { + provider: ProviderName; + model: string; + apiKey: string; +} | null { + const pinned = process.env.MIND_DEFAULT_PROVIDER?.trim(); + const order = isProviderName(pinned) ? [pinned] : ENV_FALLBACK_ORDER; + for (const provider of order) { + const apiKey = envKeyForProvider(provider); + if (apiKey) { + const model = + process.env.MIND_AGENT_MODEL?.trim() || + ENV_FALLBACK_DEFAULT_MODEL[provider]; + return { provider, model, apiKey }; + } + } + return null; +} + /** * Resolve which (provider, model, apiKey) the coder should use when * acting on behalf of `webId`. * * Priority: - * 1. The user's pref + their stored key for that provider. - * 2. The bridge-wide OPENROUTER_API_KEY + MIND_AGENT_MODEL fallback - * (preserves the demo behavior — anyone can push a repo and have - * the agents work without configuring anything personally). + * 1. The user's pref + their stored key for that provider (BYOK — the + * optional in-app custom provider; runs unmetered). + * 2. The bridge-default provider (`resolveEnvFallback` — Gemini by + * default), so anyone can push a repo and have the agents work + * without configuring anything; metered against the free MIND + * allotment. * 3. null — caller surfaces the configuration error to the user. */ export function resolveCoderConfig(webId: string): CoderConfig | null { @@ -218,18 +279,13 @@ export function resolveCoderConfig(webId: string): CoderConfig | null { } } - // Fallback: env-configured OpenRouter setup. The MIND_AGENT_MODEL var - // includes a provider slash (e.g. "qwen/qwen3-coder:free") and is - // consumed as the bare model id by OpenRouter, so we forward it - // as-is. Default is a free model so a deployment with no per-user - // BYOK keys still completes a run end-to-end. - const envKey = process.env.OPENROUTER_API_KEY; - if (envKey) { + const fb = resolveEnvFallback(); + if (fb) { return { source: "env-fallback", - provider: "openrouter", - model: process.env.MIND_AGENT_MODEL ?? "qwen/qwen3-coder:free", - apiKey: envKey, + provider: fb.provider, + model: fb.model, + apiKey: fb.apiKey, }; } return null; @@ -246,7 +302,7 @@ export type CoderConfigSummary = } | { source: "env-fallback"; - provider: "openrouter"; + provider: ProviderName; providerLabel: string; model: string; } @@ -266,12 +322,14 @@ export function resolveCoderConfigSummary(webId: string): CoderConfigSummary { }; } } - if (process.env.OPENROUTER_API_KEY) { + const fb = resolveEnvFallback(); + if (fb) { + const spec = getProvider(fb.provider); return { source: "env-fallback", - provider: "openrouter", - providerLabel: "OpenRouter (bridge-default)", - model: process.env.MIND_AGENT_MODEL ?? "qwen/qwen3-coder:free", + provider: fb.provider, + providerLabel: `${spec?.label ?? fb.provider} (bridge-default)`, + model: fb.model, }; } return { source: "none" }; diff --git a/src/lib/env.ts b/src/lib/env.ts index 42cdb82..f24a1d8 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -164,8 +164,10 @@ export function getEnv(): BridgeEnv { const allowSeededFallback = process.env.ALLOW_SEEDED_FALLBACK === "1"; const openrouterApiKey = process.env.OPENROUTER_API_KEY?.trim() || null; - const agentModel = process.env.MIND_AGENT_MODEL?.trim() || "anthropic/claude-3.5-sonnet"; - const coderImage = process.env.MIND_CODER_IMAGE?.trim() || "mind-codespaces/coder:latest"; + const agentModel = + process.env.MIND_AGENT_MODEL?.trim() || "qwen/qwen3-coder:free"; + const coderImage = + process.env.MIND_CODER_IMAGE?.trim() || "mind-codespaces/coder:latest"; const coderTimeoutMs = Number(process.env.MIND_CODER_TIMEOUT ?? 600) * 1000; const coderWorkroot = process.env.MIND_CODER_WORKROOT ?? null; const runnerRaw = (process.env.MIND_RUNNER ?? "auto").toLowerCase(); From ed1e955cf74674992e54c9f323ce84880de1ccb8 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sun, 21 Jun 2026 00:21:40 +0200 Subject: [PATCH 2/5] fix(workflows): glibc runner image so lightningcss/Tailwind v4 builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow runner was hardcoded to node:22-alpine (musl). Vite/Tailwind v4 pull in lightningcss, whose musl prebuilt binary npm skips on a fresh install (optional-deps bug) — every `vite build` died with `Cannot find module lightningcss.linux-x64-musl.node` (exit 1). Default to node:22-bookworm-slim (glibc) where the gnu binary resolves cleanly; reproduced the scaffold build green on glibc. Make it overridable via MIND_WORKFLOW_IMAGE. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/workflows/docker.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/workflows/docker.ts b/src/lib/workflows/docker.ts index efaefb7..3de408f 100644 --- a/src/lib/workflows/docker.ts +++ b/src/lib/workflows/docker.ts @@ -22,7 +22,14 @@ import { randomBytes } from "node:crypto"; * MIND_RUNNER env var. */ -const DEFAULT_IMAGE = "node:22-alpine"; +// Runner image for `npm install`/`vite build`. Defaults to a glibc image +// (Debian bookworm), NOT alpine/musl: native build deps like lightningcss +// (pulled in by Vite/Tailwind v4) only reliably resolve their prebuilt +// binary on glibc — on musl, npm's optional-deps-with-lockfile bug skips +// `lightningcss.linux-x64-musl.node` and the build dies. Override with +// MIND_WORKFLOW_IMAGE if a project needs a different toolchain. +const DEFAULT_IMAGE = + process.env.MIND_WORKFLOW_IMAGE?.trim() || "node:22-bookworm-slim"; const DOCKER_PROBE_TIMEOUT_MS = 3000; // Network isolation for the workflow container (§3.4): From 6e8ed5f16ec5742e9fa842d52d26b237ee73860d Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sun, 21 Jun 2026 00:36:16 +0200 Subject: [PATCH 3/5] fix(oidc): coalesce concurrent refreshes so single-use token isn't burned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the in-process session cache is cold (after a restart) and several publish-path callers (post_receive + reconciler + tracker + publisher) hit loadAuthedFetchForWebId for the same WebID at once, each spent the SAME stored single-use refresh token against CSS. The first rotated it; the rest got `invalid_grant (unknown refresh_token)`, and the clobbered rotation left the stored token permanently dead — forcing a /connect re-auth (observed in prod: a refresh storm of 5+ failed refreshes on one dead session). Collapse concurrent refreshes per WebID onto one in-flight promise: the token is spent exactly once and every caller shares the resulting live session. Complements the MC-176 cache (which only helps once a session is warm). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/solid/oidc-server.ts | 179 ++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 77 deletions(-) diff --git a/src/lib/solid/oidc-server.ts b/src/lib/solid/oidc-server.ts index 265e1f7..d93ed68 100644 --- a/src/lib/solid/oidc-server.ts +++ b/src/lib/solid/oidc-server.ts @@ -48,6 +48,17 @@ type CachedSession = { }; const sessionCache = new Map(); +// In-flight refresh coalescing, keyed by WebID. CSS issues *single-use* +// refresh tokens: if two callers (post_receive + reconciler + tracker + +// publisher all fire near-simultaneously) hit a cold cache at once, each +// spends the SAME stored refresh token against the IdP. The first rotates it; +// the rest present an already-consumed token and get `invalid_grant +// (unknown refresh_token)` — and the clobbered rotation can leave the stored +// token permanently dead, forcing a /connect re-auth. Collapsing concurrent +// refreshes for a WebID onto one promise means the token is spent exactly +// once and every caller shares the resulting live session. +const inFlightRefresh = new Map>(); + // Re-derive the access token this long before it actually expires, so an // in-flight request never rides a token that lapses mid-publish. const TOKEN_EXPIRY_SKEW_MS = 60_000; @@ -194,89 +205,103 @@ export async function loadAuthedFetchForWebId(webId: string): Promise> | undefined; - try { - session = await getSessionFromStorage(identity.sessionId, { - storage, - refreshSession: true, - onNewRefreshToken: (newToken: string) => { - // First+last 4 chars only — proves a new value was observed - // without exposing the secret in logs. - const fp = `${newToken.slice(0, 4)}…${newToken.slice(-4)}`; - rotations.push(fp); - log.info("oidc.refresh.rotated", { - webId: scrubWebId(webId), - sessionId: identity.sessionId.slice(0, 8), - tokenLen: newToken.length, - tokenFingerprint: fp, - }); - }, - }); - } catch (e) { - log.warn("oidc.refresh.failed", { - webId: scrubWebId(webId), - sessionId: identity.sessionId.slice(0, 8), - threw: true, - error: (e as Error).message ?? String(e), - rotationsDuringCall: rotations.length, + const refreshPromise = (async (): Promise => { + const storage = makeIdentityStorage(identity.sessionId); + + // `rotations` records every `newRefreshToken` event the SDK emits. + // The SDK's persistence path is to immediately call setForUser on + // our storage adapter — but we capture the event independently so + // a structured log line exists even if the storage write was + // somehow skipped or clobbered. On refresh failure the count is + // logged so we can tell "refresh ran and rotated tokens N times + // before giving up" from "refresh refused immediately". + const rotations: string[] = []; + // A dead dynamic-client registration (e.g. the issuer's `.css-data` was + // wiped in dev) makes the SDK *throw* during refresh — `invalid_client`, + // a network error, etc. — rather than return a non-logged-in session. We + // must funnel that throw into the same `OidcRefreshFailedError` as the + // returns-not-logged-in case; otherwise the raw error escapes past + // `getOwnerFetch`'s `OidcRefreshFailedError` catch and every caller turns a + // stale identity into an opaque 500 instead of a clear "re-connect via + // /connect" 503. (MC-173.) + let session: Awaited> | undefined; + try { + session = await getSessionFromStorage(identity.sessionId, { + storage, + refreshSession: true, + onNewRefreshToken: (newToken: string) => { + // First+last 4 chars only — proves a new value was observed + // without exposing the secret in logs. + const fp = `${newToken.slice(0, 4)}…${newToken.slice(-4)}`; + rotations.push(fp); + log.info("oidc.refresh.rotated", { + webId: scrubWebId(webId), + sessionId: identity.sessionId.slice(0, 8), + tokenLen: newToken.length, + tokenFingerprint: fp, + }); + }, + }); + } catch (e) { + log.warn("oidc.refresh.failed", { + webId: scrubWebId(webId), + sessionId: identity.sessionId.slice(0, 8), + threw: true, + error: (e as Error).message ?? String(e), + rotationsDuringCall: rotations.length, + }); + throw new OidcRefreshFailedError(webId); + } + + if (!session || !session.info.isLoggedIn) { + log.warn("oidc.refresh.failed", { + webId: scrubWebId(webId), + sessionId: identity.sessionId.slice(0, 8), + hasSession: !!session, + isLoggedIn: session?.info.isLoggedIn ?? false, + rotationsDuringCall: rotations.length, + }); + throw new OidcRefreshFailedError(webId); + } + const authedFetch = session.fetch.bind(session) as typeof fetch; + + // Cache the live session's fetch until just before the access token expires. + // `expirationDate` is ms-epoch when present; if the SDK didn't surface it (or + // it's not a sane future timestamp) we cache for one skew window only — still + // enough to collapse a burst of publishes into a single refresh without + // riding an unknown-lifetime token for long. + const exp = (session.info as { expirationDate?: number }).expirationDate; + const expiresAt = + typeof exp === "number" && exp > Date.now() + ? exp + : // No expiry surfaced — cache for one skew window so a burst of publishes + // still collapses to a single refresh, without riding an unknown-lifetime + // token for long. (CSS access tokens live for minutes, so 60s is safe.) + Date.now() + 2 * TOKEN_EXPIRY_SKEW_MS; + sessionCache.set(webId, { + sessionId: identity.sessionId, + fetch: authedFetch, + expiresAt, }); - throw new OidcRefreshFailedError(webId); - } - if (!session || !session.info.isLoggedIn) { - log.warn("oidc.refresh.failed", { + log.info("oidc.refresh.ok", { webId: scrubWebId(webId), sessionId: identity.sessionId.slice(0, 8), - hasSession: !!session, - isLoggedIn: session?.info.isLoggedIn ?? false, rotationsDuringCall: rotations.length, + cachedUntil: expiresAt, }); - throw new OidcRefreshFailedError(webId); - } - const authedFetch = session.fetch.bind(session) as typeof fetch; + return authedFetch; + })(); - // Cache the live session's fetch until just before the access token expires. - // `expirationDate` is ms-epoch when present; if the SDK didn't surface it (or - // it's not a sane future timestamp) we cache for one skew window only — still - // enough to collapse a burst of publishes into a single refresh without - // riding an unknown-lifetime token for long. - const exp = (session.info as { expirationDate?: number }).expirationDate; - const expiresAt = - typeof exp === "number" && exp > Date.now() - ? exp - : // No expiry surfaced — cache for one skew window so a burst of publishes - // still collapses to a single refresh, without riding an unknown-lifetime - // token for long. (CSS access tokens live for minutes, so 60s is safe.) - Date.now() + 2 * TOKEN_EXPIRY_SKEW_MS; - sessionCache.set(webId, { - sessionId: identity.sessionId, - fetch: authedFetch, - expiresAt, - }); - - log.info("oidc.refresh.ok", { - webId: scrubWebId(webId), - sessionId: identity.sessionId.slice(0, 8), - rotationsDuringCall: rotations.length, - cachedUntil: expiresAt, - }); - return authedFetch; + inFlightRefresh.set(webId, refreshPromise); + try { + return await refreshPromise; + } finally { + inFlightRefresh.delete(webId); + } } From 5bccc5b143d13dd53a0b23b6be3e77c00d7d16ca Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sun, 21 Jun 2026 01:16:45 +0200 Subject: [PATCH 4/5] fix(pages): prune by container-relative slug so absolute ldp:contains doesn't wipe the publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The after-upload prune compared each pod child against `kept` using the raw `ldp:contains` object. CSS can emit that object as an absolute URL, so `childRel` became a full URL that never matched the relative keys in `kept` — the prune then DELETEd every file it had just uploaded and the published site served 404s despite `publisher.done uploaded:N`. Resolve each child to absolute, re-derive the immediate segment relative to the container, and compare that. Works for both relative-slug and absolute-URL servers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/pages/publisher.ts | 43 ++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/lib/pages/publisher.ts b/src/lib/pages/publisher.ts index 97f4afc..15addb4 100644 --- a/src/lib/pages/publisher.ts +++ b/src/lib/pages/publisher.ts @@ -71,7 +71,8 @@ export async function publishPages(repoId: number): Promise<{ if (!repo) throw new Error(`repo id=${repoId} not found`); const pages = getPagesConfig(repoId); if (!pages) throw new Error(`pages config for repo id=${repoId} not found`); - if (!pages.enabled) throw new Error(`pages not enabled for repo id=${repoId}`); + if (!pages.enabled) + throw new Error(`pages not enabled for repo id=${repoId}`); if (!pages.targetContainer) throw new Error(`pages.targetContainer is empty for repo id=${repoId}`); @@ -125,7 +126,8 @@ export async function publishDirectory(input: { target: string; }> { const { repo, pages, sourceDir } = input; - if (!pages.enabled) throw new Error(`pages not enabled for repo id=${repo.id}`); + if (!pages.enabled) + throw new Error(`pages not enabled for repo id=${repo.id}`); if (!pages.targetContainer) throw new Error(`pages.targetContainer is empty for repo id=${repo.id}`); @@ -147,7 +149,8 @@ export async function publishDirectory(input: { authed = await getOwnerFetch(repo.ownerWebId); } catch (e) { if (e instanceof OwnerFetchUnavailableError) { - const status = e.reason === "needs-reauthorization" ? "needs-reauth" : "failed"; + const status = + e.reason === "needs-reauthorization" ? "needs-reauth" : "failed"; markPagesFailed(repo.id, status, e.message); Metrics.publishFailed(repo.owner, repo.name, status); } else { @@ -231,14 +234,26 @@ async function pruneStale( relPrefix: string, kept: Set, ): Promise { - // CSS emits the `ldp:contains` objects as relative URIs (e.g. ``), - // not absolute URLs. Resolve them against the container URL before doing - // anything URL-based. - const childSlugs = await listContainerChildren(fetcher, containerUrl); + const childRefs = await listContainerChildren(fetcher, containerUrl); + const containerBase = containerUrl.endsWith("/") + ? containerUrl + : containerUrl + "/"; let pruned = 0; - for (const slug of childSlugs) { - if (!slug || slug === "./" || slug === ".") continue; - const childAbsUrl = new URL(slug, containerUrl).toString(); + for (const ref of childRefs) { + if (!ref || ref === "./" || ref === ".") continue; + // `ldp:contains` objects come back as relative slugs (``) on + // some Solid servers and as absolute URLs (``) + // on others. Resolve to absolute, then re-derive the immediate child + // segment RELATIVE to this container so `childRel` lines up with the + // relative keys in `kept`. (This previously used the raw object as the + // slug; against an absolute-URL server `childRel` became a full URL that + // never matched `kept`, so the prune deleted every file it had just + // uploaded and the published site came back empty.) + const childAbsUrl = new URL(ref, containerBase).toString(); + if (childAbsUrl === containerBase) continue; // self-reference + if (!childAbsUrl.startsWith(containerBase)) continue; // outside the tree + const slug = childAbsUrl.slice(containerBase.length); + if (!slug) continue; const childRel = relPrefix + slug; if (slug.endsWith("/")) { @@ -303,7 +318,13 @@ function parseLdpContains(body: string): string[] { cursor = start + KEY.length; while (cursor < body.length) { const ch = body[cursor]; - if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n" || ch === ",") { + if ( + ch === " " || + ch === "\t" || + ch === "\r" || + ch === "\n" || + ch === "," + ) { cursor += 1; continue; } From 78e61ef83c346491946091624cabe6d2cc38ea78 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sun, 21 Jun 2026 01:48:57 +0200 Subject: [PATCH 5/5] chore(ci): satisfy biome formatter + drop non-null assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biome ci gate (#20) checks formatting too. My hand-edits to the four publish-pipeline files differed from biome's 100-col formatter, and the tracker-pod test used forbidden non-null assertions — both fail `biome ci .`. Reformat the four files and switch the test asserts to optional chaining / guarded gets. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/ai-providers/store.ts | 11 ++--------- src/lib/env.ts | 6 ++---- src/lib/pages/publisher.ts | 21 +++++---------------- src/lib/workflows/docker.ts | 3 +-- tests/tracker-pod.test.ts | 12 ++++++------ 5 files changed, 16 insertions(+), 37 deletions(-) diff --git a/src/lib/ai-providers/store.ts b/src/lib/ai-providers/store.ts index 6540368..a8df373 100644 --- a/src/lib/ai-providers/store.ts +++ b/src/lib/ai-providers/store.ts @@ -206,12 +206,7 @@ const ENV_FALLBACK_DEFAULT_MODEL: Record = { }; /** Provider preference order for the env-fallback when none is pinned. */ -const ENV_FALLBACK_ORDER: ProviderName[] = [ - "google", - "openrouter", - "anthropic", - "openai", -]; +const ENV_FALLBACK_ORDER: ProviderName[] = ["google", "openrouter", "anthropic", "openai"]; /** First non-empty container env var holding `provider`'s key, or null. */ function envKeyForProvider(provider: ProviderName): string | null { @@ -243,9 +238,7 @@ function resolveEnvFallback(): { for (const provider of order) { const apiKey = envKeyForProvider(provider); if (apiKey) { - const model = - process.env.MIND_AGENT_MODEL?.trim() || - ENV_FALLBACK_DEFAULT_MODEL[provider]; + const model = process.env.MIND_AGENT_MODEL?.trim() || ENV_FALLBACK_DEFAULT_MODEL[provider]; return { provider, model, apiKey }; } } diff --git a/src/lib/env.ts b/src/lib/env.ts index f24a1d8..6eec0b2 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -164,10 +164,8 @@ export function getEnv(): BridgeEnv { const allowSeededFallback = process.env.ALLOW_SEEDED_FALLBACK === "1"; const openrouterApiKey = process.env.OPENROUTER_API_KEY?.trim() || null; - const agentModel = - process.env.MIND_AGENT_MODEL?.trim() || "qwen/qwen3-coder:free"; - const coderImage = - process.env.MIND_CODER_IMAGE?.trim() || "mind-codespaces/coder:latest"; + const agentModel = process.env.MIND_AGENT_MODEL?.trim() || "qwen/qwen3-coder:free"; + const coderImage = process.env.MIND_CODER_IMAGE?.trim() || "mind-codespaces/coder:latest"; const coderTimeoutMs = Number(process.env.MIND_CODER_TIMEOUT ?? 600) * 1000; const coderWorkroot = process.env.MIND_CODER_WORKROOT ?? null; const runnerRaw = (process.env.MIND_RUNNER ?? "auto").toLowerCase(); diff --git a/src/lib/pages/publisher.ts b/src/lib/pages/publisher.ts index 15addb4..7280747 100644 --- a/src/lib/pages/publisher.ts +++ b/src/lib/pages/publisher.ts @@ -71,8 +71,7 @@ export async function publishPages(repoId: number): Promise<{ if (!repo) throw new Error(`repo id=${repoId} not found`); const pages = getPagesConfig(repoId); if (!pages) throw new Error(`pages config for repo id=${repoId} not found`); - if (!pages.enabled) - throw new Error(`pages not enabled for repo id=${repoId}`); + if (!pages.enabled) throw new Error(`pages not enabled for repo id=${repoId}`); if (!pages.targetContainer) throw new Error(`pages.targetContainer is empty for repo id=${repoId}`); @@ -126,8 +125,7 @@ export async function publishDirectory(input: { target: string; }> { const { repo, pages, sourceDir } = input; - if (!pages.enabled) - throw new Error(`pages not enabled for repo id=${repo.id}`); + if (!pages.enabled) throw new Error(`pages not enabled for repo id=${repo.id}`); if (!pages.targetContainer) throw new Error(`pages.targetContainer is empty for repo id=${repo.id}`); @@ -149,8 +147,7 @@ export async function publishDirectory(input: { authed = await getOwnerFetch(repo.ownerWebId); } catch (e) { if (e instanceof OwnerFetchUnavailableError) { - const status = - e.reason === "needs-reauthorization" ? "needs-reauth" : "failed"; + const status = e.reason === "needs-reauthorization" ? "needs-reauth" : "failed"; markPagesFailed(repo.id, status, e.message); Metrics.publishFailed(repo.owner, repo.name, status); } else { @@ -235,9 +232,7 @@ async function pruneStale( kept: Set, ): Promise { const childRefs = await listContainerChildren(fetcher, containerUrl); - const containerBase = containerUrl.endsWith("/") - ? containerUrl - : containerUrl + "/"; + const containerBase = containerUrl.endsWith("/") ? containerUrl : containerUrl + "/"; let pruned = 0; for (const ref of childRefs) { if (!ref || ref === "./" || ref === ".") continue; @@ -318,13 +313,7 @@ function parseLdpContains(body: string): string[] { cursor = start + KEY.length; while (cursor < body.length) { const ch = body[cursor]; - if ( - ch === " " || - ch === "\t" || - ch === "\r" || - ch === "\n" || - ch === "," - ) { + if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n" || ch === ",") { cursor += 1; continue; } diff --git a/src/lib/workflows/docker.ts b/src/lib/workflows/docker.ts index 3de408f..f03ad0c 100644 --- a/src/lib/workflows/docker.ts +++ b/src/lib/workflows/docker.ts @@ -28,8 +28,7 @@ import { randomBytes } from "node:crypto"; // binary on glibc — on musl, npm's optional-deps-with-lockfile bug skips // `lightningcss.linux-x64-musl.node` and the build dies. Override with // MIND_WORKFLOW_IMAGE if a project needs a different toolchain. -const DEFAULT_IMAGE = - process.env.MIND_WORKFLOW_IMAGE?.trim() || "node:22-bookworm-slim"; +const DEFAULT_IMAGE = process.env.MIND_WORKFLOW_IMAGE?.trim() || "node:22-bookworm-slim"; const DOCKER_PROBE_TIMEOUT_MS = 3000; // Network isolation for the workflow container (§3.4): diff --git a/tests/tracker-pod.test.ts b/tests/tracker-pod.test.ts index 015734e..7ad2934 100644 --- a/tests/tracker-pod.test.ts +++ b/tests/tracker-pod.test.ts @@ -97,7 +97,7 @@ describe("tracker → pod mirror (MC-160)", () => { expect(pod.store.has(`${CONTAINER}state.ttl`)).toBe(true); // Public-read ACL: owner R/W/Control, foaf:Agent Read. - const acl = pod.store.get(`${CONTAINER}.acl`)!; + const acl = pod.store.get(`${CONTAINER}.acl`); expect(acl).toContain("acl:Read, acl:Write, acl:Control"); expect(acl).toContain("foaf:Agent"); @@ -112,7 +112,7 @@ describe("tracker → pod mirror (MC-160)", () => { it("the published tracker.ttl carries the flow:Tracker shape (mind-issues can render it)", async () => { const { publishTrackerToPod } = await import("@/lib/solid/tracker-pod"); await publishTrackerToPod(repo, OUTPUTS); - const trackerDoc = pod.store.get(`${CONTAINER}tracker.ttl`)!; + const trackerDoc = pod.store.get(`${CONTAINER}tracker.ttl`); // C6 regression guard: the same URL must be a conformant flow:Tracker with a // flow:stateStore pointer — what mind-issues / the SolidOS issue-pane read. expect(trackerDoc).toContain("a flow:Tracker"); @@ -125,12 +125,12 @@ describe("tracker → pod mirror (MC-160)", () => { const tracker = await readPodTracker(repo, "alice", "site"); expect(tracker).not.toBeNull(); - expect(tracker!.title.length).toBeGreaterThan(0); - expect(tracker!.issues.length).toBeGreaterThan(0); + expect(tracker?.title.length).toBeGreaterThan(0); + expect(tracker?.issues.length).toBeGreaterThan(0); // Every issue carries a display number (the board groups + links by it). - expect(tracker!.issues.every((i) => i.number != null)).toBe(true); + expect(tracker?.issues.every((i) => i.number != null)).toBe(true); // At least one epic was parsed from epics.ttl. - expect(tracker!.epics.length).toBeGreaterThan(0); + expect(tracker?.epics.length).toBeGreaterThan(0); }); it("returns null when the pod has no state.ttl (caller falls back to git)", async () => {