mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
codex/prerelease-e2e-workers
594
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
204ee7a06d |
fix(daemon): explain an ACP handshake rejection instead of retrying it (#7303)
* fix(daemon): explain an ACP handshake rejection instead of retrying it
An agent CLI that answers `initialize` and then refuses `session/new` /
`session/load` surfaced as a bare `json-rpc id 2: Internal error`, and the
retry policy treated it as transient — so the run failed, retried, and failed
identically while the user read a JSON-RPC frame.
Classify a handshake-numbered protocol error as `session_init` /
`install_cli`, keep every later protocol error on `child_close`, and lead the
user-facing message with the CLI and its detected version. The raw line stays
appended because `run.error` is also the classifier's input.
* fix(daemon): route the ACP handshake guidance onto the path users hit
The guidance copy was only reachable through `rewriteKnownAgentStreamError`,
which the ACP failure path never calls. An ACP agent's error goes
`attachAcpSession` -> `fail()` -> the bridge's `send('error', payload)`, and
that callback forwarded the payload verbatim; the close handler then returned
on `acpFatalErrorObservedBeforeCancellation && hasFatalError()` before the
stderr-tail rewrite further down. So a Kimi user still read
`json-rpc id 2: Internal error`.
Rewrite the payload at the bridge instead. That one object is the whole
user-visible surface of an ACP failure: `send` streams it to SSE clients and
`design.runs.emit` reads `run.error` out of it, so correcting it there fixes
both surfaces at once. `withAcpHandshakeFailureGuidance` rewrites only the
message fields, and only for a handshake-numbered JSON-RPC error, so
`AMR_MODEL_UNAVAILABLE`, promoted opencode errors, and post-session protocol
errors keep the shape their own handling depends on. The raw line stays
embedded because `run.error` is also the classifier's input.
Cover it through the full server run cycle with a fake ACP CLI that answers
`initialize` and rejects `session/new`, asserting on what a client and the
telemetry pipeline actually observe. Against the pre-fix tree the run error is
the bare `json-rpc id 2: Internal error`; against the pre-PR tree the daemon
also opened `session/new` twice.
* fix(daemon): read the handshake failure's cause, not just its id
Running a real Kimi CLI while signed out produces `json-rpc id 2:
Authentication required`. The handshake reading keyed on the JSON-RPC id
alone, so that user was told their CLI version was incompatible and they
should update or reinstall it — a prescription for a problem they did not
have, in place of the one sentence that pointed at the fix.
The id answers *when* a failure happened; it can never answer *why*. Split
the two questions: `isAcpHandshakeRpcErrorText` stays structural, and
`isAcpCliSessionRefusalText` is what the copy and the `install_cli` verdict
now hang off — handshake numbering AND no cause with a remedy of its own.
The "has its own remedy" half reuses `classifyAgentServiceFailure` rather
than growing a second signature list: it is agent-agnostic, already covers
auth / quota / upstream, and carries its own suite. It is deliberately
unrelated to, and narrower than, `isCliNotInstalledText` — that predicate
answers "was the binary even there", which a CLI that already answered
`initialize` has plainly settled.
A handshake failure that names its cause now keeps its own text and falls
through to the branch that owns that cause, so telemetry and copy agree: an
unauthenticated handshake is `auth` / `login`, a throttled one is
`rate_limit`. A bare refusal (`Internal error`, `Method not found`) is
unchanged and still gets the CLI guidance and the retry suppression.
No pure-function test could have caught this: the helpers were consistent
with themselves. The wiring test drives a fake ACP CLI that rejects
`session/new` with `Authentication required` and asserts on the text a
signed-out user actually reads.
* test(daemon): pin the category each named handshake cause is filed under
Verifies the routing claimed in the PR body rather than asserting it. Caught
one: `insufficient balance` has its own `insufficient_balance` category, not
`rate_limit`.
* fix(chat): name the ACP session refusal instead of writing its sentence
The guidance this PR added reached the user as a paragraph the daemon
composed: "The Kimi CLI (0.38.0) accepted the connection but refused to start
a session. … Details: json-rpc id 2: Internal error". That is the wrong layer,
and it showed:
- The daemon has no locale. The string went straight into `run.error` and
the chat rendered it verbatim, so a Chinese UI put a Chinese title over an
English body. No amount of rewording fixes that; only moving the sentence
to the client does.
- The paragraph restated the raw agent line, which the card's details block
already prints — the same text twice in one card.
So the daemon now NAMES the failure and stops there: `AGENT_CLI_SESSION_REFUSED`
plus `details: { kind: 'agent_cli', action: 'update_cli', agent, agentCliVersion }`,
with `retryable: false` because a build that refuses `session/new` refuses the
identical request identically. `withAcpHandshakeFailureGuidance` stays the single
invariant point (the ACP bridge payload feeds SSE and `run.error` at once) and now
stamps only the structured half; the stderr-tail fallbacks wrap their payloads in
it too, so no path is left composing prose.
`run.error` keeps the agent's `json-rpc id N: …` line byte for byte. It is the
classifier's input — rewriting it degrades this failure class to `unknown` in
telemetry — and it is what the details block shows, so leaving it alone is also
what removes the duplicate.
The web maps the code to localized copy in `resolveRunFailureUi`, resolved before
every other branch because the copy is not a fixed sentence: it names the CLI
build the daemon detected, and falls back to a version-less wording when there
was none (the same rule the rolling model-window copy follows). The version rides
to the client as data, on the status event next to `code` / `failureDetail`.
Three keys in all 19 locales.
Red first, on the path users hit: the wiring suite (real `startServer` + a fake
ACP CLI) went red on `run.error` being the English paragraph, and a new ChatPane
render spec went red on the raw line appearing twice in one card. Both green here.
Verified in a real browser against a local runtime: the same failed run renders in
zh-CN and in en from one persisted event.
* test(daemon): stop the wiring suite pinning a racy CLI-version probe
CI run 32683047377 caught this on the previous head: the wiring test demanded
`0.38.0` inside `run.error` and got a version-less sentence, because
`getDetectedRuntimeVersions` is a process-wide cache that `probe()` clears
before each probe and refills after — a concurrent `/api/agents` refresh can
leave it empty exactly while a run is failing. Locally it always won the race;
on CI it did not.
The new assertions inherited that: `details.agentCliVersion` was pinned to an
exact string on both wiring cases. Assert the property that actually holds
instead — the version is either the one the daemon detected or absent, never
some other value — and say in the helper why presence is not asserted here.
Both copy paths (version present, version absent) stay covered
deterministically at the layers that own them: the payload unit suite and the
two web suites.
Making detection itself deterministic is a separate finding, still open on
this PR, and deliberately not fixed here.
* fix(daemon): let the classifier, not the wording, name a handshake refusal
Three review findings, one shape: the ACP handshake verdict was being decided
in more than one place, so the places disagreed.
1. The handshake reading sat inside `isAgentProtocolErrorText`, whose JSON-RPC
arm matches only `json-rpc id N: Internal error`. A CLI that refuses
`session/new` with `Method not found` or `Invalid params` skipped it
entirely and was promoted to a retryable `fatal_rpc_error` on `child_close`
— the no-auto-retry behaviour this PR claims never applied to it — or, once
the payload rewrite had stamped its code, fell all the way through to
`unknown` / `finalize`, which is exactly the untriageable bucket the module
docblock says the rewrite must never produce.
The wording an agent picks for its own rejection is not a signal. The
handshake branch now sits AFTER every cause branch instead of inside the
protocol one, so precedence is a fact of the control flow rather than a
promise: whatever auth, rate limit, balance, upstream, prompt size, or CPU
already claimed keeps it, and only an unexplained refusal reaches the last
line. Ids 3 and up never enter it and keep the transient treatment they
have always had.
2. `handshakeFailureNamesItsOwnRemedy` knew only the three
`classifyAgentServiceFailure` classes, so every other cause the run
classifier can already advise on was rewritten into the CLI-upgrade copy.
A user whose content was too long read "your CLI version is incompatible"
while the same run was filed as `prompt_too_large` / `reduce_context`.
`isAcpCliSessionRefusalText` now IS the classifier — it runs
`classifyRunFailure` over the text and reports whether that reading is the
handshake refusal — so the card the user gets and the bucket the run lands
in cannot prescribe two different fixes. The id-only helpers moved to
`runtimes/acp-handshake-id.ts` so the classifier can read the id without the
id-reader having to reach back into the classifier.
3. The guidance read the process-wide detected-version cache at FAILURE time,
while `probe()` cleared each entry before re-reading it. Any `/api/agents`
refresh overlapping a failing run decided whether the user was told which
build refused them; CI caught it (run 32683047377) and the assertion was
loosened instead of the race closed.
Both halves are closed. A probe now publishes its reading once, at the end,
and only when no later probe has already published, so the cache holds the
newest COMPLETED reading rather than the state of one in flight. And the run
freezes the version it is spawning with, so failure-time guidance reads the
run and never the live cache.
Coverage is at the wiring layer, not just the predicates: the fake ACP CLI
gained named gates so a run's handshake and a concurrent `/api/agents` refresh
can be held open together and the failure built at the exact moment the cache
is mid-probe — deterministic, no sleeping on wall-clock timing. The wiring
suite's CLI-version assertion goes back to strict.
One deferral found by the existing suite and now pinned: a handshake-numbered
frame carrying an OS-level crash banner (Windows STATUS_ILLEGAL_INSTRUCTION
from the bundled opencode) reports a child that died, not one that refused, and
stays with the crash reading.
* test(daemon): describe the overlap the gate test actually constructs
The comment said the failure is built "at the precise moment the cache is
blank". That was the pre-fix window; the cache no longer goes blank mid-probe,
which is half of what the test proves. Say what it now holds open — a probe in
flight — and name both halves it exercises.
* fix(daemon): drop the duplicate detection import that blocks startup
`getDetectedRuntimeVersions` was imported twice — once standalone and
once inside the `./runtimes/detection.js` group — so the module has two
declarations of the same lexical binding. Node's ESM loader rejects that
outright with `Identifier 'getDetectedRuntimeVersions' has already been
declared`, i.e. the daemon could not boot.
The suite never caught it: vitest transforms through esbuild, which
tolerates the duplicate, so 179 tests passed against a module real Node
refuses to load. Verified by importing server.ts under Node directly —
SyntaxError before, gone after.
* fix(daemon): report the CLI build this run actually started, on both surfaces
A version-specific refusal (`AGENT_CLI_SESSION_REFUSED`) names the CLI build
that refused, so the user knows which one to change. Two things could make
that name wrong.
**The persisted event dropped it.** `send()` stores every run event on the
assistant message before emitting it, and `runSseEventToPersistedAgentEvent`
serialized only `detail` and `code`. The live card named the build; a reloaded
one fell back to the version-less sentence — exactly when the user comes back
to act on it. The mapper now lifts `error.details.agentCliVersion` out of the
frame, validated as a non-blank string, because `details` is a pass-through bag
that also carries the agent's own JSON-RPC `error.data`.
**The spawn-time capture could name a different executable.**
`getDetectedRuntimeVersions` answers per agent id and says nothing about which
binary the reading came from. A user who repointed the CLI after Settings last
listed agents (Settings writes `KIMI_BIN`; PATH changes) ran one build and was
told to change another. The capture now resolves through
`ensureDetectedRuntimeVersions` with the same `configuredAgentEnv` that
produced this run's `agentLaunch` — cached when the launch identity is
unchanged, re-probed through the same bounded `--version` read when it is not,
and taken before the spawn marks so preflight work is accounted to preflight.
Both are pinned end to end in `acp-handshake-failure-wiring.test.ts`: one test
reads the conversation back through the messages route a reloading client
calls, the other repoints `KIMI_BIN` between detection and the run and reads
the two fake CLIs' invocation logs to establish which child actually ran before
asserting what the failure reports.
* fix(daemon): keep a runtime that never started out of the CLI-refusal verdict
An ACP handshake rejection was decided on the JSON-RPC request id alone, so any
failure numbered 1 or 2 became "this CLI build cannot open a session; change
it". AMR is the population that reaches this path, and what it runs underneath
is OpenCode: when vela's bundled OpenCode child fails to come up it reports
`start opencode server: opencode exited before readiness: exit status 3` from
inside `session/new`, which arrives handshake-numbered while saying nothing
about the agent CLI's own build.
Two things went wrong at once. The user was told to replace a perfectly healthy
CLI, and the automatic retry that actually recovers a startup race was
withdrawn — `process_exit / agent_protocol_error / session_init`,
`retryable: false`, `install_cli`, with the ACP wrapper stamping
`AGENT_CLI_SESSION_REFUSED` on top. A startup race clears on the second
attempt; a CLI-build verdict never does, so the two must not collapse.
`isManagedRuntimeStartupFailureText` names the distinction: a CLI reporting
that a runtime IT manages never came up has named the moving part itself, so
its build is not the only variable left. The markers are vela's own wrapper
text, already trusted by `isCpuUnsupportedCrashText` for the crash-banner
sibling of this shape and now shared with it. Both deferrals now read the same
way: the handshake frame is the envelope, not the evidence — a child that DIED
(crash banner) or one that never STARTED (readiness) is not a refusal.
Also stops this suite's runtime from being a property of the host. The wiring
cases drive `GET /api/agents`, which probes every shipped runtime and resolves
binaries from PATH plus the machine's toolchain directories, so a host with
real agent CLIs installed turns each refresh into a dozen real spawns: ~2.8s
here, ~28s on a reviewer's machine — past the 20s test timeout for the
mid-refresh case, which waits on two. `isolateAgentDetection` points
`OD_AGENT_HOME` at an empty per-test directory, detection's own mechanism for
scoping the search, so the only CLI anything can find is the fixture's. Same
route, same probe path, same deterministic gates and overlap assertions; the
file drops from 109s to 14s and the mid-refresh case from 30s (timing out) to
1.2s.
* revert(daemon): drop the CLI-version half of the handshake-refusal card
The copy wanted to name the build that refused, and the chain that
followed ended in the launch path: the version had to be frozen before
the spawn, freezing meant awaiting a `--version` probe there, and an
await before `spawn()` sits after the run's only pre-spawn cancellation
guard. Stop clicked during that window finishes the run as canceled,
the probe then resolves, and the child is spawned onto an already
terminal run that nothing will signal.
The whole sub-branch goes rather than a second guard. What it bought
was `(0.38.0)` in a sentence that already read correctly without it;
what it cost was a run the user asked to stop starting anyway.
Removed: the pre-spawn `ensureDetectedRuntimeVersions` await,
`run.spawnedAgentCliVersion`, and the version half of
`agentFailureIdentity`; detection's probe generation guard;
`agentCliVersion` persistence, its `PersistedAgentEvent` field, its web
pass-through, and the `{version}` copy variant in all 19 locales.
`server.ts` no longer touches the run lifecycle at all — its diff is
four `send('error', ...)` call sites and one helper.
Detection's generation guard fixes a real, independent daemon bug (for
the length of every `/api/agents` refresh the daemon cannot report the
version it detected). It ships as its own PR, and naming the build in
the copy follows it.
Kept: the localized `AGENT_CLI_SESSION_REFUSED` card in 19 languages,
no auto-retry for a handshake-stage refusal, `run.error` exactly equal
to the agent's own line, auth/quota/upstream/managed-runtime-startup
each keeping their own verdict, and ids 3+ staying out of the branch.
The cases that asserted a version number now assert the same behaviour
without one, plus `not.toHaveProperty('agentCliVersion')` and a locale
guard against a stray `{version}` placeholder, so a partial re-land
cannot slip half of this back in.
|
||
|
|
f1a73f0d89 |
feat(od-next): structure-only layout primitives for the prototype task profile (#7327)
* feat(od-next): ship structure-only layout primitives with the prototype task profile Blind evals and the pet-grooming runs showed the same layout defects over and over — stacked spans that never became blocks, chrome height constants reused as padding, authored copy crammed until it wrapped — while the prose rules that forbid them sat unchanged in the prompt. The rules are now backed by something the Build can copy instead of re-derive: - `assets/task-profiles/prototype/layout.css`: a ~50-line, structure-only stylesheet (`@layer od-layout`, `:where()`, `od-` prefix) for stacked information, truncation/clamp of data text, media ratio, rails, and an optional screen skeleton. Declared as a prototype resource, staged into `.od-frames/` under the existing ownership manifest, and quoted into the stable request context as the `layout-primitives` fact on every prototype run. - Prototype rule card 2.2.0: a "Variable-length text and stacked information" section that separates authored copy (rewritten to a budget, never truncated) from data text (truncate/clamp in lists, wrap in full on confirmation and detail screens), keyed to the real class names; the quality floor's layout bullet now points at the primitives. - Core 2.1.0: the variable-length-text paragraph rewritten as actions (three fates before the text is written) instead of prohibitions. - Run-finish observation `od_next_layout_primitives` (verbatim / modified / linked / absent) so the rollout can tell whether a model tier reproduces the block faithfully enough for inlining. * fix(od-next): let aspect-ratio win on .od-media and pin two rule-card gaps The first verification runs exposed one primitive bug and two recurring model slips: `.od-media` lacked `height: auto`, so an `<img height=…>` attribute kept its pixel height and the ratio never applied (tall cropped thumbnails); cards built on `<a>` shipped with browser-default link styling; and product tokens declared on an inner wrapper left sheet/dialog overlays mounted in the shell's screen without a surface. The stylesheet and the rule card now say so. * fix(od-next): keep .od-media at its intrinsic ratio; make cropping an explicit opt-in Review: the fallback aspect-ratio of 1 plus an unconditional object-fit: cover turned every unset .od-media into a square crop — contradicting the core image-geometry rule (content-bearing images render their full frame; cover is reserved for deliberate crops) and the primitive's own comment, exactly on the media the rule card points at product shots. The primitive now defaults to aspect-ratio: var(--od-ratio, auto) with no object-fit; a uniform tile crop sets --od-ratio and adds .od-media-cover together, and the commerce-card row of the rule card says so. * fix(od-next): carry finish-time observations on run_finished, not the frozen baseProps Review: od_next_device_platform*/od_next_layout_primitives were spread into baseProps, which is built at run creation — before any deliverable exists — and run_finished only spreads that frozen object. The daemon log printed the observations while the analytics event never carried them, which defeats the rollout signal these fields exist for. They now join finishedProperties, read live from the run after the physical run resolves. (The device-shell fields shipped with the same trap in the shells PR; fixed here on the same lines.) --------- Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com> |
||
|
|
d03bf69d58 |
feat(standalone): complete Terminal exact lifecycle (#7319)
* feat(standalone): bootstrap terminal exact lifecycle * feat(standalone): complete terminal exact lifecycle * fix(ci): build standalone during postinstall * fix(standalone): repair exact lifecycle recovery Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(standalone): make exact updates replay-safe Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(terminal): enforce replay-safe exact carrier Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(standalone): align active update replay state Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(standalone): preserve exact channel lifecycle Generated-By: looper 0.11.2 (runner=fixer, agent=codex) --------- Co-authored-by: Looper <looper@noreply.github.com> |
||
|
|
0918d79fbd |
fix(daemon): make an ACP stage-watchdog stall attributable (#7310)
* fix(daemon): make an ACP stage-watchdog stall attributable
An ACP turn that goes silent is ended by attachAcpSession's own stage
watchdog, which fails the turn and then kills the child. Two telemetry
fields that exist to explain that terminal were wrong:
- `last_progress_age_ms` is contractually "near the inactivity ceiling on
a stall". The daemon's ACP `send` wrapper stamped
run.lastAgentActivityAt for every emission, including the watchdog's own
error, so the daemon's timeout event became the last recorded progress
and a 30-minute silence reported 664ms.
- `terminal_trigger` was unset, so the run surfaced only the child's exit
code (AGENT_EXIT_130) - indistinguishable from a user interrupt.
Field case: run 14b04dd3-56b0-4d44-926b-db6cee3017ab (2026-07-28, OD
0.16.1, amr_cloud). 37.2 minutes, killed after ~30 minutes of silence,
reported last_progress_age_ms=664, no terminal_trigger. Triage read that
as "still working when killed" and chased the wrong window.
* fix(daemon): exclude host-synthesized ACP tool pairs from the progress clock
The previous commit stopped the ACP bridge's terminal `error` from stamping
`run.lastAgentActivityAt`, but that is only half the failure path. Every ACP
fatal path calls `flushOpenAcpTools(true)` *before* `send('error')`, and that
helper emits a synthetic `tool_use` + errored `tool_result` pair for each tool
the agent left open. Those ride the ordinary `agent` channel, so they stamped
the progress clock microseconds before the run finalized — a stall that died
with a tool in flight still reported a near-zero `last_progress_age_ms`.
That is the common stall shape, not an edge case: the new regression fixture
(open tool, then a silent stage timeout) reported 605ms against a 2000ms
silence, which is the same magnitude as the field incident's 664ms.
Carry the provenance out-of-band instead of in the payload: `send` takes an
optional `AcpEmissionMeta`, `flushOpenAcpTools` tags its pairs
`hostSynthesized`, and `runtimeEmissionCountsAsAgentProgress` excludes them
alongside the terminal error. The transcript, SSE wire, and Langfuse metadata
are unchanged — the pairs are still emitted, they just no longer count as the
agent making progress.
* test(daemon): name the emission that re-stamps the ACP stall progress clock
The progress-age assertion is green locally and red on CI, and a bare
"expected 16 to be >= 1600" cannot say which emission moved the clock.
Attach the run_finished summary and the persisted run event tail to the
failure so the next CI run identifies the culprit directly.
* fix(daemon): stop the progress clock when the daemon gives up on the turn
CI proved the previous two fixes were still partial. With the failure path's
synthetic tool pairs suppressed, an ACP stall on CI still reported
`last_progress_age_ms` of 17ms and 12ms, and the persisted event tail named the
culprit:
... agent:text_delta, error, agent:diagnostic, diagnostic:runtime_close
Emissions keep arriving AFTER the terminal error. The child's shutdown line on
stderr (`child.stderr.on('data')` stamps every byte), and the diagnostic
promoted from it, both land after the daemon has already decided to kill the
turn — and each one re-stamped the clock, so the reported age measured our own
teardown instead of the agent's silence. That ordering is timing-dependent,
which is why these specs were green locally and red on CI.
Freeze the clock at the verdict instead of hunting emissions one at a time.
`noteAgentActivity` stops advancing `run.lastAgentActivityAt` once this attempt
has a terminal verdict; everything else it does (tool-token TTL, watchdog
re-arm) is unchanged. The freeze is attempt-scoped: retries and the
resume-failed reseed re-enter `startChatRun` with a fresh closure, so the next
attempt measures its own silence. User cancel returns before the freeze and
keeps its own terminal path.
The flushed-tool marker is still required and not redundant: `fail()` flushes
open tools BEFORE it sends the error, so those pairs precede the freeze.
Adds a third regression case: the agent logs to stderr while shutting down.
Without the freeze it reports ~400ms against a 2000ms silence, deterministically.
* docs(daemon): tie the progress-clock predicate and freeze into one rule
* fix(daemon): let the ACP verdict own the attempt's terminal attribution
`freezeProgressClock` stopped the timestamp but left the outer chat inactivity
watchdog armed. `attachAcpSession.fail()` issues one direct SIGTERM and nothing
escalates it, so a child that lingers past the inactivity ceiling lets
`failForInactivity` fire on a run it does not yet consider terminal — the close
handler is what terminalizes — overwriting `terminal_trigger` with
`inactivity_watchdog` and emitting a second failure. The stall then reads as the
wrong clock, which is exactly the confusion `acp_stage_timeout` exists to
remove.
Red spec (a fixture that swallows SIGTERM, with the outer ceiling set just above
the stage timeout) reproduced it deterministically:
AssertionError: expected 'inactivity_watchdog' to be 'acp_stage_timeout'
run event tail: [... "agent:text_delta", "error", "error",
"diagnostic:runtime_close", ...]
Two `error` events, and the run finished under AGENT_EXECUTION_FAILED instead of
the child's own terminal code.
`retireAttemptOnAcpVerdict` now hands the attempt to the close handler under the
verdict the ACP bridge already reached: freeze the clock, retire the outer
watchdog, and escalate the teardown. The escalation is the necessary other half
— retiring the watchdog alone would leave a SIGTERM-ignoring child with nothing
to reap it. `scheduleForcedChildShutdown` captures this attempt's child, so a
retry that swaps `run.child` inside the grace window is unaffected, and user
cancel still returns before any of this.
* fix(daemon): stop every clock of an attempt that already has a verdict
Retiring the outer watchdog at the ACP verdict was not enough on its own.
`noteAgentActivity` only skipped the timestamp when frozen and still fell
through to the timer block, which clears and re-arms `inactivityTimer` — and
the raw `child.stderr` handler routes every late byte through that helper. So a
child that logs its shutdown line while ignoring SIGTERM re-armed the very
watchdog `retireAttemptOnAcpVerdict` had just cleared, and that timer firing
before forced shutdown reaped the child terminalized the run a second time.
The two existing fixtures missed it: one ignores SIGTERM without logging, the
other logs and exits. The gap is a child that does both. With the fixture logging
once (as a real CLI announces shutdown) rather than per signal — repeating it
would keep pushing the timers out and hide the race:
AssertionError: expected 'inactivity_watchdog' to be 'acp_stage_timeout'
run event tail: [... "agent:text_delta", "error", "stderr", "error",
"diagnostic:runtime_close", ...]
`error, stderr, error` is the whole story: the late stderr between the two
verdicts is what re-armed the clock.
`noteAgentActivity` now returns as soon as the attempt is frozen, so neither the
timestamp, the tool-token TTL, nor the watchdog can be restarted by a child that
is already being torn down. The new case also asserts exactly one terminal
`error` reaches the transcript, which is the property the trigger field depends
on.
---------
Co-authored-by: lefarcen <ontf116@gmail.com>
|
||
|
|
fb26b5cb8a |
fix(runtimes): support the dsh release line users are actually served (#7339)
* fix(runtimes): support the dsh release line users are actually served Two days after #7153 widened the version policy from one release candidate to the `0.1.0-rc.N` line, upstream published `0.1.1-rc.1` and then `0.1.1-rc.2`, and made the latter the `latest` tag. Scoping to `0.1.0-rc.N` was still a pin, just a longer one: every user on what npm now serves is told their CLI is untested, our installer hands out a version two releases behind, and the connection component's peers do not resolve against the host they land on. That last one is not a warning. semver only lets a prerelease satisfy a range when some comparator carries the SAME major.minor.patch tuple AND a prerelease tag, so `>=0.1.0-rc.6` matches nothing on the 0.1.1 line — a verified `satisfies('0.1.1-rc.2', '>=0.1.0-rc.6') === false`, the same rule that made the original pin uninstallable. Move all three to the 0.1.x line: the policy accepts any `0.1.N`, release candidate or stable; both installers pin `0.1.1-rc.2` with the resolution window reset to just after its publish time; the peers gain a comparator for the new line. This will expire again. A comparator per prerelease line is what semver allows, so `0.1.2-rc.1` will not resolve until someone adds one, and nothing in the repo notices when upstream moves. The check that catches installer/policy drift only compares the two against each other. * ci: watch upstream dsh releases instead of waiting for a bug report Twice in one week `@deepseek-ai/dsh` published a new release line while this repo kept pinning the old one, and both times the detection mechanism was a person reporting that their install did not work. The check added with the first fix compares our installers against our agent def. Both are our own config, so it stays green precisely when they are equally out of date — which is the state that shipped, twice. Nothing here looks at the registry. Add a daily scheduled comparison against npm's `latest` that separates the two failures worth telling apart: `behind` means our installer hands out an older version than the registry serves, everything still works; `unsupported` means a user who has what npm serves is outside what we claim to support, which is an "untested" warning in Settings and peer ranges that do not resolve. The card names all three files that must move together, because forgetting the peer ranges is the one that breaks an install rather than warning about it. Standalone and outside the merge gate, like nix.yml and docker-image.yml: an upstream release is not a reason to fail someone's PR. The workflow runs `self-check` before touching the registry so a broken checker is loud rather than quietly in-sync. * fix(ci,runtimes): stop the drift watch losing what it exists to report Four defects from review, each of which would have made this quieter than having built nothing. The widened policy over-claimed. `/^0\.1\.\d+(?:-rc\.\d+)?$/` accepts `0.1.2-rc.1`, which the peer ranges cannot install — so the daemon would suppress the untested warning for a version whose companion fails to resolve, which is worse than the warning and silent. Narrow prerelease acceptance to the tuples and floors the peers actually carry; stable releases on the line need no comparator and stay accepted. This also retires an over-claim that predates the branch: `0.1.0-rc.2` and `rc.3` are published and below the `>=0.1.0-rc.6` floor. `e2e/tests/dsh-installer-version-policy.test.ts` now runs both authorities over a version matrix and fails when the def accepts anything the peers reject, so the two cannot drift apart again. Importing the script ran the CLI. Collecting the new test fetched the live registry — measured at 354ms with the verdict printed — and once drift existed would have attempted a real Feishu post before any assertion. Move the dispatch behind a direct-entry check and prove it with a test that stubs fetch and re-imports. Feishu answers a rejected webhook with HTTP 200 and a nonzero code, so treating every 2xx as delivered discards the alert and logs success doing it. Mirror the landing notifier's contract: code 0 or absent is delivered, 429/5xx/9499 retry, anything else raises. The workflow chose the landing webhook but signed with an unpaired secret, so a bot that verifies signatures would reject the card. Pair them the way the landing workflow does, and pin the mapping in the topology test. * test(e2e): check each dsh peer range on its own The compatibility guard scanned the whole manifest and merged every comparator into one tuple table, so a single updated peer answered for the other four. A bump that updates one range and forgets the rest — the realistic way this goes wrong — read as compatible, which is the partial state the guard exists to catch. Parse `peerDependencies`, keep the `@deepseek-ai/dsh-*` entries, and require every one of them to admit a version before calling it installable. `cordis` is excluded: it has its own version line and never moves with dsh. Two cases cover it: a fixture where one peer carries the new comparator and the others do not, and the floor itself, since a comparator for the right tuple still rejects a candidate below it. Verified against the real manifest by downgrading four of the five ranges, which reds the matrix with `expected [ '0.1.1-rc.1', '0.1.1-rc.2' ] to deeply equal []`. |
||
|
|
f92c2d957c | fix(viewer): stabilize Electron HTML previews (#7336) | ||
|
|
d85e78ab7d |
fix(runtimes): reach a working agent CLI past a broken shim (#7153)
* fix(runtimes): reach a working agent CLI past a broken shim
Resolving an agent's name on PATH only proves a file exists there, never
that it runs. Detection stopped at the first hit, so a wrapper orphaned by
a half-finished `npm i -g` shadowed a perfectly good CLI of the same name
in a later search directory — and because the not-invocable branch dropped
the resolved path, the picker (which renders an unavailable agent only when
it carries one) hid the agent entirely. The user saw "scan found nothing"
with no way to act.
Detection now walks past candidates that cannot be executed, and reports
the path it tried when every candidate fails, so the row stays actionable.
Also on this chain:
- `@open-design/dsh-runtime` pinned its DeepSeek Harness peers to one exact
release candidate, so it became uninstallable the moment upstream shipped
the next one. Peers now take ranges. `scripts/guard.ts` gains a narrow,
logged exemption for external-host plugin manifests — their peers describe
a host this repo neither installs nor controls, which is the one case
where an exact pin is wrong rather than reproducible.
- The DSH installers pinned only the entry package while its ~190 siblings
used caret ranges over prerelease floors. Once a newer release candidate
existed, the transitive tree floated onto it and its mutually exclusive
peer sets sent npm into an ERESOLVE backtrack that never converged. Both
installers now freeze the resolution window alongside the pin.
- The rescan notice counted agents the list never renders, so it announced
"3 available" above two rows.
* fix(runtimes): make detection's chosen executable the one spawn sites use
The candidate walk taught detection to skip a binary it cannot execute, but
every other resolution — chat, the connection test, the memory summariser,
companion install — called `resolveAgentLaunch` with no skip list and got
back the first file that merely exists on PATH: the broken shim detection
had just rejected. Settings advertised the agent as installed while each
turn exec'd the wrapper, so the fix only ever reached the picker.
Detection is the only stage that spawns anything, so it is the only one that
learns which candidate works. It now publishes that winner, and resolution
prefers it over a fresh first-hit walk. An explicit `*_BIN` override and a
packaged built-in still outrank it, a winner whose file has since vanished
is ignored, and detection clears the entry before each pass so a rescan
after a repair never resolves against a stale one.
This restores the invariant the file already documented — detection probes
the exact path the runtime will spawn — which the walk had broken.
Reported by @mrcfps in review.
* fix(runtimes): keep the remembered executable inside the live candidate set
The winner map made resolution stop being a pure function of the current
environment. It only checked that the remembered file still existed, so a
path learned in a richer environment survived an emptied PATH, a sandboxed
OD_AGENT_HOME, and any other narrowing of the search — resurrecting a binary
the caller could no longer see.
That is not just a test artifact: `GET /api/integrations/vela/status` must
answer `amr-runtime-unavailable` when the vela binary cannot be resolved, and
it started answering "ready" instead, because an earlier detection in the
same process had remembered a path.
The memory now reorders candidates rather than introducing them: it applies
only when the live search already offers the remembered path. An emptied
PATH, a sandbox, and an uninstalled CLI all keep meaning "not found", while
a broken shim shadowing a working binary is still resolved to the winner.
This also settles the scope question raised in review — no separate cache key
is needed, because the candidate set already carries the resolution scope.
* fix(runtimes): treat a launcher whose target is gone as not invocable
A stale global npm wrapper on Windows is a `.CMD`, which OpenDesign runs
through `cmd.exe /d /s /c`. When the package behind it has been
uninstalled, cmd.exe starts, node starts, and only then does node fail to
load the script the wrapper names — a plain exit 1 with MODULE_NOT_FOUND
on stderr. None of the spawn-level signals the classifier looked for
(ENOENT / EACCES / ENOTDIR, exits 126 and 127) can occur along that
chain, so detection read the dead wrapper as a healthy CLI that merely
disliked `--version`, stopped the candidate walk on it, and never reached
the working `dsh.CMD` later on PATH.
Classify invocability from the launcher's own vocabulary as well: cmd.exe's
9009 (the Windows analogue of POSIX 127), node's missing-module report, and
cmd.exe / PowerShell's missing-command and missing-path reports. Exit status
alone still means nothing — a CLI that runs and exits non-zero is a real
answer from the right binary and keeps its place in the walk.
Drop the win32 early return from every case in the fallback suite and write
fixtures per platform, so `dsh.CMD` resolution through PATHEXT and the
cmd.exe wrapping are exercised natively. The orphaned-wrapper regression is
reproduced on every platform — the defect is a classification bug, not an OS
API one, and the daemon suite only runs on Linux in CI.
* fix(runtimes): carry forward the dead paths, not detection's winner
Detection published the candidate it settled on and resolution preferred
it over the first live PATH hit. That pinned the daemon to one binary for
its whole lifetime and let it silently outrank the caller's own PATH: a
CLI that became visible earlier afterwards — a fresh install, a version
manager swapping shims, a caller resolving under its own environment —
could no longer be reached.
The daemon suite caught it on a machine that has a real `vela` installed:
`chat-route` and `connection-test` inject a fake agent CLI ahead of PATH
per case, and the remembered winner outranked it, so 15 cases spawned the
machine's real CLI instead of the fixture. CI never saw it because its
runners have no such CLI to remember.
Carry the proven-dead paths forward instead. Skipping them keeps every
candidate still standing in plain PATH order, while chat, the connection
test, the memory summariser, and companion setup still avoid the shim
detection rejected — which is the whole point of publishing anything.
* docs(runtimes): say why detection keeps its own launcher-error list
`isCliNotInstalledText` in run-failure-classification.ts reads similar
output, and the next reader will reasonably ask why the two are not one.
They answer different questions: that one buckets a finished run for
telemetry, where over-matching costs a mislabelled event, while this one
decides whether to abandon a binary mid-resolution, where over-matching
sends a working CLI's user to a different install.
* fix(runtimes): accept the dsh release line, not one release candidate
This branch moved the installers to 0.1.0-rc.8 and left the agent def
naming 0.1.0-rc.6 as the only supported version, so following our own
one-line install produced "0.1.0-rc.8 has not been tested with this
OpenDesign build" in Settings on a clean machine. That is the same
pinning defect the installers had, one layer up.
`dsh` publishes a stream of release candidates whose sibling packages
peer-require each other, so the version our installer hands the user
moves with upstream. Version policies can now declare the release line
they accept alongside the exact versions exercised, and DeepSeek Harness
declares its own. A version off that line still warns — the point is to
stop pinning, not to stop checking.
Add a cross-boundary guard for the drift itself: the installers and the
agent def cannot import each other, so `e2e/tests` reads both and fails
when an installer pins a version the daemon would not accept.
* docs(contracts): correct what shim-broken now means
The reason's docstring said "exit 126/127", which stopped being the whole
story once detection learned to read a Windows wrapper's stderr: a `.cmd`
starts an interpreter successfully and reports the missing target there,
so no POSIX exit code appears at all.
* test(runtimes): cover the launcher signatures no fixture can produce
`launcherTargetMissing` recognises six ways a launcher says it never
reached its target, and until now two of them had any coverage — the two
a POSIX shell can print. cmd.exe's wording, PowerShell's, and exit 9009
were load-bearing for the Windows fix and untested anywhere.
A fixture cannot close that gap. POSIX masks exit statuses to eight bits,
so a shell asked for 9009 reports 49, and /bin/sh does not speak cmd.exe.
The daemon suite runs on Linux in CI, so driving the classifier directly
is the only way the merge gate can catch a typo in a signature the fix
depends on for the platform the bug came from.
Extract the catch arm verbatim as `classifyVersionProbeFailure` — same
branches, same order, no behaviour change — and feed it the rejection
shape `promisify(execFile)` actually produces. Verified by mutation:
deleting the 9009 branch reds one case, deleting the stderr branch reds
six.
The suite also pins the constraint from the review by name: exit 1 with an
ordinary argument complaint, with no stderr, or with wording that belongs
to run-failure telemetry ("not installed") all stay `spawned`. That last
one is why detection keeps a narrower list than `isCliNotInstalledText`.
|
||
|
|
18273c01b8 |
fix(packaged): recover from stale web sidecars (#7279)
* fix(packaged): recover from stale web sidecars * fix(packaged): stop stamped web owner before socket takeover Unlinking only the unresponsive web socket left the old Next process alive. Its later JsonIpcServer.close() could delete the replacement pathname. Stop the stamped owner through platform primitives first. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(packaged): strip handoff parent pid and skip unsafe web unlink Strip OD_TOOLS_DEV_PARENT_PID from the legacy payload desktop spawn environment so the replacement does not exit after waiting for the outer PID. Skip web socket unlink when a stamped owner is still running after stop. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(packaged): skip web unlink when process discovery fails listProcessSnapshots returns [] on enumeration failure, so an empty table is not proof that no stamped owner exists. Leave the live socket in place unless discovery succeeded and every matched owner is gone. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(daemon): hold parent monitor until handoff journal commits Packaged daemons now exit when the outer Electron dies. Desktop SHUTDOWN acks before asynchronously exiting, so the parent can disappear while armed handoff, attempt, and runtime writes are still pending. Hold the parent-death exit across that window. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(daemon): defer SHUTDOWN until handoff journal commits Packaged beforeShutdown still sends sidecar SHUTDOWN after desktop acks, and that path ignored the parent-monitor hold. Wait for the same release before exiting so handoff.json, attempts.json, and runtime.json can finish. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(daemon): defer SIGTERM until handoff journal commits Packaged closeManagedChild waits five seconds then stopProcesses() sends SIGTERM. That handler still exited immediately and could cut off the held journal writes. Route signal shutdown through the same hold as SHUTDOWN. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(daemon): hold parent monitor before payload spawn A crash after the replacement emits spawn but before the journal hold lets the parent monitor exit the daemon while handoff.json is still prepared. Acquire the hold before spawn and release it on failure. Generated-By: looper 0.12.0 (runner=fixer, agent=opencode) * fix(packaged): wait for deferred handoff shutdown before SIGKILL Daemon SHUTDOWN now reports deferred:true while the handoff journal hold is active. closeManagedChild then waits a bounded 30s for self-exit before stopProcesses, so SIGKILL cannot cut off the three sequential journal writes. A POSIX regression exercises the real stopProcesses path and checks all three files commit before forcedPids. |
||
|
|
7320f9b8ab |
feat(daemon): make OD Next the default design strategy (#7016)
* refactor(observability): isolate legacy run exporter
* refactor(daemon): extract internal run creation service
Route /api/runs and compatibility /api/chat through one daemon-owned create, claim,
resume, and start seam. Preserve HTTP responses, SSE timing, session bookkeeping,
and durable Run formats.
Validated with daemon tests/build/typecheck, workspace guard, and workspace typecheck.
* test(plugin-runtime): freeze OD Next strategy fixture boundaries
Add exact pre-Build route and stage cases plus deterministic deletion-boundary mutations without shipping an evaluation harness.
Preserve the current official and community fallback pipeline and composed verification/critique prompt sections as golden witnesses.
Validated with plugin-runtime tests and types, focused daemon prompt composition, daemon types, guard, and workspace typecheck.
* fix(daemon): finalize terminal telemetry delivery
Persist a versioned delivery record so normal failures stop after bounded retries
and only an unfinished crash window resumes on restart.
Reuse the persisted identity across Vela and Relay transport, isolate stable
direct Langfuse event ids by delivery purpose, and log only redacted sink data.
Validated with focused daemon and contracts tests, workspace typechecks, and
guard. The full daemon suite had one timeout; its four-test file passed alone.
* feat(plugins): add OD Next strategy scenario and contracts
Ship the V2 strategy package as inactive bundled content and validate its contracts without changing ordinary plugin manifest compatibility.
Keep bundled strategy bytes out of the catalog and generic apply path until a later hash-gated activation owner binds them.
* feat(observability): add normalized agent observation contract
Define a pure versioned contract for identity, Prompt, usage, timing, and status evidence with explicit availability and per-value provenance.
Add a structured main-Run compatibility adapter that preserves unknown facts, separates clocks, and omits provenance-ambiguous derived totals without changing legacy export behavior.
* feat(plugins): bind OD Next strategy content identity
* feat(daemon): persist OD Next strategy task chains
Add the daemon-owned SQLite truth for immutable strategy task identity, ordered physical Run mappings, canonical Plan Contracts, revision-CAS transitions, and terminal reconciliation.
Pin task-owned Snapshots and exclude their durable references from pruning so mixed GC sweeps remain safe.
Validated with 103 focused daemon tests, contracts tests/typecheck, daemon and workspace typechecks, and guard.
* feat(daemon): gate OD Next runtime capabilities by fixtures
Add exact CLI/adapter/fixture tuple resolution, stable capability snapshots, and offline
replay graph invariants for the four initial runtime paths.
Keep production registry empty until sanitized real fixtures exist; synthetic evidence and
version drift remain unknown.
Validated with contracts tests, focused daemon and e2e boundary suites, guard, and
workspace typecheck.
* feat(daemon): add hash-gated OD Next prompt recipe
Compose the bundled strategy from validated content, one Task Skill, and the exact stage policy.
Fail closed on package, profile, pipeline, or atom drift while preserving ordinary quality paths.
Validated with focused tests, package typechecks, workspace typecheck, and guard.
* feat(observability): aggregate OD Next task observations
Build stable task, Run, Child, model, and tool hierarchies from durable StrategyTaskExecution mappings and normalized facts while keeping missing coverage explicit.
Map task aggregates to legacy ingestion behind the consent gate. Preserve owner/exclude hierarchy while accounting usage once, enforce kind-specific Prompt boundaries, and accept absolute time only from one Unix-epoch evidence record.
Validated with 228 focused daemon tests, 353 contracts tests, daemon/contracts and workspace typechecks, guard, and the task harness.
* feat(daemon): collect bounded Codex child evidence
Read declared Codex rollout sessions without scanning arbitrary homes.
Normalize verified child facts while redacting prompts and preserving Run semantics.
* feat(daemon): add OD Next planning coordinator
Add a daemon-owned planning reducer, deterministic Resolver and Preflight facts, and a stream-safe machine protocol boundary backed by the durable task store.
Lock one clarification and one serialization-only repair across restart, preserve the Plan hash, and keep ordinary Run text unchanged.
* feat(daemon): collect native OpenCode child evidence
* feat(observability): add Langfuse OTLP task exporter
Map normalized task observations to complete OTLP/JSON v4 spans with stable parent context, allowlisted metadata, usage accounting, and bounded terminal delivery.
Fail closed on drift across root and operation input, model, timing, type, hierarchy, status, and complete usage provenance while dual remains a local-only OTLP shadow.
Keep legacy rollback, consent gates, and X5 live self-host verification boundaries intact.
* feat(daemon): normalize Claude native child evidence
Collect matched Claude Task sidechain lifecycle facts without changing main stream turn, stdin, or error bookkeeping, and adapt them to provider-neutral observations with unavailable Prompt and usage evidence.
Bind session, Task id, and parent identity immutably; poison rebinding, session drift, and conflicting terminals so ambiguous evidence cannot reach L2. Keep production capability unknown until X1 supplies an exact-version sanitized real fixture.
* feat(daemon): consume versioned Vela child evidence
Negotiate schema v1 only on AMR and enforce run-scoped start-to-terminal graph, status/source coherence, and exact usage source/completeness bindings. Emit allowlisted Normalized child facts without Vela private I/O.
Pin the unpublished producer golden for offline compatibility while keeping production capability unknown until the X1/X3 evidence gates are satisfied.
Validated with 147 focused daemon tests, 353 contract tests, daemon/workspace typechecks, guard, and the task harness.
* feat(daemon): automate OD Next simple production
Connect parsed OD Next terminal output to the durable coordinator, claim clarification, repair, and production Runs atomically, and start continuations only after the source Run ends.
Converge task failure and cancellation before physical end, require daemon-owned execution facts and canonical delivery, and keep ordinary Runs compatible.
Validated with focused daemon route and coordinator tests, contracts tests, daemon and workspace typechecks, guard, and the task harness.
* feat(daemon): enforce verified complex production
* feat(web): follow OD Next task chains across Web and CLI
Persist daemon-issued task handles and visible predecessor-prefix boundaries, then follow projected active Runs through one terminal outcome in Web and od run.
Normalize reloads to daemon activeRunId before subscribing, transfer controller ownership atomically across successors, discard stale cursors, and preserve visible prefixes without replaying predecessor machine payloads.
Validated with focused Web and daemon tests, contracts tests, workspace typecheck, guard, diff checks, and the task harness.
* feat(daemon): roll out OD Next task observability
* feat(daemon): add controlled OD Next rollout
* fix(daemon): align OpenCode and Vela evidence tuples
Pass GPT-5.6 Sol's verified high variant to OpenCode, pin native evidence to 1.18.18, retain locally recorded success and failure seeds as explicitly unattested, and align the Vela consumer golden to the local versioned producer.
Validated with 5 focused daemon files (175 tests), daemon and workspace typechecks, guard, the real local Vela ACP Sol smoke, and a minimal test-relay telemetry write.
* fix(daemon): honor OpenCode model variant capabilities
Parse the live verbose OpenCode catalog per model and forward only variants advertised by the exact detected binary. Keep fallback and one-off OPENCODE_BIN connection tests on the base model when variant support is unknown.
Record machine-checkable OpenCode 1.18.18 root, tool, child, recovery, cancel, timeout, and resume evidence without promoting the unattested seed into the production capability registry.
Validated with focused runtime and connection tests, host process-group and watchdog tests, contracts tests, daemon/workspace typechecks, guard, and an isolated SIGKILL delivery-recovery exercise.
* feat(daemon): add OD Next analytics operations report
Reduce normalized task aggregates into source-aware operations metrics without
copying the Langfuse dashboard or exposing user trace links.
Preserve main/Child accounting, stage-scoped coverage, unavailable facts, and
external-signal-only quality inputs.
Validated with focused and contract tests, the full daemon suite, guard,
workspace typecheck, and exact-trace Langfuse readback.
* feat(observability): complete child prompt and runtime provenance
Export bounded redacted child-injected Prompt content and exact CLI, companion, and
adapter versions through task-level legacy and OTLP mappings.
Register the OpenCode 1.18.18 best-effort replay tuple while keeping unknown versions
and incomplete Codex evidence fail closed.
Validated with 353 contracts tests, 196 focused daemon tests, daemon/workspace
typechecks, and guard.
* fix(daemon): recognize Codex child failure terminals
Parse Codex 0.147.0 Child task_complete.error as the runtime-owned failed terminal, honor session_meta id over its legacy root session_id, and map interrupt_agent aborts to canceled.
Add a redacted seven-path best-effort replay and exact Codex capability tuple. Unknown versions remain fail-closed and ordinary Codex runs are unchanged.
Validated with 92 focused daemon tests, 353 contracts tests, daemon and workspace typechecks, guard, and the 25-unit task harness.
* fix(daemon): report verified child runtime versions
Use versions observed from resolved CLI probes or Vela's ACP handshake when annotating Child observations. Correct Native OpenCode's allowlist key, add Claude and Vela/OpenCode companion provenance, reject Vela probe/handshake disagreement, and ignore the legacy 0.0.0 placeholder.
Validated with 252 focused daemon tests, daemon and workspace typechecks, guard, the task harness, and a real local Vela --version/-v/ACP initialize build smoke.
* test(observability): record Vela child evidence matrix
Add a sanitized provider-backed Terra replay for main, tool, child success, child failure with parent recovery, cancel, timeout, and cross-process resume. Keep the unpublished Vela tuple outside the production registry while pinning its exact candidate and OpenCode companion evidence.
Validated with focused Vela consumer/capability tests, daemon typecheck, workspace typecheck, guard, and the task harness.
* feat(daemon): enable OD Next production profiles
Make prototype, presentation, marketing, and HyperFrames the exact global OD Next production set while preserving explicit off/observe, user plugin authority, and fail-closed runtime capability gates. Feed runtime-owned profile, route, output, input, and capability facts into the planning contract and keep all other artifact kinds on ordinary Open Design.
Use bounded executable-scoped CLI version probes so headless and restarted daemons can resolve exact tuples without a prior agent-list request or cross-binary cache contamination.
Validated with four provider-backed Terra CLI chains, focused daemon/contracts/plugin-runtime suites, daemon and workspace typechecks, guard, and the 25-unit task harness.
* feat(daemon): classify OD Next exact agent inputs
Route prompt contributors through one daemon-owned choke point and version their final-text, semantic-fact, transport, and exclusion ownership for the forthcoming canonical bundle. Send OD Next continuation stages directly through native sessions without rewrapping or re-seeding stable instructions.
Validated with focused daemon and contracts tests, daemon/workspace typechecks, and guard.
* fix(daemon): close OD Next input ownership gaps
Bind semantic request facts to their real daemon-system or request producers, cover the DSH profile exact-text transport, and reject lookalike prompt-bundle root names.
Validated with focused exact-input, production-server and DSH tests, daemon typecheck, task harness, and guard.
* feat(daemon): emit canonical OD Next prompt bundles
Add strict pure TypeScript Bundle and request-Turn serializers, then route OD Next request and continuation text through their versioned XML contracts.
Split Web prior transcript from the current turn without text subtraction, keep mutable Skill and attachment inputs explicitly pending, and preserve ordinary prompt behavior.
Validated with focused contracts, daemon and web tests, the real server fixture, package and workspace typechecks, and guard.
* fix(daemon): align OD Next bundle input ownership
Register the real canonical Bundle contributors and keep current-turn, prior-transcript, pending-fact, and research-query ownership explicit at the production seam.
Reject continuation task_run_index zero and cover Web framing, ordinary compatibility, fail-closed contributor drift, and real server prompt capture.
* feat(daemon): freeze OD Next user skills
Capture explicit Skill bodies and audited side files before task claim, persist verified task-owned bytes, and replay only the frozen package for OD Next runs.
Keep legacy single-Skill CLI requests and ordinary live staging compatible while adding Web/CLI multi-Skill coverage and fail-closed snapshot checks. Validated with focused server, contract, CLI, Web, typecheck, and guard suites.
* fix(daemon): harden OD Next skill snapshots
Require an explicit frozen package for every mapped OD Next task and fail corrupt task state before any live Skill staging.
Use bounded no-follow descriptor reads with race checks, preserve legacy CLI compatibility, and make materialized roots collision-resistant. Validated with focused, real-server, workspace typecheck, and guard suites.
* feat(daemon): freeze OD Next task input snapshots
Freeze OD Next attachments and canonical configuration before the atomic run/task claim, then route runtime access through durable task-scoped snapshots while preserving ordinary attachment behavior.
Validate immutable identities, transport references, continuation propagation, tamper failure, and the four production task types with focused contract and daemon coverage.
* fix(daemon): isolate OD Next runtime input projections
Keep canonical task snapshots daemon-only, rebuild a read-only per-Run projection, and deliver frozen document resources through ACP or OD_TASK_INPUT_DIR.
Harden source and snapshot reads with no-follow same-fd identity checks and bounded caps. Validated with focused runtime tests, contracts, daemon/workspace typechecks, guard, and the task harness.
* fix(daemon): harden OD Next snapshot transport boundaries
Reject intermediate symlinks and realpath escapes while loading canonical snapshot files, including identical-byte replacement targets.
Preserve ordinary ACP duplicate image ordering while suppressing only OD Next resource/image cross-duplicates. Validated with focused tests, daemon typecheck, guard, and the task harness.
* fix(daemon): preserve structured OD Next snapshot failures
Defer frozen Skill package errors from the Task 04 preclaim lookup to the existing post-claim failure boundary so mapped Runs remain durable JSON failures without orphan input snapshots.
Validated with the combined real-server repair, missing-package, and tampered-package regressions.
* fix(daemon): keep OD Next continuation cwd stable
Separate authoritative project cwd resolution from best-effort file inventory and tolerate entries deleted between directory enumeration and stat.
This keeps native-session identity stable while frozen task inputs replace live attachments during continuation.
* feat(daemon): persist exact OD Next prompt inputs
Persist the canonical first-turn Bundle and every deterministic continuation Turn with UTF-8 and SHA-256 identities, then send only those validated texts through runtime transports.
Fail closed on mapped task drift, tamper, legacy NULL identities, missing mappings, and cold continuation while leaving ordinary Runs on the existing live composer.
Validated with the 15-case automatic server acceptance twice, 223 focused daemon tests, daemon typecheck, guard, and the task harness.
* feat(daemon): observe exact OD Next prompt inputs
* feat(daemon): bind and observe Claude native subagents
Register the replay-verified Claude 2.1.233 tuple, bind complex Build Packages to daemon-issued native Agent handles, and derive completion only from durable child lifecycle evidence.
Export bounded parent-tool, Child Agent, and Child-tool spans through the existing legacy and OTLP task hierarchy without exposing raw tool inputs, outputs, provider ids, paths, or credentials.
Validated with focused contracts and daemon suites, the 17-case real-server chain, workspace typecheck, guard, and the 26-unit task harness.
* fix(daemon): repair OD Next rollout authority and controls
* fix(daemon): harden OD Next public canaries
* refactor(daemon): extract OD Next prompt preparation
* test(daemon): align plugin fixture with project schema
* test(e2e): assert daemon-owned prototype routing
* fix(daemon): restore OD Next task observability
Default eligible OD Next tasks to privacy-aware Langfuse hierarchy delivery through relay or direct sinks, with a versioned safe quality projection.
Persist lightweight representation ownership before suppressing mapped Runs, retain best-effort startup retry semantics, and preserve single-Run compatibility when Task delivery is not eligible.
Adopt the active payload relay for historical packaged launchers and cover the real sidecar environment handoff.
Validated with the full daemon suite, focused contracts/packaged/e2e/tools-pack suites, workspace guard, and workspace typecheck.
* fix(daemon): make OD Next capability fallback invisible
Admit capability-certified runtime adapters across CLI version drift while keeping recorded fixture versions as auditable evidence and reserving a narrow incompatibility denylist.
Fall back before Run claim on preparation failures, keep continuation bound to the frozen capability snapshot, and report truthful child coverage across Langfuse legacy and OTLP without replaying an active task.
Validated with focused contracts and daemon suites, the automatic server suite, pnpm guard, pnpm typecheck, daemon typecheck, LSP diagnostics, and git diff --check.
* fix(strategy): isolate OD Next automatic task routing
* fix(daemon): persist blocked strategy-task attribution
Every blocked strategy task now stores its gate reason codes and the
agent-visible turn text (blocked_reason_codes_json / blocked_visible_text)
so operators can diagnose a blocked task from the store alone. Covers the
coordinator blockTask gate, the automatic-continuation fail-closed path,
and startup run-terminal reconciliation.
* fix(daemon): accept form-only clarification turns by deterministic inference
A first Full-Plan request turn that renders exactly one question form and no
machine-protocol block has exactly one valid protocol meaning: outcome
clarification_required with an unlocked execution mode. The coordinator now
recovers that state deterministically instead of dead-ending the task as
blocked, stamps od_next_protocol_runtime_state_inferred into the accepted
state's reasonCodes for attribution, and keeps every ambiguous shape (extra
forms, recovered plan blocks, later stages, spent clarification budget,
any other protocol issue) fail-closed. This closes the observed field
failure where the agent's first reply produced a discovery form, the task
went blocked, and the user's form answer then failed with 409
STRATEGY_TASK_STATE_MISMATCH.
* feat(contracts,daemon): project blocked strategy-task attribution to clients
A task the OD Next protocol gate settles as blocked persists its
attribution (reasonCodes + agent-visible text) since
|
||
|
|
e34d823160 |
fix(web): gate PPTX export on a daemon-advertised renderer capability (#7224)
* fix(web): gate PPTX export on a daemon-advertised renderer capability
The export entry was offered whenever the artifact looked like a deck, so
in a deployment without an off-screen renderer (headless / container) the
button was always shown and always failed: `POST /export/pptx` answers 501
whenever `desktopSlideRenderer` is absent, and the check sits *before* the
editable/screenshot branch, so the mode picker in the modal was a choice
that could not affect the outcome either.
Ask the daemon instead of guessing. `/api/version` now carries an optional
`capabilities.slideRenderer`, computed from the very binding the export
routes guard on, so the advertisement cannot drift from the 501 they
return. It rides on the version payload because that is already the
"what runtime am I talking to" channel (packaged/channel/platform/arch),
already fetched once at boot, and — unlike `/api/app-config` — never
persisted: the same data dir can be opened by a desktop daemon today and a
headless one tomorrow, so a stored answer would be a stale claim.
The flag deliberately cannot be derived from `packaged`: a packaged binary
started as a plain daemon has no renderer.
Mixed versions are the delicate part. The field is optional and the client
treats absent as *unknown*, not false, so a newer web bundle keeps showing
PPTX against an older daemon that supports it but does not advertise it
yet. Only an explicit `false` hides the entry. A present-but-malformed
block is dropped rather than trusted, and an unresolved answer stays
retryable so one early fetch against a not-yet-ready daemon cannot pin
"unknown" for the session.
Client-side `isOpenDesignHostAvailable()` was not used for this even though
it is the existing desktop-only gate: it reports whether the web UI runs
inside the Electron shell, which is wrong in both directions here — a
browser pointed at a desktop-hosted daemon does have a renderer, and a
packaged daemon may not.
Also splits the two states that `unavailable: true` collapsed. A dead
daemon and a runtime with no renderer both produced "PPTX export is not
available here", which sends someone whose daemon is down to look at the
wrong problem. Callers keep the existing `'unavailable' in res` fallback
behaviour; only the message differs.
* test(web): pin the two unavailable reasons in the export specs
The three specs that asserted the old flat `{ ok: false, unavailable: true }`
now pin which reason they exercise: the two 501 cases as `no-renderer` and
the transport failure as `unreachable`. Without this the split is only
enforced by types, so a later edit could collapse both back onto one value
and every assertion would still pass.
* fix(web): let a late capability answer reach an already-mounted viewer
Review catch (thanks @nettee): the first shape probed once on mount and
ignored an unresolved result, so a probe that raced daemon startup or
blipped left `available` at null for the life of that mount. The gate then
stayed permissive and the entry could still be clicked into the very 501
this change exists to prevent. Worse, the module raced the app's own boot
fetch with a second request, so a failed probe here could keep the entry
showing even when the boot response had already reported `false`.
Three changes, all aimed at that gap:
- The boot fetch now feeds the cache. `fetchAppVersionInfo()` hands its
parsed payload to `recordAppVersionInfo()`, so the common path costs no
extra request and the two paths cannot disagree. The dependency runs
registry -> runtime-capabilities only, so there is no cycle; this module
no longer imports the registry and does its own minimal probe when it
still has to.
- Consumers subscribe. An answer arriving from boot, from a retry, or from
another mounted consumer now reaches every mounted viewer instead of
leaving each one pinned on its own failed probe.
- Unresolved probes retry with a bounded backoff (500ms / 1.5s / 4s) and
then stop. An unreachable daemon is its own visible problem and should
not be polled forever; a startup race is exactly what needs one more look.
Covered at the level the review asked for: a FileViewer spec where the
first probe cannot resolve, the entry is still offered (unknown means
assume available), and a later explicit `false` removes it. Verified red by
restoring the old single-shot behaviour — the late answer reaches nobody
and the entry stays.
* fix(web): recognise proxy-answered daemon outages as unreachable
Review catch (thanks @nettee): the daemon-down diagnostic did not actually
fire on the packaged / sidecar path, which is the common one. The proxies
in front of the daemon do not reject `fetch` when it is down — they answer.
The web sidecar ends a connection error as a plain-text 502, and Next's dev
rewrite as a plain-text 500 when OD_PORT points at a dead port. Both landed
in the generic `!resp.ok` branch and surfaced as "export request failed
(502)", so the new message this PR added never appeared where it was most
needed.
`state/projects.ts` already had exactly the right detector for this, kept
private. Extracted it to `runtime/daemon-proxy-failure.ts` and used it from
both places rather than growing a second copy.
Kept deliberately narrow: only a 500/502 that is `text/plain` AND carries a
connection errno counts. Widening to any 5xx would relabel genuine upstream
failures as an outage — the same misleading move in the opposite direction,
which is why the original detector was written this way. A JSON 502 stays a
semantic error, and there is a spec pinning that.
* fix(web): honour the unknown contract on both capability cache edges
Two compatibility edges from review (thanks @nettee), both of which broke
the same rule this PR is built on: absent or malformed means unknown, and
unknown must keep the entry visible.
- A resolved `false` outlived the daemon that gave it. If a page cached
`false` from a headless daemon and then talked to one that stops
advertising the field — a restart or a downgrade — the stale `false` kept
hiding the entry against a daemon we are supposed to know nothing about.
A fresh, valid response with no capability now drops the cache back to
unknown and tells mounted consumers, so listeners take `boolean | null`.
- The module's own probe trusted the capability without validating the
envelope it arrived in, so `{version:{capabilities:{slideRenderer:false}}}`
could publish `false` and hide the entry. That was self-inflicted: the
validation went missing when this module stopped importing the registry to
avoid a cycle, and nothing replaced it.
`isAppVersionInfo` now lives here and the registry imports it, so there is
one validator instead of two and the dependency still runs one way only.
Regression specs for both: `false` followed by an absent field returns to
unknown, and a capability in a malformed envelope is refused.
* fix(web): subscribe mounted viewers even when the cache is already resolved
Follow-on from the invalidation added in the previous commit, and the same
mistake one layer out: a viewer that mounted while the answer was already
`false` took the early-return path and never registered a listener, so when
a later valid response omitted the capability and invalidated the cache to
unknown, that viewer never heard it. Its entry stayed hidden against a
daemon the compatibility contract says we know nothing about — exactly the
bug the invalidation was added to prevent, just moved from the cache to the
consumer.
Subscribe unconditionally and only skip the probe when the cache is already
resolved. Spec covers the mounted case the loader-level tests could not:
render with `false` cached and the entry absent, invalidate, entry returns.
Verified red by restoring the early return.
* refactor(web): collapse the capability store into one atom with one writer
Four review rounds found six compatibility edges in this module, all the
same shape: `cached`, `inFlight` and the listener set were separate mutable
pieces, and each fix exposed another pair that could disagree — an
invalidation that forgot the in-flight promise, a validation path that
forgot the invalidation, a subscription that forgot to register. Patching
the seventh instance was not going to be different from the sixth.
So the state is now one snapshot behind one writer. `setSnapshot` is the
only thing that mutates it and always drops any in-flight probe, because a
promise created under a superseded answer can only report something this
state has already replaced. `loadSlideRendererAvailable` clears the promise
on settle rather than only on success, so an unanswered probe stays
retryable. Consumers read through `useSyncExternalStore`, so every mounted
viewer sees the same value including a later invalidation — there is no
longer a subscribe-or-not branch to get wrong.
That makes the two edges from this round unrepresentable rather than merely
fixed: a settled probe cannot be served after an invalidation, and a
malformed payload now routes through the same invalidation path as an
absent capability instead of early-returning past it.
One deliberate asymmetry: a transport failure does not invalidate. It is
not an answer, unlike a malformed body, so it must not erase what we know.
Covered by its own spec.
* fix(web): version probes and treat an unusable answer as unknown
Blocking catch (thanks @nettee): clearing `inFlight` stopped a stale promise
being *served*, but not a request already in the air from writing when it
lands. A probe issued before the boot fetch could resume afterwards and put
its `true` back over the boot answer's `false` — the entry returns on a
headless daemon and the next export hits the 501 this change exists to
gate. That was the half of "collapse the state" I had not done: the writes
were unified, but time was not.
Each probe now carries the generation it was issued under and drops its
result once a write has superseded it, so a request from the old world
cannot write into the new one.
Second, transport and parse failures are no longer the same branch. A 200
whose body will not parse IS an answer, just an unusable one, and the
contract says that means unknown — collapsing it with a dead socket let a
stale `false` survive it and keep hiding the entry. Both the boot fetch and
the direct probe now invalidate on an unparseable OK response while leaving
a known answer alone on a transport failure.
Specs: a deferred probe that resolves after a `false` is recorded, an
unparseable 200 through the boot path, and a transport failure that must
not erase what we know. The first is verified red by removing the guard.
Also replaced a spec that passed without exercising its path — a cached
answer short-circuits the direct probe, so the parse-failure case only
reaches the code through the boot fetch.
* refactor(web): read the capability from app state instead of a client store
Seven review rounds produced eleven findings, and ten of them were in the
client-side capability store rather than in what this PR is actually about.
The pattern was not bad luck: each fix added a variable to reconcile the
existing ones (`cached` -> `+inFlight` -> `+listeners` -> `+generation`),
which widened the interaction surface that produced the next finding. The
latest pair — a parsed JSON `null` throwing past the invalidation, and a
probe's `finally` clearing a newer probe's promise — are both interactions
between those variables, not defects in the gate.
The store existed to avoid prop-drilling a boolean that `App` already holds
in React state from its boot `/api/version` fetch. That trade bought a
tidier diff and paid for it with cache coherence, staleness, concurrency,
retry and subscription lifecycle — a distributed-state problem invented for
a value that was never distributed.
So the store is gone. `App` passes `appVersionInfo?.capabilities
?.slideRenderer ?? null` down the same path `metricsConsent` already takes,
and the gate reads a prop. No cache, no probe, no retry, no generations, no
subscription: every failure mode those rounds found is now absent rather
than fixed.
The three cases the contract cares about — `false` hides, `true` offers,
`null` (unknown) offers — are three renders in `FileViewer.test.tsx`. The
specs that exercised probe lifecycle went with the probe; they were testing
a mechanism, and the mechanism is what turned out to be wrong.
* fix(web): close the two paths that could still reach the 501
Both blocking notes from review, and both are consequences of how I threaded
the capability rather than of the gate itself.
- The design-system detail route mounts `FileWorkspace` directly, so it never
received the prop and `FileViewer` fell back to its `null` default. Since
unknown deliberately fails open, a headless daemon kept offering PPTX for
`system/artifacts/deck.html`. Threaded from the same App-level value the
project route uses; one source, two callers, rather than a second mechanism
for the second route.
- `appVersionInfo` came only from the one-shot boot fetch, and that helper
yields null on any failed or non-OK response. A daemon merely slow to come
up therefore left the capability unknown for the life of the page — and
unknown fails open, so the gate was defeated by a startup blip. It is now
re-asked once the daemon is reachable, which is the narrowest retry that
fixes it: keyed on the daemon becoming live rather than on a timer, and it
stops as soon as an answer arrives.
* fix(web): make the capability refetch schedule its own retry
Third incarnation of the same mistake, so worth naming: retrying on a
dependency change cannot recover from a failed probe, because a failure
changes nothing — `appVersionInfo` stays null, `daemonLive` was already
true, neither dependency moves, and no further attempt is ever issued. The
failure is sticky, `slideRendererAvailable` stays unknown, and unknown
deliberately fails open, so a headless daemon keeps offering an export it
can only answer with 501.
The retry now schedules its own next attempt instead of waiting to be
re-triggered: bounded with backoff (500ms / 1.5s / 4s / 10s), stopping the
moment an answer arrives. Bounded because an unreachable daemon is its own
visible problem and must not be polled for the life of the page.
* fix(web): cap the capability retry interval instead of the attempt count
Closing the residual case I flagged in my own audit rather than waiting for
it to be found: with a bounded number of attempts, once they were spent
`daemonLive` never flips again and nothing could ever ask, so the capability
stayed unknown for the life of the page — and unknown deliberately fails
open. That is the same hole the retry was added to close, just with a
smaller window (four consecutive failures against a daemon that had already
health-checked as live).
I argued for bounding attempts to avoid polling for the life of the page. On
reflection that traded a rare slow poll for a permanent fail-open hole,
which is the worse side of the trade. The backoff now settles into a steady
30s interval instead of stopping: it only runs while the answer is unknown,
it stops on the first answer, and it leaves no state the page cannot recover
from.
* fix: forbid caching /api/version and stop the two writers clobbering
Both blocking notes, and the first is a consequence of the change itself
that I should have caught: adding a capability turned `/api/version` from a
near-static fact about the build into an answer that differs by which daemon
is behind the URL. It was still served with no `Cache-Control`, so a browser
or proxy could reuse a pre-swap response and a stale `slideRenderer: true`
would be consumed as authoritative — reopening the export the gate exists to
hide. Now `no-store`, with a spec pinning it.
Second, `appVersionInfo` had two unsynchronised writers: the boot pass and
the capability retry. A boot response that resolved `null` after the retry
had obtained an explicit `false` would erase it and reopen the fail-open
entry; a slow but valid boot response could likewise overwrite a newer
answer. The boot pass now never writes `null` and never overwrites an answer
that is already present — a late boot response is by definition the older of
the two.
* refactor(web): ask for the capability at the point of use
Fourteen findings on this PR, and nearly all of them asserted one property:
the capability must not get stale or stuck, because unknown fails open. That
is a global property, and global properties cannot be established by local
patches — which is why each fix produced the next finding rather than
ending the series.
The property is global because the gate was reading a client-side replica of
remote state. A replica needs an initial fetch, a retry, invalidation, write
ordering, cache directives, multi-writer synchronisation, distribution to
consumers, and a refresh when liveness changes. Every one of those was a
finding here.
Deleting `runtime-capabilities.ts` last time did not end it because it moved
the replica into `App` rather than removing it. This removes it: the export
surface asks the daemon when it opens, uses the answer, and discards it with
the menu. If the daemon is swapped, the next open simply asks again.
Gone with it: the prop threaded through four components, the boot-fetch
coupling, the retry with its backoff, and the two-writer synchronisation.
Kept: the daemon-side capability, `Cache-Control: no-store`, the optional
field tolerance, and the export-reason split.
The pending window still fails open, and that is deliberate — but it is one
round trip inside a single menu opening and nothing carries it forward, so
unlike the replica it cannot get stuck.
* fix(web): start each export-menu opening from an unknown capability
The probe state outlives the menu — the component stays mounted — so the
previous opening's answer was still present on reopen. If the daemon was
replaced in between, the gate decided on an answer describing a daemon that
is gone: a stale `true` offers an export the new daemon can only 501.
My previous commit message claimed the answer was "discarded with the menu".
It was not; only the fetch was tied to the opening. Now the state is reset
when the menu opens, so the claim and the code agree.
The regression spec is not in this commit, deliberately. My first attempt
passed with and without the fix — the second response landed too quickly for
the stale window to be observable, so it asserted nothing. The second
attempt, holding that response open, fails in both directions, which means
my assumptions about the close/reopen path are wrong rather than the code
being wrong. Shipping either would be worse than shipping neither, so the
fix goes in now and the spec follows once I understand that path.
* fix(web): revalidate at the menu-to-modal handoff and state the contract
The blocking finding: clicking the PPTX row closes the menu and opens the
modal in one batched update, and the modal's confirm reads the same
capability value — so a `true` fetched at menu-open outlived its surface and
kept the confirm enabled against a daemon that may since only answer 501.
Because the two booleans flip in one batch, an OR of them never changes
across the handoff; the effect now depends on the booleans themselves, so
the handoff resets to unknown and asks again.
The capability contract is now stated once, at the probe:
1. An answer never outlives the surface opening that fetched it — every
opening, including the handoff, starts from unknown and re-asks.
2. Unknown fails open; only a resolved `false` hides the entry.
3. The daemon is the authority of last resort: a swap after the probe
resolves is unclosable client-side, and is caught by the export POST's
501, which now surfaces the no-renderer message.
Tests: the two vacuous positive cases now hold the response open and assert
against the resolved answer, and the reopen regression I owed exists. Its
three earlier failures had one cause worth recording: a stale `true` is
observationally identical to a pending probe — both fail open — so the
true-then-swap direction asserts nothing. The observable stale direction is
a lingering `false` wrongly hiding the entry on reopen, and that is what
the spec pins. Red-check matrix: removing the reset turns the reopen spec
red; reverting the deps to the OR turns the handoff spec red; mapping an
absent field to false turns the absent spec red.
* fix(web): recognise EPIPE as a proxy connection failure; drop a stray file
Both follow-ups from review.
EPIPE belongs in the errno set: the sidecar's own replay check classifies
exactly ECONNRESET and EPIPE as connection-level, and when a replay is not
applicable the EPIPE surfaces as a plain-text 502 containing `write EPIPE` —
which fell through to the generic "export request failed (502)" instead of
the daemon-down message. The classifier also had no direct tests since its
earlier ones were deleted along with the capability store, so it now has a
dedicated spec pinning both halves of its contract: every connection shape
the proxies actually emit is recognised, and JSON 5xx / errno-less text /
other statuses are not. Removing EPIPE turns exactly its case red.
`__diag.test.tsx` should never have existed in the repo: it was a local
scratch file for probing menu toggle mechanics, swept in by a `git add -A`
in the previous commit. It logged instead of asserting and leaked its fetch
stub into the rest of the file. Deleted.
* fix(web): key the capability probe to the export tab, not the popover shell
The popover shell can open on Share and sit there across a daemon swap;
switching to Export then presented the PPTX entry against the answer from
shell-open time. The export surface's opening is the tab becoming visible,
not the shell opening.
This is the terminating fix for the transition family, and the reason can
be stated as an equivalence rather than a hope: the probe's guard is now
the same predicate the consumers render under — the PPTX row mounts inside
`unifiedActionTab === 'export'` within the open popover, the modal confirm
under `pptxExportModalOpen` — and the effect's dependencies are exactly the
free variables of that predicate. A transition into visibility is by
definition a change of one of those inputs, so it necessarily re-runs the
probe. "A transition we forgot" is no longer a findable category: the
guard cannot be coarser than the render condition, because it is the
render condition.
Regression: false from the first daemon, switch to Share, swap, switch
back to Export with the new answer held open — the entry must show
(unknown fails open) and a second probe must have fired. Reverting the
guard to the popover shell turns exactly that spec red.
* fix(web): retry a failed probe within the opening; stop at a fieldless one
A probe that fails (daemon still starting, transient network) left the
whole opening at unknown, and unknown fails open — so a headless runtime
kept the entry clickable for the lifetime of that opening. Retried now with
a short bounded backoff that is cancelled by any surface transition:
nothing crosses surfaces, so the contract's first clause still holds for
the retry itself.
The distinction that keeps this from over-polling: a FAILED probe is worth
asking again, but a probe that SUCCEEDS without the capability field is an
old daemon — asking again cannot teach us more — so it stops at unknown on
the first valid answer. The two cases were previously collapsed into one
`null`, which is exactly why the earlier background-copy design kept
tripping over them; here they are separated at the only point that knows
the difference (did `fetchAppVersionInfo` return a payload or not).
Regression: first probe answers 500, the entry stays visible (unknown
fails open), the in-opening retry fires, and its held `false` then hides
the entry. Reverting to a single-shot probe turns exactly that spec red.
---------
Co-authored-by: Jason <>
|
||
|
|
7a4175c86f |
feat(analytics): report why an installed agent CLI cannot be used (#7285)
* feat(analytics): report why an installed agent CLI cannot be used A user installed DeepSeek Harness correctly and OpenDesign showed nothing. We only learned about it because they said so and sent a diagnostics zip: detection is the one stage that knows an installed CLI is unusable, and it told nobody. There is no way today to ask how many installs are in that state, or which CLI, or why. Report each diagnostic detection produces, once per (agent, reason, version) per page session. Detection runs on cold start and on every rescan, so counting passes would turn one broken install into dozens of events; deduped, the number means "installs affected", which is the denominator a decision here actually needs. Warnings count too — an `untested-version` spike across many installs is how we would learn our supported-version list had fallen behind our own installer, which is precisely what went unnoticed. The resolved path is deliberately absent: an agent binary path contains the OS username. `has_path` carries the only part that matters, since the picker hides a row that has none. Half the registry was invisible to this before it started. `TrackingCliProviderId` covered 14 ids while the daemon ships 28, so DeepSeek Harness and a dozen others collapsed into `other` — a breakdown that cannot name the failing CLI answers nothing. Every shipped agent now has an id, and a test fails when a new runtime def arrives without one. * test(runtimes): assert the shipped agents, not the machine's The tracking-id guard walked AGENT_DEFS, which appends whatever local profiles the developer declared in their own agents.local.json. Those ids are required not to collide with a shipped one, so they correctly collapse to `other` — and turned anyone's personal config into a failure reading `expected [ 'my-custom-agent' ] to deeply equal []`, indistinguishable from the missing-mapper regression the guard exists to catch. Export the shipped list under a name that says which question it answers, and assert on that. The unknown-id case stays: a locally declared agent is not ours to name, and PostHog should never learn an id out of someone's config file. * test(runtimes): pin the local-profile case in the repo, not in my shell The isolation fix was verified by pointing OD_AGENT_PROFILES_CONFIG at a throwaway file by hand, which proves it once and protects nothing. Build the profile inside the test instead, so the machine-dependent failure the guard used to produce cannot come back unnoticed. Registry lists are built at module load, so the profile is written before a fresh dynamic import. The case asserts the profile actually reached AGENT_DEFS before asserting it stayed out of SHIPPED_AGENT_DEFS — without that, it would pass just as happily if the profile never loaded at all. |
||
|
|
35a38abf34 |
fix(daemon): admit one design-system enrichment run per conversation (#7271)
A double-triggered "AI Optimize" affordance created two concurrent design-system enrichment runs 383 ms apart in one conversation and both were billed (2026-07-28 incident). Reject the newcomer on the daemon with HTTP 409 DESIGN_SYSTEM_ENRICHMENT_IN_PROGRESS (details name the owning run), keep ordinary chat turns ungated, and make the web trigger a synchronous single-flight call so the duplicate never leaves the client. |
||
|
|
82c607d70a |
fix(packaged): report the daemon log tail when startup failure is unparseable (#7291)
The largest startup-failure bucket in production (macOS daemon-start, 968 events across 293 people over the 14 days to 2026-08-22) exits code=1 with the native module present and error_code / missing_module / daemon_error all null. The reason was printed in a log tail reportStartupFailure had already read and then discarded, because parseDaemonLogTail only names shapes we already know (ERR_*, a missing module, an XError: headline). When that parse yields nothing, send the tail itself: last 40 lines, scrubbed of POSIX and Windows home dirs before truncation, hard-capped at 4000 chars keeping the end (the tail is chronological, so the fatal line sits nearest the exit). Narrow by design — when the cause is already named the raw tail is bytes and privacy surface for no new information, so a present value also means "this log defeated the parser". |
||
|
|
114e73de16 |
chore: retire Feishu group entries, route every community CTA to Discord (#7295)
Replace every user-facing Feishu group entry (README, landing header and hero community CTA for zh/zh-tw, launch-week page buttons, DeepSeek Harness guide hero CTA in all locales, client nav-rail social slot for zh-CN/zh-TW) with the Discord invite, and drop the now-unused Feishu QR/mark assets, icon, i18n keys, CSS and analytics element value. Claude-Session: https://claude.ai/code/session_017gVe9HtkaMhcPCvm3aX7Eg Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com> |
||
|
|
05f5b33ef5 |
chore(release): bump main to 0.20.3 ahead of stable 0.20.2 (#7253)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com> |
||
|
|
5a79ad8d74 |
feat(web): move social links to the nav rail footer (#7232)
* feat(web): move social links to the nav rail footer The Feishu/Discord, X and mail entries used to live inside the top-right account menu, one popup away from anyone looking for the community. They now sit permanently in the home nav rail's bottom-left footer, which previously rendered as a dead white strip. The community slot follows the client language: Chinese UIs (zh-CN / zh-TW) link to the OpenDesign Feishu group, every other locale keeps Discord. X and mail are locale-independent. * fix(web): keep the signed-out sign-in card off the social row `.entry-local-mode-tip` was pinned to the rail footer's bottom edge (position: absolute; bottom: 3px; z-index: 2). That was harmless while the footer had nothing else in it, but the social row now occupies that strip, so the opaque card painted straight over the Discord/Feishu, X and mail links for exactly the signed-out users it is shown to. Put the card in flow instead (order 2, above the social row's 3) and let the footer size itself around both. |
||
|
|
c9eb23027f |
fix(viewer): preserve live HTML previews (#7156)
* fix(viewer): preserve live HTML previews * fix(viewer): sync saved text to retained preview * fix(viewer): prewarm retained file revisions * fix(viewer): expire saved preview adoption * fix(viewer): repaint retained previews on activation * fix(viewer): reconcile retained style history * fix(viewer): renew preview asset scopes without reloads * test(viewer): cover scope renewal in team previews * test(exports): expect managed preview bases |
||
|
|
8b18210103 |
chore(release): bump main to 0.20.2 ahead of stable 0.20.1 (#7201)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com> |
||
|
|
a81d114b1d |
fix(daemon): anchor run phases on first model event (#7155)
* fix(daemon): anchor run phases on first model event Phase boundaries anchored on `firstTokenAt`, the first *text* token. A run that opens with a tool call and stays silent until the end therefore had its entire tool loop billed to `runtime_init`, leaving the generation phase holding only the closing sentence. Tool-first is the dominant opening shape for the OpenCode/OpenAI families, so `bottleneck_phase` reported startup for runs that were working the whole time. The decomposition was also not a partition: on the new fixture's 24.2s run the old boundaries sum to 37.8s, because the tool loop is counted inside both `runtime_init` and `tool_execution`. Anchor phases on `telemetry.firstModelEventAt ?? telemetry.firstTokenAt` -- tracer marks only. Deliberately not the nearby `firstModelEventAt` composite, whose `firstToolUseAt` fallback is scanned off event records on the daemon's clock and is never reset per retry attempt. Every published metric keeps its current value; a spec pins the four first-token metrics so a future edit cannot silently redefine them. Adds `runtime_init_to_first_model_event_ms`, `model_active_duration_ms`, and `phase_schema_version` so old and new rows can be filtered apart. The `stream_output` phase now subtracts tool time to stay mutually exclusive with `tool_execution`. Trace side gains a `model-active` phase-diagnostics entry rather than a second span, to avoid an extra observation row per run. * fix(daemon): measure the tool phase as wall-clock occupancy Review found two ways the tool phase could exceed the window it sits inside, both because it was built from `tool_duration_ms` -- the sum of paired tool_use -> tool_result spans. That sum is the right published answer to "how much tool work happened" but it is not elapsed time, so it cannot partition a window: - Parallel tool calls sum to more than the clock they occupy. Two overlapping 4s and 5s tools inside a 6s active window reported 9s of tool execution. - `run.events` is never cleared between retry attempts, while the phase anchor comes from the current attempt's lifecycle marks. A retried run charged the previous attempt's tool spans to this attempt's window. - ACP producers supply their own `startedAt`, which can predate the anchor when their clock differs from ours. In each case the inflated phase won `bottleneck_phase` with a duration longer than the run had been active, and the previous `Math.max(0, ...)` floor on the residual hid the symptom rather than fixing the partition. Derive both `stream_output` and `tool_execution` from the union of tool intervals clipped to [phaseAnchorAt, runEndAt]. The two are then halves of the model-active window by construction. `tool_duration_ms` keeps summing paired spans and is unchanged. Three fixtures cover the parallel, retry, and producer-clock cases; all three reported `tool_execution` as the bottleneck before this change. * fix(daemon): keep the phase anchor on real model output Second review round found three more ways the new boundaries could publish wrong attribution. A daemon-persisted artifact was being marked as the first model event. Agent `artifact` events are emitted from exactly one place -- the daemon's close-time persistence of plain-stream stdout -- and never by a runtime relaying model output. On a plain-stream run that stamps first-token from its first buffered stdout chunk, that late mark won the anchor and reported a 1s active window and a 16s runtime init for a run that had been streaming since second 5. That is the opposite of this PR's intent, and it falsified the claim that the plain/BYOK family keeps its current behaviour through the first-token fallback: the fallback was never reached, because the field was set -- just set by the daemon, at the end of the run. Fixed at the source (the tracer no longer treats an artifact event as a model event, and neither does the scan-based fallback that feeds `first_model_event_type`), and again as an invariant: the anchor is the EARLIEST of `firstModelEventAt` and `firstTokenAt`, because the model cannot have started responding after it emitted its first token. A late producer mark can no longer drag the boundaries to the end of the run. `phase_timing_status` still required `firstTokenAt`, so a fully instrumented tool-only turn -- every boundary the phases are measured from present, no text ever emitted -- was stamped `partial` and dropped by dashboards filtering on complete timings. It now follows the anchor the phases actually use. The first-token fields are unchanged. Tool occupancy ignored a tool still outstanding at run end, handing the whole active window to `stream_output` while `last_observed_phase` said `tool_execution`. Outstanding spans are now closed at run end for phase purposes; `tool_duration_ms` still sums completed pairs only. Five fixtures, red before the change, including one in the tracer suite pinning that an artifact event is not a model event. * fix(daemon): scope outstanding tool spans to the current attempt Closing every still-open tool span at run end interacted badly with the retry path. `run.events` survives a retry while lifecycle telemetry does not, so an attempt whose child was killed mid-tool leaves an unpaired tool_use behind. Closing that at run end stretched it across the retry boundary: a tool_use at 3s, an attempt-2 anchor at 20s, and a run ending at 25s clipped to a 5s `tool_execution` phase for an attempt that never called a tool. Gate the close on the daemon-clock timestamp the tool_use was OBSERVED at, which shares a clock with our own marks, rather than on its start. A producer-supplied `startedAt` can legitimately predate the anchor on a tool that is genuinely running, and clipping already handles that case -- gating on the start would discard real occupancy instead. Two fixtures: the killed-prior-attempt case (red before this change), and a guard that an outstanding tool with a skewed producer start is still counted, so the gate cannot be tightened into discarding live work. * fix(daemon): give the summarizer a real attempt boundary Both remaining review findings came from the same gap: the outstanding-tool gate used `phaseAnchorAt` as a stand-in for "when this attempt started", and those are different instants. `phaseAnchorAt` falls back to `firstTokenAt` when no model-event mark exists. On a legacy or recovery tool-first run -- tool_use at 4s, first text at 10s, run ending at 30s -- the gate then discarded a tool this attempt had issued and never finished, reported zero tool occupancy, and handed the whole active window to `stream_output`. That is the exact shape this change exists to measure. The gate now uses the attempt boundary; `phaseAnchorAt` is only used for clipping. For that boundary to exist, two producers had to stop destroying it: - The retry path called `resetForAttempt`, which stamps `attemptStartedAt` and `attemptIndex`, and then replaced `run.analyticsTelemetry` wholesale, so both were dropped every time. It now spreads, keeping the `run.createdAt` fallback for the logical run start. This also means `attempt_index` and `attempt_duration_ms` -- already-published fields -- start reporting across retries instead of silently vanishing. - `prepareRestart` reused the run for a recharge resume and cleared its terminal state but left `analyticsTelemetry` untouched, so a resumed attempt inherited attempt 1's `firstModelEventAt`/`firstTokenAt`. Every boundary then measured from before the recharge pause, putting the wait inside the new attempt's model-active window. It now resets the marks, preserving only `startRequestedAt` so queue time is still measured from the user's request. Two fixtures, red before the change: a no-model-event outstanding tool, and a recharge resume asserting the stale marks are gone while the logical run start survives. * fix(daemon): anchor ACP tool-first turns at the tool's start Three findings from the fifth review round. ACP accumulates `tool_call` frames and emits the canonical `tool_use` only once the call reaches terminal status, carrying `startedAt` set to the first frame's arrival. The universal `send()` path stamped the anchor with `Date.now()` when that delayed event arrived, so a tool-only ACP turn put its anchor at tool COMPLETION -- measuring the whole tool loop as runtime init. That is precisely the case this PR exists to fix, still fully present for the ACP family. The stream-event markers now carry the producer's `startedAt` (daemon clock, same domain as our own marks) and the tracer stamps that instead of arrival time. Tool ids are reused across a retry: sequential ids like `call_0` restart in the retry's fresh session, and `openTools` is keyed by id alone. A killed attempt's still-open `call_0` therefore merged with the retry's genuinely new `call_0`, pairing attempt one's opening with attempt two's close and reporting a 19s tool that never ran -- inflating `tool_duration_ms` as well as phase occupancy. A still-open entry observed before the current attempt began is now replaced rather than merged. The Langfuse `model-active` diagnostic used different boundaries from the metric it exists to be compared against: it preferred `firstModelEventAt` over the earliest mark, and ended at `finalizeStartAt` rather than run end. On a text-first stream those differ, so the same run reported two windows. It now mirrors the canonical rules; the shared "earliest finite mark" logic is a named helper. * fix(daemon): stop reporting phase attribution we cannot support Two findings from the sixth review round. The first-model-event mark was first-write-wins, which is wrong for ACP. It holds each toolCallId until terminal status, so two parallel calls can complete in the opposite order they started: a call that began at 200ms can be the first one we hear about, and the call that began at 100ms is then discarded. Every phase boundary shifted later by the difference. The mark now keeps the earliest finite timestamp with its type. Marks without a producer timestamp default to arrival, which is monotonic, so this only ever moves the anchor earlier. Phase occupancy is reconstructed from tool frames in `run.events`, which is a bounded ring buffer. A run that opens with a 20s tool and then emits enough output to evict it reconstructs zero occupancy, hands the whole active window to `stream_output`, and -- because lifecycle marks live on the run and survive eviction -- still stamped the bundle `complete`. The summarizer now reads the same truncation signal the execution diagnostics in `runtimes/runs.ts` already use (first surviving event id is no longer 1), downgrades a `complete` phase bundle to `partial`, and withholds `bottleneck_phase` entirely rather than naming whichever phase the buffer happened to keep. Timings that come from lifecycle marks are unaffected and keep reporting. Five fixtures. Two of them were green before their change and exist to pin the boundary from the other side: a later event must not move the anchor forward, and a truncated run must still report its mark-derived timings. * fix(daemon): gate every tool interval on the opening attempt The pairing path never checked which attempt opened the tool. A `tool_result` that arrives after a retry has started -- a buffered stdout flush racing the abort, which the ACP session code already documents as a real sequence -- was paired with its attempt-1 opener into a single interval spanning the retry boundary. Clipping then charged the new attempt 8.9s of tool execution inside a 9s window for an attempt that issued no tool at all. Earlier rounds gated the two other ways a stale entry reaches an interval (id reuse on open, close at run end) but did so at each site, which is why this third path was missed. The rule is now stated once as `openedInCurrentAttempt` and every append to `toolIntervals` goes through it, so a fourth path cannot quietly skip the check. `tool_duration_ms` deliberately does not pass through the gate and keeps its whole-run paired-sum definition; the fixture asserts that value explicitly so the distinction stays deliberate. * fix(daemon): stop moving a published metric, and abstain when tools are unattributable Two findings from the seventh review round. Passing the producer's start into `markFirstModelEvent` moved `time_to_first_model_event_ms`, which this PR promised not to touch. That field means "when we first saw a model event"; for an ACP tool starting at 4s and emitted at terminal 20s it began reporting 4s instead of 20s. My own invariance fixture did not catch it because it only pins the four first-token metrics. The mark is now split. `firstModelEventAt` goes back to first-write-wins on arrival and keeps feeding the published metric unchanged. A new `firstModelResponseAt` holds the earliest producer start (clamped to arrival, so a fast producer clock cannot claim the model responded in the future) and is what phase boundaries anchor on. The new phase field is renamed to `runtime_init_to_first_model_response_ms` to match what it measures; it is introduced by this PR and has never shipped. Second, a tool id shared by two attempts cannot be attributed. When a retry reuses an id whose previous opener is still outstanding, a later `tool_result` could belong to either, and the two readings differ by seconds of occupancy. Earlier rounds tried to pick a winner; nothing in the event log supports one. The summarizer now records that an opener was displaced, marks the ledger ambiguous when a result arrives for such an id, and withholds `bottleneck_phase` and a `complete` phase status -- the same treatment truncation already gets, and for the same reason. Timings derived from lifecycle marks keep reporting. Fixing this properly rather than abstaining needs an attempt or generation id carried on the tool events themselves, which is a producer and event-schema change; noted in the review thread rather than smuggled in here. * fix(daemon): share one phase-anchor rule between both sinks The Langfuse `model-active` diagnostic listed its own marks, so adding `firstModelResponseAt` to the analytics anchor last commit left the mirror behind again. On an ACP tool-first run with no text token it reported 10s against the analytics value of 26s for the same run -- on precisely the runtime shape this change targets, and in the one entry whose whole purpose is to be compared across sinks. This is the second time the two drifted, so the fix is structural rather than another manual sync: the rule is exported as `phaseAnchorFromMarks` and both call sites use it. Re-listing marks per sink is what allowed the drift, and a third mark would have caused it again. Validity rules are now identical by construction too, which resolves the separate half of the finding. |
||
|
|
3662637d38 |
fix(media): surface concrete image-generation failure reasons (#7118)
* fix(media): surface concrete generation failures * fix(media): preserve error code underscores in chat * test(contracts): expect concrete media failures * fix(media): flush structured CLI failures |
||
|
|
a50a71b656 |
fix(viewer): stabilize retained HTML previews (#7128)
* fix(viewer): stabilize retained HTML previews
* fix(viewer): finish motion on direct slide seeks
* Revert "fix(viewer): finish motion on direct slide seeks"
This reverts commit
|
||
|
|
16fad1c4d8 |
revert(design-systems): remove structured runtime workflow (#7110)
* revert: remove structured design system runtime * fix: preserve authority boundaries during revert |
||
|
|
0d53c96beb |
fix daemon HyperFrames scaffold and runtime (#7030)
* fix daemon HyperFrames scaffold and runtime * fix CI snapshots and Nix hashes * fix(pack): preserve HyperFrames sharp runtime Copy the target-native sharp closure into final standalone payloads and execute the packaged HyperFrames CLI during afterPack. Generated-By: looper 0.11.8 (runner=fixer, agent=codex) * test(pack): avoid chmod on linked Node fixture Only chmod the copied fallback so a successful hard link does not try to change the CI-owned Node inode. Generated-By: looper 0.11.8 (runner=fixer, agent=codex) |
||
|
|
879236e614 |
fix(workspace): restore last selection after restart (#7067)
* fix(workspace): restore the last selection on restart * test(workspace): cover persisted restart default * fix(workspace): reuse directory authority for context reads * test(workspace): align unknown seat invite behavior * test(workspace): cover unknown seat invite fallback * fix(workspace): preserve unknown invite capacity * fix(workspace): commit selection after persistence * fix(workspace): preserve unknown seat analytics * fix(workspace): guard stale selection cleanup * fix(workspace): make stale recovery atomic |
||
|
|
aa5bb5d67a |
fix: persist public file shares across daemon restarts (#7002)
* fix: persist public file shares across restarts * fix(daemon): redact unpersisted public snapshots Generated-By: looper 0.11.8 (runner=fixer, agent=codex) * fix(web): expose public share recovery action Generated-By: looper 0.11.8 (runner=fixer, agent=codex) |
||
|
|
42faa84d0c |
fix(web): recover aborted srcdoc previews (#7055)
* fix(web): recover aborted srcdoc previews * fix(web): parse only top-level stylesheet imports * fix(web): preserve font import conditions * fix(web): latch srcdoc parser completion * fix(web): defer recovery while srcdoc parses * fix(web): confirm blank previews before reporting * test(e2e): cover preview white-screen monitoring * test(e2e): select the requested preview file |
||
|
|
67576cb0b6 |
fix(collab): keep workspace projects usable during outages and first open (#7027)
* fix(daemon): decouple local data plane from workspace authority * test(e2e): align workspace authority contracts * fix(daemon): preserve read-only team mirrors offline * test(daemon): preserve retryable result literal * fix(web): keep projects usable during workspace outages * test(web): keep collaboration mounted during outages * test(daemon): keep viewer comments local during outages * fix(collab): open team projects during materialization * fix(collab): keep project placeholders fail-closed |
||
|
|
ab77eafeb1 |
refactor(release): retire dedicated betas and preview lanes (#7058)
* refactor(release): retire dedicated betas and preview lanes * fix(updater): consume dynamic release channels * fix(e2e): narrow counted release versions structurally |
||
|
|
833e1a20d1 |
fix(daemon): carry Vela image safety refusals through to the client (#6993)
* fix(daemon): carry Vela image safety refusals through to the client A refused image request and a provider outage arrived at the user as the same sentence. `vela image gen --json` prints the complete task object, including its structured `error` block, and only then exits non-zero — but `runVelaCommand` rejected with the bare exec error and dropped that stdout, so the verdict was already gone before any caller could read it. Attach the failed command's stdout to the rejection, decode it into a typed `VelaMediaError` carrying `code`, `subject` and `retryable`, and let those fields reach the persisted task snapshot the way `code` already did. The prompt contract gains a third outcome so the assistant stops calling a refusal an outage; every other failure keeps its existing wording. Classification reads the structured `code` only. An unrecognised code, a non-JSON stdout, or safety wording in a message all keep their original error, because mislabelling an outage as a policy refusal sends a user off to rewrite a prompt that was never the problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t * test(daemon): prove a refusal survives the process boundary and reaches the agent The existing suite builds its rejections by hand, which exercises the decoder but assumes the thing that was actually broken: that a non-zero exit still carries the CLI's stdout back. These two suites remove that assumption. The first spawns a real stand-in `vela` that reproduces the CLI's failure protocol -- task JSON on stdout, human line on stderr, exit 1 -- and asserts renderVelaImage still yields the structured verdict with its subject. A crash with no task output stays an ordinary failure, so a verdict is never invented from nowhere. The second spawns the real `od media wait`, because its stdout is the agent's entire view of the outcome. If the verdict were flattened there, no prompt wording and no interface could recover it -- the agent would be choosing its reply from a fact it was never told. It also pins that an unrecognised field passes through, so a server-side addition does not need a CLI release, and that an ordinary failure does not arrive wearing a refusal's clothes. What these do not prove, and no test here can: that the assistant then picks the refusal sentence. That is prompt adherence, not plumbing. The interface still has no deterministic surface of its own -- see the PR's adjacent issues. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t * fix(daemon): apply the refusal wording to the prompt the daemon renders Two review findings, both real, both mine. The refusal branch went into packages/contracts, but composeSystemPrompt imports MEDIA_USER_REPLY_CONTRACT from the daemon's own copy in apps/daemon/src/prompts/. Before this change the two were byte-identical, and nothing imports the contracts copy at all -- so the edit looked like a behaviour change and was none. The primary agent flow kept describing a content-safety refusal as a temporary outage, which is exactly the symptom the PR set out to remove. The duplication is the actual defect, so a guard test now pins the two copies equal and fails if they drift again. Deleting one of them is the better fix and is left as its own change; a mirror test is the cheap thing that stops a repeat today. Second: normalizeError reconstructed only message, status and code, so subject and retryable survived until the first daemon restart or cache rehydration and then vanished from /api/media/tasks/:id/wait. The read path now validates both against the same vocabulary the write path uses, and an absent retryable stays absent -- "the producer did not say" and "retrying is pointless" are different answers to a client. The mocked wait test could not have caught this: it never touches SQLite. The round-trip cases added here do, covering insert, update, every subject, and rejection of an unknown subject or a non-boolean retryable arriving from a row this build did not write. Snapshot update is totalChars only -- the section gating matrix is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRWE1BhCCVmRLLpKz5q61t * fix(daemon): surface provider image errors --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1a0b60c6c |
chore: unify product name display to OpenDesign repo-wide (#6998)
* chore: unify product name display to OpenDesign repo-wide Rename the user-facing product name from "Open Design" to "OpenDesign" across documentation, web/desktop UI strings, i18n locales, prompts, plugin/skill/design-system content, changelogs, and specs, matching the landing-page rename already in flight. Deliberately excluded so installed-app identity stays untouched: - packages/release PRODUCT_NAME and everything derived from it (app bundle names, installer/registry identity, updater feeds) - tools/pack, tools/release, apps/packaged, landing page (PR #6983) - mocks/ recorded traces and LICENSE - test fixtures and comments that encode real on-disk install paths (Open Design.app, Program Files\Open Design, AppData paths) Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host test runs over the renamed strings. * fix(web): keep two-letter author monogram for CamelCase brand names authorInitials split words on whitespace only, so the renamed single-token author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper CamelCase boundaries as word breaks so the avatar fallback stays "OD". * fix(release): restore spaced Windows source artifact names in win.ps1 tools/pack still derives builder outputs from the unchanged PRODUCT_NAME ("Open Design"), so the asset script must look for the spaced filenames. Matches the mac/mac-intel/linux asset scripts, which were already kept on the original names. --------- Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com> |
||
|
|
1a7a23d4cc |
fix(community): make deck remix previews reliable (#6996)
* fix(community): make deck remix previews reliable * fix(community): support nested deck scrollers * fix(web): isolate nested deck fallback scrolling * fix(web): infer deck scroll axis from slide geometry |
||
|
|
dfc63955b8 |
feat(analytics): report files_written_count on run_finished (#6977)
artifact_count deliberately counts only renderable outputs (HTML + image/video/audio), so a run whose deliverable was PROMPTS.md or report.docx reported artifact_count: 0 and was indistinguishable from a pure chat turn in the artifact funnel. A 2026-08 sample of "artifact_count = 0" successful runs on 0.18–0.19 found ~1/3 had actually written such non-renderable files. files_written_count counts distinct files of ANY type the run created or modified. Primary source is the filesystem snapshot diff (the snapshot walk now stamps non-tracked files with a size+mtime-only fingerprint under a separate 5000-file cap, so code-heavy imported repos cannot starve artifact fingerprinting and no extra content hashing happens at run finish). When no baseline exists the tool-stream fallback (countWrittenFiles) and the truncation-proof side-effect ledger (writtenFilePaths) report it with the usual per-agent caveats. artifact_count semantics are unchanged. Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com> |
||
|
|
db6db34859 |
add od lint — CLI half of the artifact lint endpoint (#6959)
* feat(cli): od lint — CLI half of the live artifact linter POST /api/artifacts/lint has been reachable by the web layer since the linter landed, but had no SUBCOMMAND_MAP entry, leaving it invisible to external agents in violation of the dual-track rule. Adds the shared DTO in contracts and a runLint mirroring runExport's flag shape (stdin via '-', --json envelope, --fail-on threshold, exit 1 on findings at/above). * address review: contracts as producer authority, cli-lint entrypoint spec, root-help discoverability - lint-artifact.ts now imports ArtifactLintFinding/ArtifactLintSeverity from @open-design/contracts and aliases the legacy exported names to them, so lintArtifact() is typed by the shared contract and a producer change that breaks the wire shape fails compilation instead of drifting behind the CLI's consumer-side assertion. The lint route types its request/response against LintArtifactRequest/Response. - New tests/cli-lint.test.ts on the cli-templates/cli-deploy stub-server harness: file + stdin input, all four --fail-on thresholds, clean and finding responses, human/--json/--agent-message output, usage errors (unknown flag, bad threshold, missing/empty file), HTTP failure (structured exit 64), unreachable daemon (exit 3), and root-help presence. 12 specs. - printRootHelp() lists od lint beside od export with its headless/ threshold purpose. --------- Co-authored-by: Cruz Morales <273886418+DojoGenesis@users.noreply.github.com> |
||
|
|
4034380285 |
fix(security): reject loopback for BYOK asset download URLs (#5478) (#6072)
* fix(security): reject loopback for BYOK asset download URLs (#5478) The assertExternalAssetUrl guard previously routed through validateBaseUrlResolved without forbidLoopback, so asset URLs pointing at 127.0.0.1, localhost, or ::1 passed validation and were fetched by the daemon. This allowed a compromised or misconfigured upstream gateway to exfiltrate data from internal services via SSRF. Changes: - Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts) - validateBaseUrl rejects loopback hosts when forbidLoopback is set - validateBaseUrlResolved skips the loopback early-return when forbidLoopback - validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved loopback addresses are also rejected (not just literal loopback hosts) - assertExternalAssetUrl now passes forbidLoopback: true - User-configured provider endpoints (validateUserProviderBaseUrl) are unaffected — they still allow loopback for local gateways - Adds regression tests covering literal loopback rejection, DNS-resolved loopback rejection (IPv4, IPv6, mixed results), and provider endpoint exemption Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the separate BYOK asset-download path (assertExternalAssetUrl) that retains the loopback carve-out. Closes #5478 * fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478) The validation step (validateBaseUrlResolved) and the fetch step (assertAndFetchExternalAsset) previously performed independent DNS lookups. A DNS-rebinding domain could return a public IP for validation and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard. Fix: - validateBaseUrlResolved now attaches the DNS-resolved addresses that passed validation to its return value (BaseUrlValidationResult.resolvedAddresses) - assertExternalAssetUrl passes these addresses through on the ok branch - assertAndFetchExternalAsset creates an Undici Agent with a custom connect.lookup that returns only the validated addresses, pinning the TCP connection regardless of any DNS rebind between validation and fetch - 3 call sites in byok-tools.ts that used validate-then-fetch separately now route through assertAndFetchExternalAsset for a single unified path - 4 regression tests covering: single validated address, DNS-rebind mock (public→loopback), round-robin DNS, and IP-literal short-circuit * fix(security): fail-closed on DNS error + connection-time validating lookup (#5478) Addresses mrcfps third review: the fallback to unpinned fetch when validation did not attach resolved addresses left a DNS-rebinding hole where an attacker could make the validation lookup throw (ENOTFOUND / SERVFAIL) and then answer loopback for the fetch lookup. Changes: 1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup failures now return a forbidden result instead of the sync success. This prevents the fail-then-rebind vector. 2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback. Non-IP-literal hostnames without resolvedAddresses now throw — the fetch never happens. IP literals are safe because they were validated synchronously and have no hostname to rebind. 3. Replaced the per-request pinned Agent with a long-lived Undici Agent (assetDispatcher) whose connection-time lookup (createAssetValidatingLookup) rejects any non-public address. This is defense-in-depth on top of the pre-validation, following the same pattern as brands/safe-fetch.ts and plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the keep-alive socket leak of per-request Agents. 4. createAssetValidatingLookup is exported for unit testing. 5. Expanded test coverage: - DNS failure → fail-closed (forbidLoopback true and false) - createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP; allows public - assertAndFetchExternalAsset: throws on blocked URLs and internal IPs - Updated existing public-CDN tests to use IP literals (fail-closed behavior now correctly rejects hostnames when DNS is unavailable) * fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478) Addresses mrcfps 4th review: daemon CI was red because fail-closed behavior caused hostname-based test fixtures to NXDOMAIN, and undiciFetch bypassed vi.stubGlobal('fetch') stubs. Changes: 1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept an optional injectable `lookup` (DnsLookupFn) and `fetchImpl` parameter. Production callers pass neither, so default DNS and globalThis.fetch are used. Tests can inject mock lookups/fetch. 2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch) so vi.stubGlobal('fetch', ...) stubs in existing test suites (byok-tools, aihubmix, proxy-routes, senseaudio) still intercept. 3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34 (public IP literal) in 6 test files (41 substitutions) so IP literals skip DNS resolution and don't trigger fail-closed on NXDOMAIN .test hostnames. 4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset with injected lookup + fetch: - Validation lookup public → fetch called with redirect:error - Validation lookup throws → assertAndFetchExternalAsset rejects without invoking fetch (fail-then-rebind vector closed) - IP literal → skips DNS, calls fetch directly 5. assetDispatcher + createAssetValidatingLookup kept for production connection-time defense-in-depth (exported, available for future wiring to a production fetch dispatcher). Total: 31 tests in asset-ssrf-loopback.test.ts, all passing. * fix(security): attach validating dispatcher to asset fetch (#5478) mrcfps 5th review: assetDispatcher was built but never attached to the fetch call. The validating lookup only runs at connect time through the dispatcher, so without it the fetch re-resolves the attacker-controlled hostname and a public-then-loopback rebind still reaches the daemon. Fix: attach assetDispatcher to the RequestInit via the dispatcher key (same pattern as plugins/plugin-asset-cache.ts safeExternalFetch). Production globalThis.fetch (Node/undici) uses it to refuse connect-time non-public addresses. Test stubs still see redirect:'error' and can ignore dispatcher. Also fixed the misnamed TOCTOU test to assert dispatcher is present on the captured RequestInit, and removed unused undiciFetch import. * fix(tests): update dispatcher assertions for assertAndFetchExternalAsset assertAndFetchExternalAsset now attaches a validating assetDispatcher to the fetch RequestInit (issue #5478). Tests that blanket-checked init?.dispatcher === testMock fail because asset-download calls correctly carry the validating dispatcher instead. Fix: conditional assertion — when init.redirect === 'error' (asset download path), check dispatcher exists rather than matching the test mock. API calls still verify the caller's dispatcher is forwarded unchanged. * fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override Address mrcfps's non-blocking review items: 1. Replace the redirect==='error' heuristic with URL-keyed assertions. The heuristic silently dropped the submit dispatcher identity check on the AIHubMix video path (its submit hop also sets redirect:'error'). Submit/poll hops now assert toBe(callerDispatcher); the asset hop asserts toBe(getAssetValidatingDispatcher()) — exported from connectionTest.ts so the exact validating instance is compared. 2. Document the intentional dispatcher override on the BYOKToolContext.requestInit JSDoc: asset downloads through assertAndFetchExternalAsset intentionally never ride the turn proxy dispatcher; submit/poll hops keep it. --------- Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com> |
||
|
|
9475b9817f |
fix(web): make deck thumbnails drive deck-stage previews (#6950)
* fix(web): sync deck-stage thumbnail navigation * fix(web): keep prototype annotations out of deck navigation * fix(web): distinguish legacy deck slides from annotations |
||
|
|
eee03776fb |
fix(daemon): make streamed message persistence append-only (#6952)
* fix(daemon): make streamed message persistence append-only * fix(daemon): materialize event batches in transcript export |
||
|
|
84c8f0ee0f |
chore(release): bump main to 0.19.2 ahead of stable 0.19.1 (#6902)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com> |
||
|
|
696f39263c | fix(web): show creator identity on recent projects (#6857) | ||
|
|
bdad26cfcb |
fix(platform): search Nix profile bin dirs for CLI discovery (#6121) (#6885)
* fix(platform): search Nix profile bin dirs for CLI discovery (#6121) The packaged desktop app does not discover agent CLIs installed through nix-darwin environment.systemPackages because launchd provides a minimal PATH that excludes Nix profile directories. Add three standard Nix bin paths to wellKnownUserToolchainBins: - /run/current-system/sw/bin (system-wide NixOS/nix-darwin profile) - /nix/var/nix/profiles/default/bin (default system profile) - ~/.nix-profile/bin (user's active Nix profile) Since this list is the single source of truth consumed by both the daemon runtime resolver and the packaged sidecar PATH builder, all Nix-installed agent CLIs are now detected after Rescan. Closes #6121 * fix: gate system-wide Nix paths behind includeSystemBins (review) Move /run/current-system/sw/bin and /nix/var/nix/profiles/default/bin behind the includeSystemBins flag so sandboxed runs with OD_AGENT_HOME do not reach host-installed Nix tools. Keep ~/.nix-profile/bin (home- relative) always included. Add test verifying system Nix paths appear only when includeSystemBins is true and are omitted when false. --------- Co-authored-by: CVE-Hunter-Leo <CVE-Hunter-Leo@users.noreply.github.com> |
||
|
|
7a37c1462d |
feat(export): add reliable standalone HTML export (#6855)
* feat(export): add standalone HTML export Bundle project-local HTML, CSS, JavaScript, images, fonts, workers, and iframe dependencies into one offline-safe file. Expose the same contract through the web download flow and od CLI, with structured failures and bounded output assembly. * fix(daemon): honor standalone source URL semantics Resolve document assets against local base href values, reject browser-invalid TypeScript and JSX sources with a structured 422, and count unquoted first-level resources before graph traversal. Validated with daemon tests, workspace typecheck, and guard. * fix(daemon): handle standalone data scripts and CSS URL tokens Preserve non-JavaScript script payloads as data blocks and scan CSS values without treating strings or comments as project dependencies. Validated with focused and full daemon tests, workspace typecheck, and guard. * fix(daemon): classify standalone export resources correctly Preserve non-HTML iframe MIME types, inline only resource-bearing link relations, and propagate unexpected Vite dist read failures instead of silently falling back. Validated with focused export tests, the full daemon suite, guard, and workspace typecheck. * fix(export): preserve version and stylesheet semantics Hide standalone HTML export for historical versions until dependency snapshots are available, avoiding an action that always fails. Keep disabled and alternate stylesheets as embedded link elements so standalone export preserves activation semantics. Validated with focused red/green coverage, the full web suite, pnpm guard, and pnpm typecheck. --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> |
||
|
|
f9c07517b7 |
fix: make DeepSeek Harness cancellation authoritative (#6880)
* fix deepseek harness cancellation lifecycle * fix DeepSeek canceled status rendering * fix DeepSeek cancellation handshake race * feat support reasoning selection in composer * test DeepSeek pre-execute cancellation protocol * test reasoning selector visual surface |
||
|
|
eea8a8522d |
fix(collab): bound workspace refresh storms (#6871)
* fix(collab): bound workspace refresh storms * fix(collab): push workspace directory freshness * test(collab): follow identity-aware hub callbacks * fix(collab): reset authority on account changes * fix(collab): fence workspace identity transitions * test(collab): guard full identity reset |
||
|
|
f9fe73c836 |
feat: add DeepSeek Harness support (#6874)
* feat(daemon): scaffold DeepSeek Harness profile protocol * feat(daemon): integrate DeepSeek Harness profile runtime * fix(daemon): preserve headless prompts on native resume * refactor(daemon): model Harness profile resume explicitly * fix(daemon): resume Harness sessions after cancel * test(daemon): verify DeepSeek Harness Windows carrier * test(daemon): reap DeepSeek Harness Windows process tree * feat(agents): surface DeepSeek Harness models and branding * fix(daemon): surface Harness credential guidance * feat: add bundled DeepSeek Harness setup * fix: link DeepSeek Harness installer * fix: link official Harness docs * fix(daemon): distinguish Harness companion install * test(pack): verify bundled Harness resources on Windows * fix: harden Harness profile turn lifecycle Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(dsh-runtime): stop cancelled restore follow-ups Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(daemon): cancel Harness startup before execute Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(pack): build DeepSeek runtime on cold cache --------- Co-authored-by: lefarcen <935902669@qq.com> Co-authored-by: Looper <looper@noreply.github.com> |
||
|
|
9a402b2e4c |
feat(campaign): extend the DeepSeek V4 campaign to Pro + Flash (#6861)
* feat(campaign): extend the DeepSeek V4 campaign to Pro + Flash The V4 Flash free week ends 8/13 20:00; a two-week window offering BOTH V4 Pro and V4 Flash opens at that same instant and runs to 8/27 20:00. This grows the campaign already in production rather than landing a second stack beside it. That implementation ran the free week for a week: its audience resolution (wallet balance is not a subscription), i18n wiring, localized countdown, frequency control and attribution are all already exercised, and it carries a product decision this change must not undo -- the URL review backdoors (campaign / audience / usage overrides) were deliberately removed, and reviewing the campaign is done by overriding `window.startAt`, never through query parameters. Almost all of it is the campaign module growing a second model: `isDeepSeekV4FlashCampaignModel` now matches either id, which is why the model switcher badges both without its rendering changing at all, and the modal's benefit line is an i18n key, so naming both models was copy. `「立即使用」` now switches a paid user to Pro, the headline benefit. Two things needed real edits. Model-benefit impressions fired once with a hardcoded Flash id; product compares the two models' reach separately, so they now fire once per campaign model actually on screen, each with its own `model_id`. And `campaign_id` / `element` move to the spec's `deepseek_v4_pro` naming, with `deepseek_v4_flash` kept declared so the finished campaign's warehouse rows stay valid. Copy is rewritten across all 19 locales -- dates, the two-week framing, both model names, and the peak-hours queue caveat product added. Dates follow each locale's existing format rather than one imposed format, so only the day numbers move. Frequency control is keyed on the campaign id, and the id is new, so a user who dismissed the free week is still owed this campaign's single showing. That was an invisible dependency on remembering to change the id; it now has a test. Module and i18n key names keep their `…V4Flash` spelling: renaming 28 keys across 19 locales mid-campaign is risk without user-visible benefit, and belongs in a cleanup pass once the campaign is off. * fix(web): read a rolling model window as a wait, not a failure vela's hosted gateway rejects a request that exhausted its rolling per-model window with `model_limit_exceeded`, phrased for API callers as "You have reached the 5-hour usage limit for X. Try again after <instant>. This request was not charged to Wallet Credits." Two things went wrong with that sentence on the way to a user. The daemon's `isHardQuotaText` matches its "usage limit" wording, so a window that resets on its own was classified as `hard_quota` — an exhausted quota, marked non-retryable. Nothing was charged and nothing needs topping up, so that both misnames the cause and counts a self- healing wait against reliability as a real failure. The web runtime then had nowhere to put it. The AMR branch of `resolveRunFailureUi` ends in a catch-all returning the generic "task failed" title with a null message key, which renders the raw upstream string — so a localized chat showed an English sentence under a wrong heading. Home is worse: its send path fails before a run exists, never reaches that mapping at all, and prints `err.message` verbatim. Classify the window separately, ahead of the hard-quota reading, and give both surfaces the same localized copy: high demand, when it reopens, and the reassurance that nothing was charged. The instant is rendered in the reader's locale — the gateway reports UTC. Both surfaces read the signal through one shared contracts module so they cannot drift, and degrade to copy that does not promise a time when no instant is readable. Other rate limits are untouched: a genuine session-limit exhaustion keeps its hard_quota reading, and every other business failure on Home still prints verbatim. * fix(analytics): keep a campaign entry for the whole campaign The attribution record expires after seven days, but this campaign runs fourteen. A visitor who clicks the banner on day 1 and subscribes on day 11 arrives at checkout with no entry left to attribute the payment to, so the campaign under-reports against the metric it is judged on -- 活动归因付费人数 / 金额, both defined as payments carrying its `campaign_id`. Extended only for entries that carry a `campaignId`. Raising the window globally would move the attribution口径 for every other entry point and the dashboards already built on it; the campaign is the thing that needs a longer memory, so it is the thing that gets one. Past fourteen days a campaign entry expires like any other -- the longer window is scoped to the campaign, not an open-ended exemption. * fix(campaign): carry the new campaign id through ingest and e2e fixtures Two places still recognised only the finished free week's id, both found in review. The daemon's AMR entry ingest allowlist is fail-closed: an unrecognised `campaignId` voids the WHOLE entry rather than just its campaign field. With only `deepseek_v4_flash` listed, every entry this campaign produces would have been dropped at ingest -- and its success metrics are defined as payments carrying its `campaign_id`, so it would have reported zero conversions while converting normally. Both ids are listed now, since entries minted during the free week can still arrive inside their attribution window. The Playwright suite pre-seeds a dismissal so the campaign modal never opens over a functional spec. That seeding was keyed on the old campaign id, so the modal would have covered Home in every UI spec. `home-hero- rail` also clears storage and re-seeded its own hardcoded copy of the key; it now reapplies the shared map instead, so a future campaign cannot be dismissed in one place and left interrupting specs in the other. * fix(campaign): land the workbench badge on the console, like the modal The badge opened a hardcoded `https://open-design.ai/zh/pricing/` while the modal's CTA opened the console's plan surface scoped to the caller's workspace. Both are in-product entry points for a signed-in user, so one funnel was being split across two destinations -- and only one of them lets the user actually subscribe. The hardcoded link also pinned `/zh/`, so every non-Chinese user landed on a Chinese page from a badge that renders in all 19 locales. Now shares the modal's destination, including the workspace scope, so the console can resolve the right upgrade surface for that workspace. Verified against a real browser: the badge opens `/amr/dashboard?workspaceId=…&billing=plan` carrying entry id, source, campaign id, conversion source and device id. |
||
|
|
698a17aac2 |
feat(web): refresh home and preview workspace controls (#6692)
* feat(web): home type-fan picker, floating account cluster, docked tab dropdown Home hero: the 12 create-scenario types render as a fanned card carousel above the composer (flat elliptical arc, apex card = selected, arrows / card click switch along the arc; selected card scales about its own center on an inner body so the outer transform stays a pure arc move). Entry chrome: the account module leaves the nav-rail bottom and rides a fixed top-right cluster next to the DeepSeek campaign badge — credits as its own pill, avatar-only circular trigger, hover menu opens downward (right-aligned, width-capped). The cluster is app-region no-drag so the menu stays reachable in the desktop shell. Project route: the docked workspace tab strip becomes one white dropdown spanning the chat column (MRU order, most recent first), and the chat collapse control lifts out of the chat card header to sit left of it. Design files: the category tab bar drops its near-opaque sticky backdrop strip and scrolls with content. * feat(web): polish top-right capsule and dock controls, seed default deck chip - Home composer seeds 幻灯片 (deck) as the default creation type on a fresh mount — same silent deferred-apply path as a user pick, skipped whenever a persisted chip draft owns the composer. - Type fan: tighter card spacing (arc pivot 3700px → 3000px, ~128px between neighbours) so the hand-of-cards overlap reads again. - Top-right chrome: credits + avatar merge into ONE capsule (pill material on the shared wrapper, chrome-free 24px segments inside, no hover fill on the credits segment). - Project dock: the chat-collapse control gains a white seat matching the tab dropdown, and the dropdown drops its border (shadow only). * test(web): align refreshed home chrome acceptance coverage - Read account badges and member labels from the floating account menu. - Match the default deck route, non-sticky Design Files tabs, and lifted chat control. - Navigate projects through the visible docked Home control instead of hidden state nodes. - Cover the affected Web suite and focused UI P0 restoration flows. * fix(web): lock submit until default routing is ready - Keep fresh-home sends disabled while the plugin catalog and default deck binding settle. - Let explicit plugin handoffs supersede the default without leaving Send locked. - Add a red-spec that holds the catalog response and verifies the routing barrier. * test(e2e): await seeded type before picker actions - Wait for the fresh-home deck binding before opening the creation-type menu. - Switch to Prototype instead of clicking the already-active deck row. - Preserve clear-selection coverage without racing menu reconciliation. * fix(web): keep type menu open during internal scroll - Listen for scroll only on the trigger ancestor chain and the viewport. - Let the portaled creation-type list scroll without dismissing itself. - Add coverage that distinguishes internal menu scroll from anchor-moving scroll. * test(web): await routed submit readiness in legacy flows - Wait for the Home submit control to become enabled before exercising the AMR workspace race path. - Wait for refreshed workspace metadata and default routing to settle before submitting a community template. - Keep the assertions aligned with the production guard that blocks submits until a deterministic default route exists. * feat(web): replace the home type fan with a capsule pill row The 12 create-scenario types render as ONE line of capsule pills exactly as wide as the composer below: pills that fit render inline (8px gaps throughout), 视频 stays pinned before the 全部 trigger, and the rest fold into the 全部 popover. Fit is measured against an invisible probe row so visibility never feeds back into its own measurement. 幻灯片 leads the order (default-selected type); 网站复刻 trails so it folds first. The fanned card carousel this replaces is fully removed (component + styles) — selection still flows through the same onPick scenario path. * feat(web): drop the clear-type control, cap the type menu at 6 rows Clearing the creation type is gone (per product): the composer pill loses its inline x (the chevron now stays in every state) and the menu loses its leading Clear row, so a type is only ever swapped for another. TemplatePicker no longer takes onClear. The type menu shows exactly 6 rows and scrolls for the rest (6 x 44px row + 5 x 2px gap + 12px padding). Specs that drove the removed control are rewritten around swapping instead: the e2e clearHomeTemplate helper is deleted, pickHomeTemplate now observes the pill's has-selection state, and the three clear-specific specs assert the affordance is absent. * feat(web): lift the GitHub star badge into the top-right cluster The star count leaves the account menu's social row and becomes its own chip between the campaign badge and the account capsule, sharing their pill material. The menu's social row keeps Discord / X / mail. * chore(web): clean up stacked EOF whitespace - Remove the trailing blank line introduced in the home hero stylesheet. - Keep the shared Playwright home helper free of the same EOF artifact. * fix(web): polish preview workspace controls * fix(web): stabilize floating comment interactions - Treat a configured comment portal as an overlay from the first render and pre-mount its empty host, preventing the preview from briefly entering the 320px local side-dock layout before the floating card appears. - Route the floating card's close button and Escape key through one cleanup path, restore focus to the originating toolbar trigger, and clear board, create, and composer state so the card reopens with one click. - Add red-spec coverage for the transient side-dock class, single-action close/reopen behavior, and focus restoration; the tests fail on the PR base and pass with this fix. - Align the signed-out balance action with pill-radius controls, remove the redundant portal label, delete obsolete radial/reset/search/card picker CSS, and clean the two stacked EOF whitespace issues. - Preserve the intended steady-state overlay behavior and validate the change with 389 focused tests, workspace typecheck, guard, diff checks, and Electron frame/focus sampling. * test(e2e): align shared project title assertion with focus mode - Wait for the file workspace to become visible after first-open materialization. - Assert the canonical project-title state without requiring the intentionally hidden focus-mode dock to be visible. - Preserve stale-title rejection and the visible catalog-title check after returning to All Projects. - Validate with three focused repetitions, guard, workspace typecheck, and the entry-settings CI lane. * fix(web): stabilize responsive home controls - Recompute the Home type-pill split when localized labels or intrinsic pill widths change without a container resize. - Observe the measured pill children and tail so late font layout cannot leave the overflow decision stale. - Restore composer errors to the reviewed in-flow presentation instead of carrying the unrelated fixed top-chrome toast. - Add focused coverage for locale-style rerenders and post-render width settling. * test(e2e): await default home routing in attachment flow - Wait for the visible deck binding before checking the Home attachment submit lifecycle. - Use the shared long timeout so grouped CI does not race catalogue readiness. - Preserve the production submit gate and the attachment add/remove assertions. * feat(web): add a view-all-comments entry to the comment popover * feat(web): cap the docked tab dropdown at six scrollable rows * fix(web): preserve comment and tab menu interaction semantics - Return focus to the still-open View all comments control when the floating comment panel closes. - Include border-box borders in the six-row tab menu height so the last row is not clipped. - Add focused regression coverage for comment focus restoration and menu sizing. * test(web): await default routing in AMR home flows - Wait for the cold Home composer to finish binding its default deck route before clicking Send. - Preserve the existing AMR balance retry and auth-expiry assertions after the new readiness gate. * fix(web): preserve explicit Home submit routes * fix(web): defer restored Home plugin apply * fix(web): close workspace chrome review gaps - Clamp the Home template menu to six rows and the visible viewport, flipping it above the trigger when needed. - Reset docked tab-menu state when its route-owned portal disappears and preserve editor-owned keyboard shortcuts. - Scope the project header account identity, balance, and billing destination to the project workspace while retaining the ambient fallback for unbound local projects. - Cover tall and short viewports, dock remounts, composer focus, and ambient/project workspace divergence. --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com> Co-authored-by: Amy <1184569493@qq.com> |
||
|
|
4908baa4f4 |
feat(design-systems): ship structured runtime workflow (#6805)
* feat(design-systems): add structured runtime contract (#6456) * feat(design-systems): add structured runtime contract * chore: retrigger CI for PR 6456 --------- Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com> Co-authored-by: mrcfps <mrc@powerformer.com> * feat(design-systems): resolve structured runtime intents * fix(design-systems): preserve workspace runtime scope * test(design-systems): cover scoped prompt runtime * fix(design-systems): use one component authority * fix(design-systems): preserve run workspace authority * fix(design-systems): honor runtime rollback gate * fix(design-systems): pin run binding authority * fix(runs): reject client-supplied scopes * fix(design-systems): keep user package assets scoped * feat(design-systems): validate generated UI adherence * test(e2e): assert durable reload authority state * fix(design-systems): reject non-executable adherence evidence * fix: harden design system adherence validation * fix(design-systems): validate JSX selectors structurally * fix(design-systems): close adherence input gaps * chore(nix): refresh pnpm dependency hashes * chore(ci): retrigger design system validation * fix(daemon): bound design system artifact reads * fix(design-systems): bridge Codex runtime tools * docs(codex): clarify shell environment policy --------- Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com> Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com> Co-authored-by: mrcfps <mrc@powerformer.com> |
||
|
|
e654592ba6 |
fix(daemon): point users at the Settings section that actually exists (#6831)
* fix(daemon): point users at the Settings section that actually exists
When image generation failed for a missing credential, the reply told
the user to configure it in "Settings → Media". No such nav item exists —
the section is "Media providers" ("媒体生成提供商" in zh-CN) — so users
following the instruction found nothing.
The cause is that Settings destinations were spelled independently
wherever a producer happened to need one: 29 provider errors in
apps/daemon/src/media/index.ts said a bare unnamed "Settings" (one said
"Settings -> Media Providers"), and the od-media-generation skill said
"Settings → Media". None of them were tied to the Settings dialog, so a
rename could not reach them.
packages/contracts/src/settings-nav.ts is now the single place a Settings
destination is spelled, and every producer reads it: the media provider
errors, the ElevenLabs voice-options prompt (daemon + contracts), and the
external-MCP auth guidance. e2e/tests/settings-nav-copy.test.ts holds the
constants against the labels apps/web actually renders and fails the
build if a producer re-inlines its own guess.
Swept the same drift class while here: both hatch-pet SKILL.md copies
pointed at "Settings → Pets", but #5517 folded the pet picker into
General, so the real path is "Settings → General → Pets".
Verified the gate red on the pre-fix tree (3 failing assertions) and
green after. Adjacent, not fixed: these strings are English and the
agent translates them ad hoc, so a localized client can still render a
section name that does not match its own UI — see the report.
Record: recvre8FrTE2Oa (V0.19.1 acceptance)
* fix(contracts): gate destinations on rendered nav, not the i18n dictionary
Review on #6831 caught that SETTINGS_EXTERNAL_MCP named a place users
cannot navigate to. `mcpClient` kept its Settings render block but lost
its sidebar nav item (SettingsDialog.tsx says so in its own comment), and
reconnecting an external MCP server now lives in the top-level
Integrations view. So the destination is not a Settings section at all.
The stale string predates this branch — it was hardcoded in
prompts/system.ts on main. But promoting it into a named constant made it
worse twice over: the docblock asserted "The Settings nav item where
external MCP servers are added/reconnected" in a canonical location, and
the new gate whitelisted it by construction, so the check built to catch
dead paths became the thing defending one.
The deeper defect was the gate's invariant. Comparing a constant to its
i18n key's value proves only that some dictionary entry matches — not
that anything renders it. `settings.externalMcpTitle` still resolves to
"External MCP" with no nav item behind it, which is exactly how the dead
path passed. The same hole covered SETTINGS_MEDIA_PROVIDERS.
Now the gate parses what the UI actually renders — `settings-nav-item`
buttons and INTEGRATION_TABS — and resolves those keys through the
dictionary, using it as a lookup rather than as the source of truth.
Verified by reintroducing "Settings → External MCP": the old gate passed
it, the new one fails with the rendered destinations listed.
Replaces SETTINGS_EXTERNAL_MCP with INTEGRATIONS_MCP_PATH
(`Integrations → MCP`) and adds a regression test pinning that a
dictionary-only label is rejected.
|
||
|
|
9120f3dab1 |
fix(web): name the reason when an invite target is already a member (#6830)
Inviting an address that is already an ACTIVE member of the workspace
showed the generic "invitation failed, try again later" copy, so the user
had no way to learn that retrying could never succeed.
The dialog already branches on B's allowlisted invite-create codes; the
gap was in the allowlist itself. B rejects that case with HTTP 409
`{"error":"invite_existing_member"}`, which was absent from
WORKSPACE_INVITE_CREATE_ERROR_ALIASES, so it degraded to the
transport-shaped `create_409` and fell through to the retry-later
fallback. Add the alias, and split the two 409 duplicate variants apart:
"already a team member" and "already has a pending invitation" call for
different next steps and no longer share one merged sentence.
The code -> copy mapping moves out of the component into
`collab/invite-error-copy.ts` as a pure code -> i18n-key function, so a
new B code has exactly one place to be taught and the mapping is
testable on its own.
Verified red on the alias-less baseline (4 failures across both specs)
and green after.
Record: recvrovm9Bcyy0 (V0.19.1 acceptance)
|
||
|
|
b55d443b44 |
fix(web): streamline Home submit preflight (#6756)
* fix(web): retry transient home balance preflight * refactor(web): streamline home submit preflight * fix(web): keep local submit independent from cloud identity * fix(web): preserve local and legacy submit lanes * fix(web): keep in-memory workspace witness valid * fix(web): retain workspace preflight compatibility * fix(web): require workspace witness for team plugin apply * fix(plugins): apply exact local plugin source * fix(web): invalidate legacy witness on identity change * refactor: keep Home submit local-first * test: align local-first creation coverage * fix: preserve exact local plugin context * fix: fence exact local plugin use * fix: disable inexact legacy plugin fallback * test: mock exact plugin apply in visuals * fix: reject unsupported exact project source * Revert "fix: reject unsupported exact project source" This reverts commit |
||
|
|
85d2e4893c |
fix(amr): recover expired cloud sessions (#6786)
* fix(amr): recover expired cloud sessions * fix(diagnostics): redact dynamic API routes * fix(daemon): honor settings in AMR diagnostics * fix(daemon): align workspace auth revisions * fix(web): gate expired cloud sessions on sign-in * fix(web): scope cloud sign-in gate to cloud execution * test(web): align cloud auth fixture * fix(web): recover transient run authority failures * test(daemon): expect retryable authority detail * test(e2e): align BYOK auth boundary * test(e2e): align expired cloud recovery flows |