mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 13:54:39 +00:00
fix: reduce compaction and cross-session responsiveness stalls
Aggregate compaction fragments before reactive SDK reduction while advancing revision fences immediately and preserving native terminal and snapshot authority. Keep task-tool shells keyed across payload updates and rescan bounded membership so removals and replacements cannot leave phantom tools. Isolate SSE callbacks and decode/dispatch diagnostics. Subscriber-local teardown no longer destroys the healthy shared native client. Install HTTP cancellation before preflight and fence late continuations without replaying or interrupting already-admitted mutations. Preserve ownership and environment admission checks. Move HTTP password derivation to async scrypt with serialized password changes and login-authority fencing. Yield cooperatively in both structural-index phases without changing snapshot/digest protocols; an individual SQLite step stays sync. Add isolated red/green regressions, bounded browser/native qualification scripts, independent reviews, source fingerprints and performance evidence for issue #824. Synchronize the Git focus assertion with its async contract and make the opt-in trace self-contained instead of serializing a tsx helper into the browser. Validation includes 793 passing server tests and 6 skips after replay, UI/server/ Electron typechecks, UI/server/plugin builds, and private Windows native 2.0.21 fixtures. Renderer fixture median main-thread cost is 324.57 to 16.94 ms. The latest full browser run remains 358 pass, 1 fail, 1 skip: its trace failure was subsequently reproduced and corrected with a minimal Chromium check, but the full suite has not been rerun. Store assertions use force-exit; natural cleanup and Linux Electron to Windows LAN/HTTP with 2.0.19 remain unqualified. Publish as a draft for review, not a universal resolution or issue-closing fix. Refs #824.
This commit is contained in:
parent
366830f6b4
commit
f6a18d00bd
63 changed files with 6966 additions and 275 deletions
121
dev-docs/ISSUE_824_COMPACTION_UI_DIAGNOSIS.md
Normal file
121
dev-docs/ISSUE_824_COMPACTION_UI_DIAGNOSIS.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Issue #824: native compaction renderer diagnosis
|
||||
|
||||
## Scope
|
||||
|
||||
This investigation covers the UI event path only. It does not exercise session
|
||||
pruning, the shared daemon, provider traffic, Electron/Linux, or the remote
|
||||
Windows backend. The fixture uses the real `SessionView`, native event
|
||||
dispatcher, OpenCode Solid reducer, CodeNomad message store, and virtualized
|
||||
transcript. Native client calls and `/api` traffic are isolated fakes.
|
||||
|
||||
The red reproduction is:
|
||||
|
||||
```powershell
|
||||
$env:TEMP = 'C:\Users\Admin\AppData\Local\Temp\opencode'
|
||||
$env:TMP = $env:TEMP
|
||||
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts
|
||||
```
|
||||
|
||||
## Confirmed behavior
|
||||
|
||||
A compaction for an inactive session still performs synchronous work in the
|
||||
single renderer that owns the active session:
|
||||
|
||||
1. `SSEManager.handleEvent()` calls `handleInstanceInvalidation()` for every
|
||||
native event.
|
||||
2. `handleInstanceInvalidation()` calls `applyOpenCodeDataEvent()` before it
|
||||
checks whether the session is active for visible projection.
|
||||
3. `applyOpenCodeDataEvent()` creates/retains a per-session OpenCode Solid data
|
||||
reducer and emits every `session.compaction.delta` into it.
|
||||
4. The bundled `@opencode/client` reducer finds the running compaction and
|
||||
appends every fragment to its `summary` in a Solid `produce()` update.
|
||||
5. CodeNomad delays only `projectOpenCodeMessages()` to 250 ms. That avoids
|
||||
repeatedly normalizing/hydrating the visible transcript, but it does not
|
||||
coalesce the upstream reducer work or callback scheduling.
|
||||
|
||||
The active transcript stayed byte-for-byte unchanged in the fixture. The
|
||||
timeline and virtualizer were therefore not the cause of the measured stall.
|
||||
No `message.list` request occurred during any burst. The one `session.active`
|
||||
call per sample comes from the synthetic terminal `compaction.failed` cleanup,
|
||||
not from delta delivery.
|
||||
|
||||
### October 1 isolated Chromium sample
|
||||
|
||||
Each fragment was 1 KiB. The fixture schedules a zero-delay active-session
|
||||
timer before dispatch and records browser Long Tasks. The final run reported:
|
||||
|
||||
| Delta events | Dispatch | Active timer delay | Long tasks |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 4,096 | 14.1 ms | 14.2 ms | 0 |
|
||||
| 8,192 | 21.9 ms | 22.0 ms | 0 |
|
||||
| 16,384 | 42.6 ms | 42.8 ms | 0 |
|
||||
| 32,768 | 82.5 ms | 82.6 ms | 83 ms |
|
||||
|
||||
CDP sampling attributed named self time primarily to
|
||||
`applyOpenCodeDataEvent` (36.3 ms), OpenCode/Solid store access/update paths,
|
||||
and `projectMessages` (11.8 ms). A large `(program)` bucket remained
|
||||
unattributed by the sampling profiler. The near-linear count scaling points to
|
||||
per-event reducer/callback overhead rather than a whole-transcript DOM redraw
|
||||
or a superlinear summary concatenation in this corpus.
|
||||
|
||||
Two preceding runs also failed red at 32,768 fragments, with 80-85 ms timer
|
||||
delay and one 82-85 ms Long Task. A 16,384-fragment run varied around the Long
|
||||
Task boundary (approximately 42-70 ms), so the regression deliberately uses
|
||||
the larger corpus and asserts behavior, not a comparative performance gain.
|
||||
|
||||
## Interpretation and limits
|
||||
|
||||
This is a confirmed renderer scalability defect: inactive compaction content
|
||||
is reduced synchronously even though it cannot be displayed. It provides a
|
||||
mechanism for cross-session UI interference and demonstrates delayed active
|
||||
input/timers in the real UI stack.
|
||||
|
||||
It is not yet proof that normal EventSource delivery produces one callback
|
||||
containing 32,768 events. The current browser transport invokes `onBatch` with
|
||||
one event, while the reproduction deliberately presents a backlog directly to
|
||||
the dispatcher to make callback work deterministic. Real network task
|
||||
scheduling may interleave user input. The corpus is also 32 MiB of aggregate
|
||||
summary fragments, larger than many compactions. Therefore this result must
|
||||
not be presented as the sole cause of the reported multi-second freeze.
|
||||
|
||||
The test does not reproduce the daemon-side symptom where other native
|
||||
sessions stop progressing, nor the Linux Electron to Windows HTTP persistence
|
||||
after restart. Those require separate service/transport evidence. Native
|
||||
compaction and CodeNomad's pruning RPC are unrelated paths here.
|
||||
|
||||
Current V2 UI events are `session.compaction.started/delta/ended/failed` plus
|
||||
other granular `session.*` events. No current renderer handler for legacy
|
||||
`session.updated`, `message.updated`, or `message.part.updated` was found.
|
||||
|
||||
## Smallest recommended product patch
|
||||
|
||||
Coalesce `session.compaction.delta` **before** the OpenCode Solid reducer, keyed
|
||||
by instance and session, instead of only coalescing the later CodeNomad message
|
||||
projection:
|
||||
|
||||
- append fragments to a plain pending accumulator;
|
||||
- preserve an immediate revision fence on the first arrival so in-flight
|
||||
native message reads cannot publish stale content;
|
||||
- flush at most once per 250 ms to the reducer and visible projection;
|
||||
- cancel the pending aggregate on `ended` because that event carries the
|
||||
authoritative final text, and discard it on `failed`;
|
||||
- clear pending aggregates on instance disposal/reconnect;
|
||||
- do not materialize an inactive, unloaded transcript reducer solely for
|
||||
compaction deltas. A later activation should hydrate authoritatively.
|
||||
|
||||
The regression should retain this two-session browser fixture and add exact
|
||||
event-count instrumentation: thousands of inactive deltas must produce a
|
||||
bounded number of reducer emissions/projections, zero message-list reads, no
|
||||
active-transcript mutation, and no Long Task. Existing request-generation,
|
||||
revert, pruning, rotation, and terminal-authority tests must remain green.
|
||||
|
||||
## Files added for reproduction
|
||||
|
||||
- `packages/ui/tests/browser/compaction-responsiveness.test.ts`
|
||||
- `packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx`
|
||||
|
||||
The initial timing-only test has since been replaced by deterministic reducer
|
||||
and projection counters in `compaction-responsiveness.test.ts`. It is red on
|
||||
the diagnosed implementation and green with the buffering correction. See
|
||||
`issue-824-compaction-ui-fix.md` for the current gates and their limits. No shared
|
||||
daemon, user profile, external network, or desktop restart is used.
|
||||
484
dev-docs/ISSUE_824_PERFORMANCE.md
Normal file
484
dev-docs/ISSUE_824_PERFORMANCE.md
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
# Issue #824 — freeze investigation
|
||||
|
||||
Mission baseline: `366830f6` (`origin/dev`, 2026-10-01). Integration worktree:
|
||||
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance`, branch
|
||||
`fix/issue-824-performance`. Confirmed corrections are integrated and independently
|
||||
reviewed, but final acceptance is not green. On 2026-10-02 the user authorized
|
||||
committing/pushing the mission changes and opening a draft PR to `dev` for review.
|
||||
Publication does not imply acceptance, merge, installation, deployment or a shared
|
||||
daemon restart.
|
||||
|
||||
## Scenarios and evidence policy
|
||||
|
||||
- Issue report: Electron Linux `0.20.0-dev-20260930-47f9e43e`, OpenCode 2.0.19,
|
||||
remote Windows backend over HTTP. Submitting a prompt often locks the UI;
|
||||
reopening the client and reconnecting to the same server/session does not help.
|
||||
- Additional user report: native compaction makes the UI and other sessions
|
||||
unresponsive, despite earlier optimization work.
|
||||
- Distinguish renderer CPU, HTTP connection occupancy, backend event-loop work,
|
||||
shared native/plugin work and provider waiting. Compaction and technical-content
|
||||
pruning are different operations.
|
||||
- Use synthetic databases, loopback fixtures and separately launched native
|
||||
runtimes. Never test against the user's OpenCode database or mutate their
|
||||
sessions. A local fixture reproduction is not proof of the reporter's exact
|
||||
root cause or Linux/Electron qualification.
|
||||
|
||||
## Parallel investigations
|
||||
|
||||
1. Prior audit continuity and broader unaddressed performance risks.
|
||||
2. Compaction events, transcript hydration and renderer responsiveness.
|
||||
3. HTTP/SSE, prompt admission and shared backend responsiveness.
|
||||
4. Coordinator: plugin history/index reads and event-loop fairness.
|
||||
|
||||
The previous audit's reports remain in the separate `audit-resume-integration`
|
||||
worktree. Its changes must be checked against this baseline before reuse.
|
||||
|
||||
## Confirmed: structural-index batches monopolize their event loop
|
||||
|
||||
`packages/server/src/opencode/session-pruning/outline-index.ts` originally read
|
||||
512 checkpoint headers synchronously with `.all()`, then projected assistant JSON
|
||||
for 128 rows before yielding. A row count is not a time budget: tool output size
|
||||
varies considerably, and SQLite evaluates nested assistant JSON synchronously.
|
||||
The routine is invoked by the bundled plugin's `outline` RPC.
|
||||
|
||||
### Reproduction
|
||||
|
||||
`scripts/profile-outline-responsiveness.mts` creates only an in-memory SQLite
|
||||
database. It runs the real structural-index reader with 512 assistant messages,
|
||||
each holding one 1 MiB textual tool result. The heartbeat is a separate
|
||||
`setImmediate` callback on the same loop. Its maximum observed gap includes
|
||||
synchronous SQL/JS work and host scheduling, not remote-client paint latency.
|
||||
|
||||
```powershell
|
||||
node --import tsx scripts/profile-outline-responsiveness.mts 512 1048576 --baseline
|
||||
node --import tsx scripts/profile-outline-responsiveness.mts 512 1048576
|
||||
```
|
||||
|
||||
`--baseline` bundles the exact pre-mission `366830f6` reader without replacing
|
||||
working-tree files. Seeding and module compilation are outside the measured scan.
|
||||
The stress corpus is deliberately large (~512 MiB of payload); this is not an
|
||||
estimate of a typical session or the reporter's data size.
|
||||
|
||||
First baseline run: maximum gaps **542 / 522 / 720 ms**, total scans
|
||||
1717 / 1969 / 2209 ms, six heartbeat turns each. A subsequent immutable-source
|
||||
baseline run observed **615 / 755 / 712 ms**, totals 2066 / 2409 / 2273 ms.
|
||||
Initial modified run: maximum gaps **13 / 14 / 14 ms**, totals
|
||||
1829 / 2680 / 2225 ms, 199 / 262 / 224 turns. Windows, Node/SQLite; uncontrolled
|
||||
concurrent host work. These observations demonstrate improved cooperation, not
|
||||
reduced total CPU or an application frame-rate claim. No wall-clock CI threshold.
|
||||
|
||||
### Correction and regression contract
|
||||
|
||||
Iterate the same checkpoint headers and retain the exact digest/page protocol.
|
||||
Yield during both header reads and JSON projection when the slice has consumed
|
||||
8 ms or 128 rows, and check cancellation between rows. Preserve read snapshots,
|
||||
ownership, staged-undo visibility, known checkpoints and response bounds.
|
||||
|
||||
`outline-responsiveness.test.ts` uses real SQLite with a view that charges a
|
||||
deterministic simulated cost per payload access. It schedules an actual pending
|
||||
event-loop cancellation in the header or projection phase. The original header
|
||||
regression failed after **896 payload accesses** before cancellation could run.
|
||||
Both new tests pass after the patch and assert the read transaction is released.
|
||||
The existing nine navigation tests also pass; server typecheck passes.
|
||||
|
||||
A single SQLite step remains synchronous and cannot be interrupted by this
|
||||
cooperative budget. This patch does not move native storage work to another
|
||||
thread, reduce all JSON parsing, or establish that this defect alone causes #824.
|
||||
Native plugin qualification and independent review are tracked separately.
|
||||
|
||||
## Confirmed: subscriber failures cross feature/connection boundaries
|
||||
|
||||
The prior audit found these defects, but both source blobs were unchanged in
|
||||
`366830f6`. They were reproduced again against the current mission baseline.
|
||||
|
||||
- Browser `ServerEvents` dispatch stopped at the first throwing callback. A
|
||||
wildcard subscriber could prevent the real SSEManager and typed subscribers
|
||||
from receiving valid native events; open/status callback failures could prevent
|
||||
reconciliation or abort restart. Three browser regressions failed before the
|
||||
patch; all four (including normal FIFO/unsubscribe) pass after per-callback
|
||||
exception isolation. Faults are diagnosed and never replayed.
|
||||
- `OpenCodeSharedService.invalidateAfterStream` invalidated the shared client in
|
||||
`finally`, including on subscriber-local abort, iterator return/throw or an
|
||||
already-aborted subscription. Four regressions failed with the real SDK and
|
||||
loopback HTTP/SSE source. All seven now pass: local lifetime changes leave the
|
||||
healthy source/client alone, whereas genuine upstream EOF/transport errors
|
||||
still invalidate the originating generation. Late cleanup cannot invalidate a
|
||||
replacement; obsolete iterators cannot publish queued events. The fourteen
|
||||
existing shared-service tests also pass.
|
||||
|
||||
Commands:
|
||||
|
||||
```powershell
|
||||
node --import tsx --test packages/ui/tests/browser/event-subscriber-isolation.test.ts
|
||||
node --import tsx --test packages/server/src/workspaces/subscriber-lifetime.test.ts packages/server/src/workspaces/opencode-service.test.ts
|
||||
```
|
||||
|
||||
The complete private native fixture also passed on OpenCode 2.0.21 with the
|
||||
production shared-service wrapper: abort/return/throw retain the same client,
|
||||
relay shutdown leaves the independent subscriber usable, slow-recipient FIFO
|
||||
remains correct, and proxy/history/navigation/pruning checks pass. Retained
|
||||
fixture: `C:\Users\Admin\AppData\Local\Temp\opencode\codenomad-pruning-native-Atf4fG`.
|
||||
The 48 adjacent relay/SSE/proxy/navigation unit tests pass as well. No user
|
||||
daemon was stopped or restarted.
|
||||
|
||||
These are functional isolation fixes, not measured CPU speedups, and no
|
||||
production callback failure from the reporter has been captured. They prevent
|
||||
one feature/consumer's failure from disturbing unrelated consumers; they do not
|
||||
isolate synchronous CPU-heavy callbacks into different threads.
|
||||
|
||||
Independent event-boundary review approved these corrections without a
|
||||
functional blocker, but reproduced a diagnostic gap: a deferred Solid memo
|
||||
failure can occur at the final shared-batch flush, after callback-level catches.
|
||||
The transport then mislabeled it as invalid JSON. A small follow-up separates
|
||||
JSON decoding from event dispatch in `event-source-handlers.ts`. Its new unit
|
||||
and real-SSE/browser-derived-error tests failed before the patch; all nine focused
|
||||
tests now pass. The same lot's native/typed handlers receive each event once,
|
||||
and subsequent events continue. This does not isolate every reactive computation
|
||||
or change shared batching. Final UI review will recheck it with compaction work.
|
||||
|
||||
Residual limits from `issue-824-event-review.md`: these diagnostics use the
|
||||
existing `sse` debug logger (not enabled by default); a concurrent local abort and
|
||||
physical source failure has not been deterministically reproduced. No speculative
|
||||
connection-invalidation change or new logging pipeline is introduced for them.
|
||||
|
||||
## Confirmed: orphaned HTTP preflight observers
|
||||
|
||||
The real Fastify/loopback proxy reproduction in `issue-824-transport` held the
|
||||
native session-ownership read for prompt and compact requests. Closing the HTTP
|
||||
client did not cancel those reads: listeners were installed only after preflight.
|
||||
An independent session still responded before the held reads were released.
|
||||
This proves an orphaned-request defect, not a global server CPU freeze or why a
|
||||
real native read stalls in the reporter's case.
|
||||
|
||||
The dedicated correction (`d9598e42`, following red diagnostic `1ece148c`)
|
||||
installs per-request cancellation at entry, propagates it to supported reads,
|
||||
retires the local observer of uncancellable shared work, and fences late
|
||||
continuations before environment writes or forwarding. It never treats ordinary
|
||||
POST body completion as disconnection, adds no new execution timeout and never
|
||||
interrupts/replays an admitted native mutation. Twelve real HTTP tests and three
|
||||
scope tests passed in the source worktree and again after integration, along with
|
||||
65 adjacent proxy tests and server typecheck. Independent review accepts the patch;
|
||||
see `issue-824-http-admission-fix.md`.
|
||||
|
||||
## Confirmed: HTTP password derivation blocks unrelated work
|
||||
|
||||
The prior audit's synchronous-scrypt concern was reproduced with real
|
||||
Fastify/AuthManager/auth routes and a private v1 auth file. In five baseline runs,
|
||||
a witness endpoint could not run until all eight invalid logins or four password
|
||||
changes finished. The async-scrypt correction admits the witness while those
|
||||
operations are pending. Descriptive five-process medians: login witness
|
||||
**168.63 → 3.37 ms**, password-change witness **82.43 → 0.80 ms**. Timing is not
|
||||
the CI assertion: the tests require witness progress before the burst completes.
|
||||
|
||||
Hash format/parameters and constant-time comparison are retained; password
|
||||
changes are serialized and recover after persistence failure. The CLI/env
|
||||
override hashes synchronously only at startup, before HTTP serving. The shared
|
||||
libuv pool remains a finite resource, and there is no claim of reduced password
|
||||
CPU or a fix for compaction. Eight focused tests passed repeatedly in the source
|
||||
worktree. Integration added a deterministic regression for an old login crossing
|
||||
a password change: the initial async patch returned 200 after the new secret was
|
||||
stored. The corrected verifier checks that its auth record remains authoritative
|
||||
after the derivation; it returns 401 without a cookie or replay. All nine auth
|
||||
tests and server typecheck pass after integration. Independent review accepts it;
|
||||
see `issue-824-auth-responsiveness.md`.
|
||||
|
||||
## Confirmed: nested task tools remount on every delta
|
||||
|
||||
The prior audit's task-renderer correction was requalified against the identical
|
||||
`366830f6` source blob and integrated with a minimal +29/-18 diff. It retains
|
||||
stable child-tool keys/shells while reading their current payload. Two baseline
|
||||
regressions fail on shell identity and stale Markdown; 63 adjacent browser tests
|
||||
pass after correction, covering disclosure, nested scroller state, in-place
|
||||
revision, removal, copy, errors/retry and tool images.
|
||||
|
||||
Current bounded A/B (30 samples per variant, instrumentation off): median
|
||||
main-thread duration **353.39 → 22.72 ms**. Instrumented controls show 80 tool-shell
|
||||
removals/reinsertions per delta becoming zero. The 80 clones/reloads remain; no
|
||||
claim is made about desktop frame rate or eliminating all task-render costs.
|
||||
See `issue-824-render-recovery.md` for artifacts, failed setup runs excluded from
|
||||
statistics, and source-drift caveats. Final combined validation must use a frozen
|
||||
composition; the prior measurements do not establish that the whole concurrent
|
||||
worktree was unchanged. Independent combined UI review confirmed a membership
|
||||
defect: an authoritative page removing one tool part from a retained message
|
||||
leaves a phantom key/count, potentially hiding a later replacement. The renderer
|
||||
correction therefore was not accepted for final qualification. The follow-up now
|
||||
reuses the existing bounded structural scanner at each session revision, retaining
|
||||
the prior ordered key list when unchanged. Keyed `For` preserves retained shells
|
||||
through removal/reordering. Five new regressions fail on the preimage and all
|
||||
68 adjacent browser scenarios pass naturally after correction, including exact
|
||||
membership/count, tool-to-text identity changes and truncation shrink. The scan
|
||||
still caps at 10,000 units and 201 tool discoveries/200 displayed tools; scanning
|
||||
IDs/types on every revision is the explicit correctness tradeoff. Zero remounts
|
||||
per delta remain instrumentally verified, but prior CPU A/B figures do not
|
||||
qualify this new postimage. Independent re-review now closes M1 with no residual
|
||||
blocker: 13 renderer and nine copy/image cases pass freshly and naturally, with
|
||||
zero remounts per delta. See
|
||||
`issue-824-task-membership-fix.md` and `issue-824-ui-review.md`, finding M1.
|
||||
|
||||
## Confirmed: compaction payload reduction precedes visible throttling
|
||||
|
||||
The existing 250 ms throttle limited only visible projection; every native
|
||||
fragment still entered the reactive SDK reducer, even for an inactive session.
|
||||
The correction aggregates by instance/session before that reducer, with a timer
|
||||
started by the first chunk (later chunks do not postpone it). Incoming fragments
|
||||
still advance revision/read fences immediately. Same-session ordering and SDK
|
||||
page boundaries flush pending text; terminal authority, reconnect, deletion,
|
||||
revert, pruning and disposal cancel obsolete buffers. Never-loaded inactive
|
||||
sessions keep authority markers rather than materializing summary payloads.
|
||||
|
||||
The acceptance tests now use exact emission counts and text, not the original
|
||||
artificial 32 MiB Long Task threshold: 128 active chunks produce one interval
|
||||
reduction instead of 128, eight chunks delivered in separate 10 ms tasks produce
|
||||
one reduction, and 128 never-loaded inactive chunks produce no payload reduction
|
||||
or history request. All five browser tests discriminate the exact baseline and
|
||||
pass on the patch. Activation mid-compaction, loaded-inactive sessions and remount
|
||||
preserve exact authoritative text. Eleven new reducer/lifecycle tests and 130
|
||||
neighbors pass; 67 real-renderer/history browser cases pass naturally.
|
||||
|
||||
See `issue-824-compaction-ui-fix.md`. Unit store gates use the repository's
|
||||
`--conditions=browser --test-force-exit` convention for persistent imported
|
||||
timers; this is not evidence of natural process cleanup. Spaced dispatcher tasks
|
||||
are not real EventSource/provider cadence qualification. Interleaved same-session
|
||||
events/read boundaries intentionally reduce the coalescing ratio, and sustained
|
||||
arrivals can delay the existing revision-stable activation catch-up. Independent
|
||||
UI review accepts this coalescing within those limits, without a reproduced text,
|
||||
ordering or fence defect. Frozen-source validation results are recorded below.
|
||||
|
||||
## Browser HTTP/1 isolation: bounded non-reproduction
|
||||
|
||||
Five real Chromium Windows → Fastify Windows loopback HTTP/1.1 cases pass:
|
||||
prompt/compact crossed with page/context close, plus a separate background-read
|
||||
budget case. One real multiplexed SSE and a held SDK session A preflight leave
|
||||
input/switch, B reads, metadata and a control endpoint available. Abandonment and
|
||||
reconnection retire old HTTP/SSE observers before the held handler is released;
|
||||
late completions produce no forward, environment write or shared invalidation.
|
||||
Five catalogue intents admit only two held reads; queued work from the destroyed
|
||||
page does not resume. Held upstream handler closures and idle pooled sockets are
|
||||
reported separately from outstanding HTTP observers.
|
||||
|
||||
See `issue-824-browser-http.md`: 5/5 standalone, 10/10 combined with event
|
||||
isolation, and 15/15 neighboring server tests pass naturally. No new product
|
||||
correction follows from this non-reproduction. CDP and Fastify both verify the
|
||||
HTTP/1.1 protocol, but controls are a minimal Solid fixture, not the complete
|
||||
transcript/composer; upstream is disposable HTTP with native-shaped responses,
|
||||
not native compaction. Linux/Electron, LAN and native 2.0.19 remain unqualified;
|
||||
the report records the isolated cross-host experiment and evidence to collect.
|
||||
|
||||
## Native service isolation: bounded non-reproduction
|
||||
|
||||
The private native compaction fixture completed twice on OpenCode 2.0.21/Windows.
|
||||
While A's real summary response was deliberately gated for ~1.5 s, B accepted a
|
||||
prompt, ran its model request and completed successfully before A was released.
|
||||
Native info/get/activity remained available (~1.8–7 ms observed). Both sessions
|
||||
were simultaneously running; A then completed compaction with 29 native deltas
|
||||
and no compaction-failed event. During a separate 32 MiB outline scan in the real
|
||||
plugin host, six native info reads completed before the scan ended (final run).
|
||||
|
||||
See `issue-824-native-compaction.md` for commands, evidence and limits. This first
|
||||
scenario used only 24,557 bytes of outgoing summary context; the 32 MiB tool
|
||||
corpus belongs to outline work, not A's compaction context. Large-context assembly
|
||||
was then qualified separately: two private runs each at 2/8/32 MiB verify every
|
||||
tool payload byte at the actual provider boundary (32 MiB request JSON:
|
||||
33,612,826 bytes). Submission-to-provider headers grows to 225–291 ms at 32 MiB;
|
||||
unrelated pre-arrival reads show transient latency up to ~85 ms. B nevertheless
|
||||
completes before A reaches the provider in both 8/32 MiB pairs, and completes
|
||||
before A's held response is released at every size. No continuous global freeze
|
||||
or specific native CPU hotspot is established. Provider JSON parse time is
|
||||
separately recorded, not attributed to native assembly. No speculative upstream
|
||||
patch follows. No inference about 2.0.19, Linux/Electron, remote TCP or the
|
||||
reporter's exact data follows from this bounded evidence.
|
||||
|
||||
## Validation checkpoint (not final combined qualification)
|
||||
|
||||
`node --import tsx --test "packages/server/src/**/*.test.ts"` completed naturally:
|
||||
**776 tests, 770 pass, 6 skip, 0 fail**, about 32.8 s. UI and server typechecks
|
||||
also passed at this checkpoint. Auth/proxy corrections developed in separate
|
||||
worktrees are not included in that run; fresh validation follows integration.
|
||||
|
||||
After auth and HTTP integration, a fresh strict-unhandled-rejection full server
|
||||
run completed naturally: **799 tests, 793 pass, 6 skip, 0 fail**, about 32.8 s.
|
||||
Command: `node --unhandled-rejections=strict --import tsx --test
|
||||
"packages/server/src/**/*.test.ts"`. Log: managed shell
|
||||
`sh_0f87b4309001T4M1Bgo7XpAvfz.out`. This includes the new login-authority race
|
||||
regression; counts are not additive with earlier runs. Final review and frozen
|
||||
composition validation remain outstanding.
|
||||
|
||||
The complete server build also passes (`npm run build --workspace
|
||||
@neuralnomads/codenomad`), including production UI, TypeScript, auth assets and
|
||||
bundled pruning/automation plugins. UI and Electron typechecks pass afterward;
|
||||
diff check reports no whitespace defects. Vite reports chunks above 500 kB; no
|
||||
unrelated bundling policy change or warning suppression was introduced. This
|
||||
build did not start or deploy a desktop/backend process.
|
||||
|
||||
The first complete browser run finished naturally: **349 tests, 348 pass, 1 skip,
|
||||
0 fail**, about 22.2 minutes (`npm run test:browser --workspace @codenomad/ui`,
|
||||
log `sh_0f86cb7bb001g0xkd6q5qnv4G2.out`). It includes the five compaction cases,
|
||||
task-shell retention, renderer/history/navigation and native Electron fixture
|
||||
checks. The opt-in Electron tab-chrome check is skipped. Because this run began
|
||||
while source owners were still integrating, it is not a frozen-source final
|
||||
qualification; its event-isolation file executed before the additional deferred
|
||||
Solid error case, and the later browser HTTP fixture was not included. Do not
|
||||
reuse or add its count to the forthcoming fresh composition gate.
|
||||
|
||||
The independent server integration review accepts auth and HTTP changes without
|
||||
a blocking finding; see `issue-824-server-review.md`. It preserves the distinction
|
||||
between proven local defects and the unqualified reporter scenario. UI review
|
||||
accepts compaction and SSE diagnostics; its task-membership finding was corrected
|
||||
and closed by independent re-review. Dedicated HTTP browser checks are complete.
|
||||
Fresh combined validation subsequently finished with before/after source fingerprints
|
||||
covering tracked and untracked executable inputs. Its acceptance verdict and later
|
||||
bounded follow-ups are below; documentation updates are excluded from its source
|
||||
manifest.
|
||||
|
||||
## Reviewed-composition validation: acceptance remains blocked
|
||||
|
||||
The fresh validation uses 1,696 tracked and untracked executable inputs. Completed
|
||||
gates have identical before/after manifests, SHA256
|
||||
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
|
||||
This verifies their boundaries, not a filesystem lock or installed dependencies.
|
||||
Exact commands, logs and limitations are in `issue-824-combined-validation.md`;
|
||||
private artifacts remain under
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-combined-b91c8b66fcc840e1909170c5eeb846b9/`.
|
||||
|
||||
- Full server replay: **799 tests, 793 pass, 6 skip, 0 fail**, natural exit 0.
|
||||
The first run's private Git-directory cleanup `EPERM` (792 pass, 1 fail, 6 skip)
|
||||
is retained; the isolated nine Git tests and full replay pass with unchanged
|
||||
sources. Its Windows cause is not established.
|
||||
- UI/server/Electron typechecks and server build (including UI and bundled
|
||||
plugins) pass naturally. A fresh complete private native fixture passes on
|
||||
OpenCode 2.0.21; no user runtime or storage was used.
|
||||
- Stores: **141/141 assertions pass with force-exit**. The natural-exit attempt
|
||||
for the new compactage tests prints 11 passes but times out after 30 seconds;
|
||||
lifecycle cleanup is not qualified. Four event-handler tests exit naturally.
|
||||
- Full browser: **360 tests, 356 pass, 3 fail, 1 skip**, natural exit 1 in about
|
||||
21.6 minutes. Failures are focus not returning to the composer after successful
|
||||
Git diff insertion (line 609), MCP fixture readiness at reduced motion/150%
|
||||
scale, and the expected legacy cached HTML not appearing after the literal-mode
|
||||
toggle (line 104; earlier HTML/source checks passed).
|
||||
All three pass in a targeted natural-exit replay without source edits; that
|
||||
does **not** convert the full suite into a passing gate or establish the causes.
|
||||
The existing focused replay has also finished naturally: **104/104 pass**, no
|
||||
skip/cancelled, unchanged sources. Its eight suites include all mission browser
|
||||
gates and the full session-rendering file. The validator's terminal report is
|
||||
received; validation execution is complete, acceptance is still non-green.
|
||||
- Fresh renderer A/B on the final membership-corrected postimage: three couples,
|
||||
30 samples per variant, instrumentation off, natural exit 0. Median main-thread
|
||||
**324.57 → 16.94 ms** (baseline range 168.06–743.53 ms, fixed 9.93–50.33 ms).
|
||||
Raw metrics: `C:/Users/Admin/AppData/Local/Temp/opencode/codenomad-render-cost-srSQOf/metrics.json`.
|
||||
This supersedes the earlier postimage's CPU qualification, not its historical
|
||||
evidence; it measures the bounded fixture, not desktop FPS or all task costs.
|
||||
|
||||
No functional review blocker remains. However, the fresh full-browser gate is
|
||||
non-green and the reporter's Electron Linux → Windows LAN/HTTP/OpenCode 2.0.19
|
||||
scenario remains unqualified. The original freeze's exact cause is not established;
|
||||
the private 2/8/32 MiB native compaction and loopback HTTP fixtures did not reproduce
|
||||
a persistent global freeze. Do not mark #824 universally resolved or publish/install
|
||||
these changes from this evidence alone.
|
||||
|
||||
The independent `browser-focus-cache-diagnosis` is now complete without editing
|
||||
sources. It identifies a test synchronization gap: insertion updates the value
|
||||
immediately, but composer focus/selection run in `setTimeout(0)`; the test asserts
|
||||
focus immediately after reading the value. The original failed instant is allowed
|
||||
by that contract; persistent loss of focus or draft is not demonstrated. Three
|
||||
new complete focus passes and three HTML-cache passes are recorded separately.
|
||||
A fourth focus attempt fails earlier, during a detached/intercepted Monaco overlay
|
||||
click, and is retained as a distinct failure rather than attributed to #824.
|
||||
The HTML cache transition is a synchronous local cache hit, so ordinary slow
|
||||
Markdown highlighting is not its explanation; the original timeout remains
|
||||
unattributed. See `issue-824-browser-focus-cache-diagnosis.md`.
|
||||
|
||||
The bounded `browser-mcp-bootstrap-diagnosis` is also complete: readiness timed
|
||||
out in one of five natural-exit runs, with unchanged executable sources. The
|
||||
network observations saturated the private harness's shared 4,000-entry budget
|
||||
before terminal stages/errors/snapshots, so attribution is still impossible.
|
||||
This is a confirmed diagnostic-capture weakness, not an identified product cause.
|
||||
See `issue-824-browser-mcp-bootstrap-diagnosis.md`; all five outcomes are retained.
|
||||
|
||||
`browser-focus-test-sync` has now completed. The coordinator reviewed the diff:
|
||||
after the unchanged text assertions, the test captures the textarea and waits for
|
||||
its identity to equal `document.activeElement`, then retains the original identity
|
||||
assertion. No sleep, new timeout, force-click, retry or product edit. Focus passes
|
||||
2/2; the two-case run passes once and fails once on the independent HTML-cache
|
||||
timeout. That timeout is now reproduced, not merely an unknown historical failure.
|
||||
Opt-in `CODENOMAD_BROWSER_BOUNDARY_TRACE=1` prepares cache/overlay/focus diagnostics;
|
||||
it was added after those two runs and has not yet captured the cache recurrence.
|
||||
See `issue-824-browser-focus-test-sync.md`. Three authorized test/fixture inputs
|
||||
changed; the earlier combined manifests remain historical, not fingerprints of
|
||||
the revised test composition. Four selected product inputs remain unchanged in
|
||||
the specialist's focused manifest; that subset is not a full-product manifest.
|
||||
|
||||
`browser-mcp-critical-capture` is now terminal: the private collector's overflow
|
||||
is verified without a browser, then **5/5 targeted runs pass naturally** in 54.35 s
|
||||
with complete critical traces, zero overflow and zero executable-source drift.
|
||||
The 1,696-input comparison confirms that only the three authorized focus-sync
|
||||
test/fixture paths differ from the earlier combined validation. Settings and render
|
||||
finish in tens of milliseconds in these passes; most observed navigation time is
|
||||
before the fixture body. That localizes cost in passes, not the historical timeout.
|
||||
No sixth targeted run or product patch is justified. The prior red MCP and recurrent
|
||||
HTML-cache timeout remain open; see `issue-824-browser-mcp-critical-capture.md`.
|
||||
|
||||
`browser-final-boundary-gate` has finished its **single normative full-browser
|
||||
run**: **360 tests, 358 pass, 1 fail, 1 skip, 0 cancelled**, natural exit 1 in
|
||||
20 min 10 s. All 1,696 inputs remain identical before/after, new manifest SHA256
|
||||
`da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
|
||||
The only failure is the final `errors=[]` assertion in the Git insertion test:
|
||||
`ReferenceError: __name is not defined` from the anonymous opt-in browser script.
|
||||
All earlier functional assertions in that case, including focus, pass in this
|
||||
execution. The trace array is present but empty; it cannot prove absent gestures.
|
||||
MCP and HTML-cache cases pass here, without attributing their earlier failures.
|
||||
No retry or another full suite was run. See `issue-824-browser-final-boundary-gate.md`.
|
||||
|
||||
Acceptance remains **non-green**. `browser-trace-serialization-fix` is now
|
||||
complete: the exact callback imported through the installed `node --import tsx`
|
||||
serializes `const describe=__name(...)` without the module-scope helper. A minimal
|
||||
Chromium page reproduces the same exception and empty trace. This is a confirmed
|
||||
test-instrumentation defect introduced during this mission, not a product defect.
|
||||
It is corrected with explicit, self-contained JavaScript passed to
|
||||
`addInitScript({ content })`. The minimal Chromium check passes with no pageerror,
|
||||
all observation kinds present and 200 entries retained after saturation. Snapshot
|
||||
failure is isolated from the test assertion and page closure. UI typecheck and
|
||||
diff-check pass; assertions, gestures, timeouts and product remain unchanged.
|
||||
See `issue-824-browser-trace-serialization-fix.md`. The recorded full gate stays
|
||||
red; there has been **no full-suite or feature-case rerun** after this correction.
|
||||
|
||||
Coordinator checkpoint, 2026-10-02 00:03 CEST: all **26 assigned tasks are
|
||||
terminal completed**, no outstanding task, and no matching browser runner was
|
||||
observed in the read-only Windows process check. A fresh hash comparison of all
|
||||
**1,696 executable inputs**, including current tracked/untracked paths, against
|
||||
the final gate finds exactly one difference: `git-history.test.ts`, SHA256
|
||||
`dc7c378cc15e54f22f5d7e1a4c485313f0af9df536e1544690a33e3b2b2e5264`.
|
||||
No product, configuration or script drift is found. This checkpoint is not a new
|
||||
test execution or retroactive all-green verdict.
|
||||
|
||||
Next decision, not work running in the background: an acceptance run on the
|
||||
corrected test composition is still needed for an all-green full-browser claim.
|
||||
No automatic retry, extra audit or deployment is launched at this checkpoint.
|
||||
The exact original #824 cause, earlier MCP/cache failures and natural store
|
||||
cleanup remain unqualified; the mission is not finished as a successful fix.
|
||||
Cross-host traces remain to be collected
|
||||
according to `issue-824-browser-http.md`. No new product patch is justified solely
|
||||
by a passing retry. No commit/push/merge/install/restart/session cleanup is
|
||||
authorized by this checkpoint.
|
||||
|
||||
## Draft PR handoff authorized by the user
|
||||
|
||||
2026-10-02: the user requested a PR containing all mission changes. Deliver the
|
||||
63 reviewed product/test/script/report paths on `fix/issue-824-performance` to
|
||||
`dev` as a **draft**, without a closing keyword for #824. Preserve every recorded
|
||||
failed run and qualification limit. The draft is the reviewable deliverable, not
|
||||
a claim that all gates pass or the reported freeze is universally resolved.
|
||||
|
||||
All 26 investigation/correction/review tasks are complete and no test runner is
|
||||
active at the handoff checkpoint. The durable mission's nonterminal status does
|
||||
not mean that an agent or command is still executing: acceptance is incomplete.
|
||||
No automatic retry or extra general audit accompanies publication. Acceptance
|
||||
still needs the corrected test composition qualified and the cross-host limits
|
||||
resolved or explicitly accepted; merge/install/restart/session cleanup remain
|
||||
outside this publication authorization.
|
||||
|
||||
Modified source files above the size guideline (no size-only refactor):
|
||||
`packages/server/src/server/http-server.ts` ~2,346 lines,
|
||||
`packages/server/src/workspaces/manager.ts` ~1,240,
|
||||
`packages/ui/src/stores/instances.ts` ~2,093,
|
||||
`packages/ui/src/stores/opencode-data.ts` ~828,
|
||||
`packages/ui/src/components/tool-call/renderers/task.tsx` ~569.
|
||||
189
dev-docs/issue-824-audit-continuity.md
Normal file
189
dev-docs/issue-824-audit-continuity.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Issue #824 — continuité de l'audit performance
|
||||
|
||||
- Date : 2026-10-01
|
||||
- Baseline examinée : `366830f6` (`origin/dev`)
|
||||
- Audit repris : worktree `audit-resume-integration`, baseline `47f9e43e`
|
||||
|
||||
## Portée et niveaux de preuve
|
||||
|
||||
Diagnostic en lecture seule du produit. Aucun daemon, profil, desktop ou stockage
|
||||
utilisateur n'a été démarré ou modifié. Le seul exercice exécuté ici est un micro-
|
||||
benchmark local de la primitive d'authentification, sans réseau ni fichier partagé.
|
||||
|
||||
Les affirmations ci-dessous portent explicitement l'un des niveaux suivants :
|
||||
|
||||
- **Mesure exécutée** : observation chronométrée pendant cette mission ou mesure A/B
|
||||
conservée par l'audit précédent avec protocole et artefact décrits.
|
||||
- **Reproduction fonctionnelle antérieure** : assertion rouge/verte exécutée par
|
||||
l'audit précédent, sans nouvelle mesure de latence dans cette mission.
|
||||
- **Analyse statique** : chemin de code observé ; coût ou impact encore à mesurer.
|
||||
- **Hypothèse** : mécanisme plausible à tester, pas un défaut confirmé.
|
||||
|
||||
Les diagnostics spécialisés de compactage et de transport HTTP Linux/Windows sont
|
||||
hors de cette carte. Les priorités proposées sont transversales et complémentaires.
|
||||
|
||||
## Continuité entre l'audit et la baseline courante
|
||||
|
||||
L'audit précédent n'était pas un commit : `audit/resume-integration` est toujours à
|
||||
`47f9e43e` avec ses corrections en fichiers modifiés/non suivis. Entre `47f9e43e` et
|
||||
`366830f6`, les 16 commits de `origin/dev` concernent notamment Files, responsive,
|
||||
usage, OpenCode 2.0.20/2.0.21 et Tauri macOS. Ils ne constituent pas l'intégration
|
||||
générale de l'audit.
|
||||
|
||||
Contrôle objet Git effectué : les dix fichiers sentinelles suivants ont exactement
|
||||
le même blob à `47f9e43e` et à `366830f6` :
|
||||
|
||||
| Surface auditée | Blob encore présent à `366830f6` | État |
|
||||
| --- | --- | --- |
|
||||
| rendu des tâches imbriquées, `packages/ui/src/components/tool-call/renderers/task.tsx` | `dee90df8560b83958dbf6d6f7c40e013bbdb289f` | correction audit absente |
|
||||
| distribution SSE UI, `packages/ui/src/lib/server-events.ts` | `29d8342071fee3fc1ab3a2f7bc71c847bc68f8a8` | correction audit absente |
|
||||
| itérateurs/relay OpenCode, `packages/server/src/workspaces/opencode-service.ts` | `ad8cfbaa00ebce3cc47e9a70217244217cb8b3dd` | correction audit absente |
|
||||
| Speech/Sidecars, `packages/ui/src/stores/speech.ts`, `sidecars.ts` | `45832e4c…`, `d52a8d5f…` | corrections audit absentes |
|
||||
| fermeture multi-fenêtres Electron, `packages/electron-app/electron/main/multiwindow-lifecycle.ts` | `59995dfd1b36cc4d4eee742c4798477cb5b59b9b` | correction audit absente |
|
||||
| historique pruning, `packages/server/src/opencode/session-pruning/history-database.ts` | `37318db55235fb9b50ffd2efb60a154495f6967a` | correction audit absente |
|
||||
| polling provider, `packages/ui/src/components/provider-auth/provider-manager-modal.tsx` | `c1e977a70acaa649ab81d3dba5d6019c693f4f09` | correction audit absente |
|
||||
| lecture conversation, `packages/ui/src/stores/conversation-speech.ts` | `67a12d80c070874f222b24b7b44dfd400beb51a6` | correction audit absente |
|
||||
| auth manager, `packages/server/src/auth/manager.ts` | `4e234ace6fc1b94ebb8543617d7d8048239135db` | correction audit absente |
|
||||
|
||||
`packages/server/src/server/http-server.ts` a changé dans #811/#814, mais la
|
||||
comparaison avec le worktree d'audit montre encore un delta : ces commits ne sont
|
||||
pas l'intégration de la composition preview/redaction auditée.
|
||||
|
||||
### Ce que l'audit avait réellement qualifié
|
||||
|
||||
- **Rendu imbriqué** : mesure A/B historique sur 80 étapes `read` de 8 000 caractères,
|
||||
30 échantillons par variante. Médiane main-thread `369,98 -> 20,64 ms` par delta,
|
||||
remounts DOM `80 retirés + 80 insérés -> 0`; 80 snapshots natifs restent. Protocole,
|
||||
variance et artefact dans `AUDIT_RENDERING_COST.md:40-80`. Deux assertions rouges
|
||||
sur la source baseline et 63 tests navigateur verts sur la correction
|
||||
(`AUDIT_RENDERING_COST.md:128-145`). **Mesure historique, pas rejouée sur 366830f6.**
|
||||
- **Isolation événements** : 50 tests SDK/HTTP/relay/server et 4 régressions Chromium
|
||||
verts sur la correction ; un destinataire relay lent n'y bloque pas le destinataire
|
||||
rapide (`AUDIT_UPSTREAM_DEV_2026_09_30.md:193-208`). La correction isole exceptions
|
||||
et fins locales d'itérateur, pas un callback UI qui consomme le CPU.
|
||||
**Reproduction fonctionnelle antérieure, pas mesure de latence.**
|
||||
- Les correctifs Speech/Sidecars, provider polling, pruning/history et fermeture
|
||||
concurrente ont eux aussi été qualifiés dans le worktree d'audit, mais aucun signal
|
||||
ne permet de les compter comme intégré à `366830f6`. Ils restent des correctifs de
|
||||
robustesse/lifecycle, pas les premières reproductions performance de #824.
|
||||
- L'audit a réfuté dans son fixture réel le montage de tout le transcript/timeline et
|
||||
le DOM non borné des gros outputs (`AUDIT_RENDERING_COST.md:83-100`). Ne pas rouvrir
|
||||
ces trois pistes sans nouvelle preuve contraire.
|
||||
|
||||
## Priorités résiduelles bornées
|
||||
|
||||
### P1 — Rejouer puis intégrer la correction de rendu des tâches imbriquées
|
||||
|
||||
**Niveau : mesure historique confirmée ; correction absente de la baseline.**
|
||||
|
||||
Le code courant recrée le rendu complet via un `createMemo` dans
|
||||
`task.tsx:54-99`, et vide encore les clés à `task.tsx:236-243` pendant une
|
||||
réindexation. Chaque `ToolCall` clone aussi profondément son entrée à la création et
|
||||
à chaque changement de versions (`packages/ui/src/components/tool-call.tsx:630-640`).
|
||||
Cela correspond exactement au chemin mesuré par l'audit. Le coût résiduel des 80
|
||||
`structuredClone` est lui-même mesuré, mais son optimisation n'est pas validée.
|
||||
|
||||
**Plus petite prochaine reproduction :** récupérer sans l'élargir le fixture
|
||||
`scripts/test-render-cost-audit.mjs` et `packages/ui/tests/browser/render-cost.test.ts`
|
||||
du worktree audit, exécuter d'abord l'override rouge sur le blob
|
||||
`dee90df8560b…`, puis A/B sur `366830f6` et sur la correction auditée. Conserver les
|
||||
assertions d'identité DOM comme gate ; ne pas introduire de seuil timing CI instable.
|
||||
|
||||
### P2 — Isoler les abonnés SSE et mesurer le temps de distribution
|
||||
|
||||
**Niveau : défaut fonctionnel reproduit antérieurement ; risque CPU statique.**
|
||||
|
||||
La baseline distribue ouverture, événements et statuts avec des `Set.forEach`
|
||||
synchrones sans frontière par callback (`packages/ui/src/lib/server-events.ts:69-76`,
|
||||
`:151-170`). L'audit a confirmé qu'une exception d'un abonné supprime les abonnés
|
||||
suivants et peut interrompre un restart. Cela peut ressembler à une application figée
|
||||
alors que le transport reste vivant. En revanche, aucune preuve ne montre encore un
|
||||
callback lent bloquant durablement le renderer ; l'audit précise que ce cas n'est pas
|
||||
isolé.
|
||||
|
||||
**Plus petite prochaine reproduction :** réimporter le fixture
|
||||
`event-subscriber-isolation.test.ts`, prouver rouge/vert sur l'exception, puis ajouter
|
||||
un lot borné d'événements avec un abonné instrumenté volontairement lent. Mesurer
|
||||
durée de `dispatchBatch`, retard d'un `requestAnimationFrame` sentinelle et abonnés
|
||||
appelés. Le premier correctif utile reste la petite barrière `try/catch` déjà auditée ;
|
||||
une planification asynchrone ne doit être envisagée qu'après mesure car elle change
|
||||
l'ordre/la sémantique Solid `batch`.
|
||||
|
||||
### P3 — Retirer `scryptSync` du thread HTTP d'authentification
|
||||
|
||||
**Niveau : mesure exécutée et analyse du chemin HTTP.**
|
||||
|
||||
`verifyPassword` et `hashPassword` utilisent `crypto.scryptSync`
|
||||
(`packages/server/src/auth/password-hash.ts:23-28`, `:37-48`). Le POST login appelle
|
||||
ce chemin synchroniquement via `AuthManager.validateLogin`
|
||||
(`packages/server/src/auth/manager.ts:74-79`; route dans
|
||||
`packages/server/src/server/routes/auth.ts:99+`). Pendant ce calcul, le thread Node
|
||||
partagé ne sert ni autres requêtes ni événements des autres sessions.
|
||||
|
||||
Micro-benchmark exécuté sur Windows, Node `v25.2.1`, paramètres de production
|
||||
`N=16384,r=8,p=1`, 10 vérifications séquentielles : médiane **19,56 ms**, plage
|
||||
**19,12–21,60 ms**. C'est une durée de blocage de primitive, pas une mesure Fastify,
|
||||
desktop ou charge concurrente. Une rafale de tentatives multiplie directement ce
|
||||
blocage ; l'impact réel et les limites de taux ne sont pas qualifiés ici.
|
||||
|
||||
**Plus petite prochaine reproduction :** fixture Fastify inject/loopback isolée avec
|
||||
un heartbeat de latence pendant une rafale bornée de logins invalides. Mesurer p50/p95
|
||||
du heartbeat et durée login avant/après passage à `crypto.scrypt` asynchrone, en
|
||||
conservant comparaison constante, paramètres et réponses d'auth identiques. Couvrir
|
||||
aussi `setPassword`, qui appelle `hashPassword` sur le même thread.
|
||||
|
||||
### P4 — Borner le coût renderer + disque de la persistance d'état client
|
||||
|
||||
**Niveau : analyse statique ; hypothèse de freeze à reproduire.**
|
||||
|
||||
Le renderer recapture l'état après 100 ms d'activité
|
||||
(`packages/ui/src/lib/hooks/use-app-session-capture.ts:124-168`), puis la couche état
|
||||
reprogramme une sauvegarde après 250 ms (`packages/ui/src/stores/client-state.ts:10`,
|
||||
`:85-128`, `:139-145`). Chaque sauvegarde normalise, encode tout le graphe et contrôle
|
||||
jusqu'à 4 096 partitions / 256 MiB autorisés
|
||||
(`client-state-partitions.ts:8-15`, `:188-220`). Côté Electron, chaque mutation clone
|
||||
l'enveloppe avec `JSON.parse(JSON.stringify(...))`, la resérialise, l'écrit, rename et
|
||||
fsync le répertoire (`packages/electron-app/electron/main/client-state.ts:628-692`).
|
||||
Le chemin normal est asynchrone côté disque ; les migrations/cross-host utilisent
|
||||
encore des opérations sync, mais surtout au démarrage/récupération, pas à chaque save.
|
||||
|
||||
Aucun profil de gros état ou disque lent n'a été exécuté : **ne pas décrire cette
|
||||
piste comme cause confirmée**.
|
||||
|
||||
**Plus petite prochaine reproduction :** fixture Electron/module isolée générant des
|
||||
états réalistes à 1, 16, 64 et 256 MiB, avec churn de draft/layout à 10 Hz. Instrumenter
|
||||
séparément `encodeClientSnapshotV2`, IPC, file d'écriture, retard event-loop renderer
|
||||
et main, taille/compte de partitions et durée de flush. Simuler un writer lent injecté,
|
||||
sans disque utilisateur. Le résultat dira s'il faut du partage structurel, un worker,
|
||||
une fréquence moindre ou uniquement une borne plus basse.
|
||||
|
||||
### P5 — Mesurer la rafale périodique de réconciliation multi-instance
|
||||
|
||||
**Niveau : analyse statique ; hypothèse de contention réseau/réactive.**
|
||||
|
||||
Toutes les 30 secondes, `App.tsx:276-289` parcourt simultanément toutes les instances
|
||||
`ready`. Même sans session active, `runPendingRequestLiveness` finit par
|
||||
`syncPendingRequests` (`packages/ui/src/stores/instances.ts:864-887`), lequel lance en
|
||||
parallèle scans permissions et Forms, avec jusqu'à trois reprises de stabilisation
|
||||
(`instances.ts:795-817`). Les appels sont coalescés par instance, mais pas étalés entre
|
||||
instances. Des horloges UI à 1 s existent aussi dans `session-list.tsx:117-121`,
|
||||
`instance-shell2.tsx:375-379` et `instance-tab.tsx:28`; leur coût n'est pas mesuré.
|
||||
|
||||
**Plus petite prochaine reproduction :** monter 1/5/20 instances contre clients fake,
|
||||
compter requêtes et concurrence au tick 30 s, puis mesurer retard RAF et commits Solid
|
||||
avec zéro puis plusieurs Forms/permissions. Tester visibilité/inactivité séparément.
|
||||
Ne modifier intervalle, staggering ou demande visible qu'après avoir établi une pente
|
||||
avec le nombre d'instances.
|
||||
|
||||
## Ordre recommandé
|
||||
|
||||
1. Rejouer P1 : gain historique majeur et patch déjà borné/qualifié.
|
||||
2. Rejouer P2 : évite les faux freezes d'état sans toucher au transport.
|
||||
3. Corriger P3 si le test Fastify confirme le retard concurrent ; le mécanisme bloquant
|
||||
est déjà mesuré.
|
||||
4. Profiler P4 et P5 en parallèle conceptuel, mais ne promouvoir un correctif qu'avec
|
||||
une mesure par phase et un fixture de non-régression.
|
||||
|
||||
Cette carte ne prétend pas expliquer le freeze de compactage ni le blocage HTTP
|
||||
Linux/Windows persistant après restart. Elle évite également de compter comme
|
||||
intégrées les corrections seulement présentes dans `audit/resume-integration`.
|
||||
193
dev-docs/issue-824-auth-responsiveness.md
Normal file
193
dev-docs/issue-824-auth-responsiveness.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# #824 — coopération du serveur pendant les calculs de mot de passe
|
||||
|
||||
## Périmètre et résultat
|
||||
|
||||
Tâche initiale `auth-loop-responsiveness`, branche `fix/issue-824-auth`, worktree
|
||||
`D:\CodeNomad\.codenomad\worktrees\issue-824-auth`, baseline `366830f6`.
|
||||
Intégration `integrate-auth` dans
|
||||
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` : sept fichiers
|
||||
autorisés seulement, via patch tool, sans réinstallation des dépendances.
|
||||
|
||||
Défaut confirmé puis corrigé : les routes de login et de changement de mot de
|
||||
passe exécutent une dérivation scrypt coûteuse sur le thread HTTP partagé.
|
||||
Les dérivations HTTP utilisent maintenant `crypto.scrypt`, sur le pool libuv,
|
||||
et leurs callers attendent le résultat. Aucun changement logout, compaction,
|
||||
transport SSE, daemon OpenCode ou configuration utilisateur par ces tâches.
|
||||
|
||||
## Reproduction comportementale rouge
|
||||
|
||||
`packages/server/src/server/routes/auth-responsiveness.test.ts` utilise le vrai
|
||||
Fastify, les vraies routes et un vrai AuthManager. Chaque fixture crée un
|
||||
`auth.json` v1 privé sous `%TEMP%/opencode/codenomad-auth-responsive-*`, avec un
|
||||
hachage produit indépendamment par l'ancienne API synchrone. Elle ferme Fastify
|
||||
et supprime uniquement son propre répertoire à la fin.
|
||||
|
||||
Deux rafales bornées : 8 logins invalides, puis 4 changements authentifiés de
|
||||
mot de passe. À l'entrée du premier calcul, un `setImmediate` programme un GET
|
||||
sur un endpoint témoin, qui observe le nombre d'opérations encore en cours.
|
||||
L'assertion exige que le témoin s'exécute **avant la fin de tous les calculs**.
|
||||
Les durées ne sont que des diagnostics, sans assertion de seuil en ms.
|
||||
Le corps des méthodes, le calcul cryptographique et les handlers ne sont pas
|
||||
remplacés ; les wrappers ne font que compter et chronométrer.
|
||||
|
||||
Avant modification produit, les deux tests ont échoué : témoin après les
|
||||
8/8 logins (~169 ms), et après les 4/4 changements (~84 ms). Réponses correctes
|
||||
mais aucun travail témoin pendant la rafale. Chaque appel bloquait ~21 ms.
|
||||
|
||||
Après modification, l'ancien code a été recopié par `git show 366830f6:<path>`
|
||||
dans une fixture privée sous le `node_modules/.cache` du worktree **source**,
|
||||
avec les tests courants. Les deux mêmes assertions échouent dans chacune des
|
||||
5 exécutions rouges. Aucun checkout ni fichier produit n'a été inversé pour
|
||||
ce contrôle.
|
||||
|
||||
Fixture baseline retenue localement (non versionnée, dans `issue-824-auth`,
|
||||
non recopiée dans le worktree mission) :
|
||||
`node_modules/.cache/auth-baseline-4193325c8f9a4bcd973718259e94c2f6`.
|
||||
Elle contient `auth/{auth-store,manager,password-hash,http-auth,token-manager,session-manager}.ts`,
|
||||
`server/routes/auth.ts` de la baseline et le test de réactivité courant ; les
|
||||
imports de dépendances utilisent seulement le node_modules du worktree source.
|
||||
|
||||
## Mesures exécutées rouge / vert (tâche initiale)
|
||||
|
||||
Windows x64, Node `v25.2.1`, pool libuv par défaut (4), mêmes paramètres scrypt.
|
||||
Cinq processus Node distincts par variante, tests témoins seuls ; pas de seuil
|
||||
de performance ni de conclusion statistique au-delà de ces échantillons.
|
||||
|
||||
| Mesure (médiane des 5 runs) | Baseline | Correctif initial |
|
||||
| --- | ---: | ---: |
|
||||
| Témoin pendant les 8 logins | 168,63 ms | 3,37 ms |
|
||||
| Durée totale des 8 logins | 179,23 ms | 67,33 ms |
|
||||
| Témoin pendant les 4 changements | 82,43 ms | 0,80 ms |
|
||||
| Durée totale des 4 changements | 84,23 ms | 93,57 ms |
|
||||
| État au témoin : logins encore en cours | 0/8 | 8/8 |
|
||||
| État au témoin : changements encore en cours | 0/4 | 4/4 |
|
||||
|
||||
Échantillons témoin, en ms, dans l'ordre des runs :
|
||||
|
||||
- Rouge login : 187,0279 ; 163,5008 ; 161,1307 ; 168,6303 ; 173,3027.
|
||||
- Vert login : 4,6347 ; 3,3659 ; 2,9695 ; 3,1687 ; 3,7206.
|
||||
- Rouge password : 91,9619 ; 82,9719 ; 82,2616 ; 82,4302 ; 82,1526.
|
||||
- Vert password : 1,1573 ; 0,6115 ; 0,6394 ; 0,7956 ; 1,6040.
|
||||
|
||||
La sérialisation des changements vise la cohérence, pas leur débit. Leur temps
|
||||
total n'est pas amélioré ; le témoin et donc la coopération du thread HTTP le
|
||||
sont. Le coût CPU scrypt n'est pas supprimé.
|
||||
|
||||
## Correction et garanties
|
||||
|
||||
- Format v1, sel aléatoire 16 octets, clé 64 octets, paramètres par défaut
|
||||
`N=16384, r=8, p=1, maxmem=32 MiB` inchangés.
|
||||
- Vérification avec les paramètres enregistrés et comparaison
|
||||
`crypto.timingSafeEqual` conservées ; longueur incompatible et algorithme
|
||||
inconnu restent des refus. Les erreurs crypto deviennent des rejets attendus.
|
||||
- Login attend le booléen, sinon une Promise truthy aurait autorisé les mauvais
|
||||
mots de passe. Password attend le calcul **et la persistence** avant succès,
|
||||
et attrape les rejets pour conserver la réponse 409 text/plain.
|
||||
- AuthStore sérialise uniquement les changements de mot de passe par ordre
|
||||
d'admission. Un échec ne bloque pas les suivants ; le bootstrap ne perd pas
|
||||
son identité entre deux calculs ; le dernier changement admis gagne.
|
||||
- Lors de l'intégration, une fence d'identité du record d'autorité a été ajoutée
|
||||
après la dérivation : `valid && this.load() === auth`. Un changement réussi
|
||||
remplace le record cached ; un échec ne le remplace pas. Une dérivation sur
|
||||
un ancien record ne peut plus accorder de session après son remplacement.
|
||||
Aucun nouveau calcul ni replay automatique du login.
|
||||
- Désactivation d'auth, override CLI/env et cookies conservent leur sémantique.
|
||||
- Le hachage synchrone de l'override CLI/env reste explicitement nommé
|
||||
`hashPasswordSync`, appelé seulement par `ensureInitialized` au constructeur,
|
||||
avant le service HTTP. Cela évite d'élargir cette correction au cycle de
|
||||
démarrage. Les lectures/petites écritures auth.json synchrones ne changent pas.
|
||||
- Recherche des callers effectuée dans le dépôt : les deux handlers HTTP sont
|
||||
les seuls callers produit de validateLogin/setPassword ; les await ont été
|
||||
ajoutés. Le typecheck serveur valide cette propagation.
|
||||
- Aucun rate limiter explicite n'a été trouvé dans les routes/auth courantes :
|
||||
aucun mécanisme existant n'est retiré, et aucun nouveau n'est introduit ici.
|
||||
|
||||
## Validation et commandes
|
||||
|
||||
```powershell
|
||||
node --import tsx --test packages/server/src/auth/password-hash.test.ts packages/server/src/server/routes/auth-responsiveness.test.ts packages/server/src/server/routes/auth.test.ts
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Tâche initiale : 8 tests verts, répétés cinq fois (40/40 exécutions de tests),
|
||||
puis 5 runs verts supplémentaires des seuls tests témoins. Typecheck serveur
|
||||
vert. Diff check sans erreur (avertissements Git habituels LF/CRLF seulement).
|
||||
|
||||
Intégration : les sept chemins cibles étaient sans modifications au départ.
|
||||
Les quatre fichiers produit et les deux tests ont été repris via patch tool ;
|
||||
seuls `auth-store.ts` et le test de réactivité ont été enrichis pour la course
|
||||
de changement de mot de passe. Le rapport source est repris et actualisé ici.
|
||||
Les changements voisins de mission ne sont pas touchés.
|
||||
|
||||
### Course login / changement de mot de passe : rouge puis vert déterministe
|
||||
|
||||
Le test `rejects a pending login verified against a password replaced before
|
||||
verification returns` fait réellement dériver l'ancien mot de passe par
|
||||
`crypto.scrypt`, mais retient son callback. Il enregistre ensuite un nouveau
|
||||
mot de passe par la vraie route `/api/auth/password` et vérifie le nouveau
|
||||
secret, avant de libérer l'ancien callback. Il ne suppose aucune durée ni
|
||||
ordre implicite du scheduler. Le mock est restauré et les requêtes privées
|
||||
sont drainées avant fermeture.
|
||||
|
||||
Sur le correctif initial intégré sans fence : échec attendu, **200 au lieu de
|
||||
401**, après le succès de la mutation password. Après ajout de la fence :
|
||||
**401 Invalid credentials sans Set-Cookie**, nouveau secret valide, ancien
|
||||
secret refusé. L'authentification ne réutilise pas une autorité remplacée.
|
||||
|
||||
La suite intégrée passe **9/9 tests**, dont les 8 tests précédents et ce nouveau
|
||||
cas ; elle a été rejouée trois fois supplémentaires (27/27 verts).
|
||||
Typecheck serveur vert. Exemple de diagnostics au run intégré : témoin
|
||||
login 3,29 ms avec 8 opérations encore en cours ; password 0,52 ms avec 4 en
|
||||
cours. Ces mesures sont descriptives et séparées du tableau A/B initial.
|
||||
|
||||
```powershell
|
||||
node --import tsx --test --test-name-pattern='rejects a pending login' packages/server/src/server/routes/auth-responsiveness.test.ts
|
||||
```
|
||||
|
||||
Commande témoin courte, utilisable sur le correctif ou avec le chemin du test
|
||||
dans la fixture baseline source ci-dessus :
|
||||
|
||||
```powershell
|
||||
node --import tsx --test --test-name-pattern='services a witness' packages/server/src/server/routes/auth-responsiveness.test.ts
|
||||
```
|
||||
|
||||
Couverture : ancien hachage v1, paramètres personnalisés, nouveau hachage lu
|
||||
par scryptSync indépendant, mot de passe et utilisateur invalides, login valide
|
||||
et cookie, refus de password non authentifié, erreurs override/désactivation,
|
||||
bootstrap concurrent, ordre des changements, échec persistence puis reprise,
|
||||
réactivité login/password, autorité remplacée pendant une vérification et
|
||||
compatibilité du test login HTML existant.
|
||||
|
||||
Installation isolée initiale des dépendances par `npm ci --ignore-scripts`
|
||||
(lockfile inchangé). npm signale 49 vulnérabilités préexistantes ; pas de
|
||||
`npm audit fix` ni de mise à jour hors périmètre. Aucune réinstallation lors
|
||||
de l'intégration ; dépendances de mission existantes uniquement.
|
||||
|
||||
## Limites et suite minimale
|
||||
|
||||
- Les requêtes passent par `Fastify.inject`/light-my-request, pas par un socket
|
||||
réseau ni Chromium. Ceci isole le défaut du thread HTTP, sans prétendre
|
||||
reproduire la saturation HTTP Linux→Windows, SSE ou compaction.
|
||||
- Scrypt utilise toujours du CPU/de la mémoire et partage le pool libuv avec
|
||||
d'autres opérations. La saturation du pool et les rafales non bornées ne sont
|
||||
pas qualifiées ici ; une politique d'admission serait un travail séparé.
|
||||
- La fence compare l'autorité cached/override de ce processus, comme le store
|
||||
existant. Elle ne crée pas de surveillance des changements externes du
|
||||
fichier auth.json ni de révocation des sessions déjà accordées.
|
||||
- Aucun serveur utilisateur, daemon partagé, profil, desktop ou base native
|
||||
OpenCode utilisé ; aucun restart, déploiement, commit ou push.
|
||||
- Intégration effectuée ; revue indépendante et validation finale de mission
|
||||
restent nécessaires. Aucune correction logout ou politique rate-limit.
|
||||
|
||||
## Fichiers du patch
|
||||
|
||||
1. `packages/server/src/auth/password-hash.ts`
|
||||
2. `packages/server/src/auth/auth-store.ts`
|
||||
3. `packages/server/src/auth/manager.ts`
|
||||
4. `packages/server/src/server/routes/auth.ts`
|
||||
5. `packages/server/src/auth/password-hash.test.ts` (nouveau)
|
||||
6. `packages/server/src/server/routes/auth-responsiveness.test.ts` (nouveau)
|
||||
7. `dev-docs/issue-824-auth-responsiveness.md` (ce rapport)
|
||||
|
||||
Tous les fichiers source/test modifiés restent sous les seuils AGENTS.
|
||||
198
dev-docs/issue-824-browser-final-boundary-gate.md
Normal file
198
dev-docs/issue-824-browser-final-boundary-gate.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# #824 — gate navigateur final unique
|
||||
|
||||
## Verdict terminal
|
||||
|
||||
**Gate non vert : 360 tests, 358 pass, 1 fail, 1 skip, 0 cancelled.**
|
||||
Commande normative exécutée **une seule fois**, exit 1 **naturel**, sans timeout ni
|
||||
force-exit. Zéro dérive des sources. Travail de validation terminé et rapporté,
|
||||
pas acceptation globale. Aucun retry/ciblé ou second fullbrowser après ce rouge.
|
||||
|
||||
L'échec unique est `git-history.test.ts:684`, sur le contrôle final `errors=[]` :
|
||||
un pageerror `ReferenceError: __name is not defined` vient d'un script navigateur
|
||||
anonyme. Ce n'est **pas** l'ancien échec d'identité du focus ni le clic Monaco.
|
||||
Le snapshot opt-in est présent mais sa chronologie est vide. Le cache HTML et le
|
||||
cas MCP précédemment rouges passent ici, sans démontrer leur résolution causale.
|
||||
Le gate historique 356/3/1 et les diagnostics ciblés restent des pièces séparées.
|
||||
|
||||
## Admission fraîche
|
||||
|
||||
Workdir exact : `D:/CodeNomad/.codenomad/worktrees/issue-824-performance`.
|
||||
HEAD `366830f6b4373e6b283cabf7dd04764e87e1cc32`, changements mission non committés.
|
||||
Rapports `issue-824-browser-focus-test-sync.md` et
|
||||
`issue-824-browser-mcp-critical-capture.md` lus, leurs tâches terminées.
|
||||
Contrôle processus Node read-only préalable : aucun runner navigateur concurrent
|
||||
repéré ; résultat et heure conservés dans `process-preflight.json` et
|
||||
`preflight-time.txt`.
|
||||
|
||||
Nouveau manifeste **1 696 fichiers**, tracked + untracked non ignorés : sources
|
||||
produit, tests, scripts, configs et locks ; exclusions docs/Markdown/PDF,
|
||||
dist/public/deps/cache/release/target. Comparaison avec `combined/.../initial.json` :
|
||||
**exactement les trois inputs autorisés de focus-sync** ont changé :
|
||||
|
||||
- `packages/ui/tests/browser/git-history.test.ts` ;
|
||||
- `packages/ui/tests/browser/session-rendering.test.ts` ;
|
||||
- `packages/ui/tests/browser/fixtures/user-html.tsx`.
|
||||
|
||||
Comparaison des hashes de fichiers avec `mcp-critical/.../terminal-manifest.json` :
|
||||
**aucun écart**. Aucun autre changement à déclarer ou autoriser ; hashes produit
|
||||
inchangés depuis la validation combinée. `admission.json` conserve tous les hashes
|
||||
anciens/nouveaux, chemins de référence et décisions. `browser-before.json` est
|
||||
identique à l'admission ; le runner aurait refusé une dérive avant lancement.
|
||||
|
||||
SHA256 nouveau `initial.json` :
|
||||
`da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
|
||||
SHA256 combined initial historique :
|
||||
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
|
||||
Le manifeste MCP map-only est un format différent mais les hashes de fichiers
|
||||
correspondent exactement : SHA
|
||||
`4628acfc4df16f8e573da48dab23df3614a7a8a55643466e5e6e8e4f6e80105b`.
|
||||
|
||||
## Exécution normative et confinement
|
||||
|
||||
```powershell
|
||||
$env:CODENOMAD_BROWSER_BOUNDARY_TRACE = '1'
|
||||
npm run test:browser --workspace @codenomad/ui
|
||||
```
|
||||
|
||||
Le script npm inchangé exécute
|
||||
`node --import tsx --test --test-concurrency=1 tests/browser/*.test.ts`.
|
||||
Pas de filtre, de liste privée substituée, de nouveaux délais/assertions/gestes,
|
||||
ni de policy/cleanup du cache Vite. La capture MCP privée n'est **pas** insérée
|
||||
dans ce fullbrowser : ses cinq passes restent ciblées/historiques, distinctes du
|
||||
test MCP normal exécuté ici. Le trace opt-in préparé active uniquement les surfaces
|
||||
focus/cache existantes. Aucun patch produit/test/fixture/script/config/deps/lock.
|
||||
|
||||
Le runner privé invoque la commande npm exacte via `cmd.exe /d /s /c`, depuis le
|
||||
workdir exact, et conserve stdout/stderr sans filtrage dans `browser.log`.
|
||||
Budget 60 minutes, exécution background avec notification de fin, pas de polling
|
||||
répétitif. `started.json` créé en mode exclusif interdit un second lancement.
|
||||
Si timeout, seule l'arborescence PID de ce runner npm privé est arrêtée, jamais le
|
||||
daemon partagé ; timeout/forced se distinguent d'une sortie naturelle.
|
||||
|
||||
Artefacts :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-final-0e81ac45520348328ab5bc06252a2010/`.
|
||||
Runner `validate.cjs` SHA256 :
|
||||
`2d31356e2f67899fc7a1207e145feb411907ff6f4bdc6bffa2842f12619e599d`.
|
||||
Shell background : `sh_0f95dae060013DD7dMlptG7IAp`.
|
||||
|
||||
Aucun install, commit/push/merge, restart/déploiement, suppression de sessions,
|
||||
accès au daemon/base/profil utilisateur, sous-agent ou autre gate. Les seuls edits
|
||||
du validateur pour cette tâche sont ce rapport et les artefacts TEMP privés.
|
||||
|
||||
## Limites invariantes
|
||||
|
||||
Serveur/build/typechecks/A-B antérieurs restent historiques ; pas de nouvelle
|
||||
exécution nécessaire ici puisque produit/scripts/locks sont identiques. Ce gate
|
||||
ne qualifie ni Linux Electron → Windows LAN/OpenCode 2.0.19, ni la sortie naturelle
|
||||
des stores. Un éventuel vert ne prouve pas l'élimination universelle des freezes
|
||||
ni la cause des rouges précédents. Une panne HTML/Monaco/MCP reste rouge sans retry
|
||||
et sans narration d'innocuité supposée.
|
||||
|
||||
Les snapshots sont ceux des tests existants. Si leur évaluation échoue, le log
|
||||
complet doit conserver l'exception et le rapport distinguer `observation-error`
|
||||
de l'échec produit/assertion ; un snapshot absent n'est pas un état vide démontré.
|
||||
La mission et sa livraison terminale restent la décision du coordinateur.
|
||||
|
||||
## Résultat exact et captures
|
||||
|
||||
- Début UTC `2026-10-01T21:27:41.967Z`, fin `2026-10-01T21:47:52.008Z`.
|
||||
- Durée runner **1 210 016 ms**, soit **20 min 10 s** ; durée Node test
|
||||
`1 209 656.4827 ms`. Notification finale reçue, pas statut déduit du dernier test.
|
||||
- Exit 1, signal null, launchError null, timeout false, forcedExit false,
|
||||
naturalExit true. Aucune action d'arrêt du runner nécessaire.
|
||||
- Total 360 = 358 pass + 1 fail + 0 cancelled + 1 skip, 0 todo, 0 suites.
|
||||
- Skip : `Electron BrowserWindow native zoom retains tab seams and matching
|
||||
scrollbars`, opt-in `CODENOMAD_TEST_ELECTRON` absent. Ne pas confondre avec les
|
||||
autres fixtures Electron du corpus qui passent ; pas de qualification Linux/LAN.
|
||||
- Node v25.2.1 Windows x64 ; Chromium par défaut Playwright, version 153.0.8010.12
|
||||
confirmée par les logs HTTP. `NODE_OPTIONS=--enable-source-maps` hérité inchangé.
|
||||
|
||||
### Unique échec — assertion et limite observationnelle distinctes
|
||||
|
||||
Cas : `central diff inserts local lines and revision-qualified history into the
|
||||
real session composer`, durée cas `3316.8763 ms`, déclaration ligne 590.
|
||||
Assertion rouge ligne **684** : expected `[]`, actual :
|
||||
|
||||
```text
|
||||
ReferenceError: __name is not defined
|
||||
at <anonymous>:2:78
|
||||
at <anonymous>:2:1004
|
||||
at <anonymous>:3:7
|
||||
```
|
||||
|
||||
Le cas a atteint le contrôle final après ses assertions de brouillon, références
|
||||
Git, restitution de focus (`:630–632`), sélection de plage, historique de commit,
|
||||
référence du worktree WSL, autorité de session et restauration de draft. Toutes
|
||||
ces assertions ont donc passé **dans cette exécution**, malgré son statut final
|
||||
rouge. Pas de timeout `activeElement`, de détachement/clic Monaco ou de replay.
|
||||
|
||||
Snapshot existant intégral, avant fermeture :
|
||||
|
||||
```text
|
||||
Git focus boundary trace {
|
||||
events: [],
|
||||
active: { tag: 'BODY', id: '', className: 'enable-motion underline-links' },
|
||||
widgets: [],
|
||||
selections: []
|
||||
}
|
||||
```
|
||||
|
||||
**Observation-error distincte** : la collecte opt-in du script d'init n'a pas
|
||||
produit de chronologie exploitable ; `__name` est un binding auxiliaire absent
|
||||
dans le contexte navigateur. Le source de l'instrumentation (`:595–615`) initialise
|
||||
le tableau puis définit un helper nommé `describe` et les listeners. Le mécanisme
|
||||
attendu de transformation/sérialisation d'un helper par tsx/esbuild est cohérent
|
||||
avec cette erreur, mais le JS exact sérialisé n'a pas été capturé : pas de preuve
|
||||
supplémentaire par nouveau test ou expérience de transpilation dans cette tâche.
|
||||
|
||||
Le snapshot lui-même **n'a pas échoué à l'évaluation** : il est enregistré, sans
|
||||
exception page.evaluate dans le résultat final. `events: []` ne démontre donc pas
|
||||
l'absence de gestes/focus : la capture est défaillante. L'état BODY est celui de
|
||||
la fin, après d'autres navigations ; il ne contredit pas l'assertion textarea passée
|
||||
plus tôt. Les widgets absents en fin ne prouvent pas leur détachement pendant clic.
|
||||
La régression observationnelle ne doit être ni attribuée au produit sur hypothèse,
|
||||
ni ignorée pour déclarer ce gate vert.
|
||||
|
||||
### Frontières qui passent dans ce seul gate
|
||||
|
||||
- MCP `reduce, 150% scale` static rest : pass `1619.5347 ms` ; autres trois cas MCP
|
||||
pass également. Test normal, aucune substitution/capture MCP privée insérée.
|
||||
- HTML/cache : `user HTML mode preserves Markdown, pasted disclosures and code
|
||||
source and fences legacy caches` pass `2196.9656 ms`. Aucune exception/snapshot
|
||||
d'échec HTML imprimé, donc pas de nouvelle attribution du timeout ancien.
|
||||
- Les 358 passes restent celles du corpus normal, pas additionnées aux
|
||||
retries ni aux résultats historiques. Les erreurs de bootstrap antérieures
|
||||
restent non attribuées ; aucun claim d'innocuité universelle.
|
||||
|
||||
## Empreintes et livraison terminale
|
||||
|
||||
`browser-before.json` et `browser-after.json` sont strictement identiques à
|
||||
`initial.json`, SHA `da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
|
||||
1 696 fichiers ; `drift: []`, `driftFromInitial: []`, HEAD inchangé.
|
||||
Le runner exécuté conserve son SHA d'admission. L'immuabilité est constatée aux
|
||||
bornes, pas un verrou filesystem pendant le run.
|
||||
|
||||
| Artefact privé | SHA256 |
|
||||
| --- | --- |
|
||||
| `browser.log` brut complet | `f24b7866e58109e029acdda5947caa24ce282f78818ff4ddd0a69b220f845eea` |
|
||||
| `failures.log` extrait intégral du résumé rouge | `7db7e5c1ecfaf693f0540ea5d732df0c1f34435b55465012a5cf7bced8a3b5f9` |
|
||||
| `boundary-output-through-end.log` trace puis suite jusqu'à fin | `fa7ef5a8b580e05cc810ac80af67fce3f18949c5d600e47e4981bdaad3b45b5f` |
|
||||
|
||||
`browser-result.json` conserve commande/statut/heures/durée/exits/manifests ;
|
||||
`summary.json` vérifie compteurs et hashes ; `git-focus-snapshot.log` conserve le
|
||||
snapshot exact, `boundary-observation-status.json` distingue l'observation-error
|
||||
du contrôle `errors=[]` rouge et du snapshot évalué avec succès. Le postprocesseur
|
||||
privé `summarize.cjs` ne lance aucun test/browser et ne filtre pas le log brut.
|
||||
|
||||
Serveur/build/typechecks/A-B ne sont pas rejoués ; leurs preuves historiques sur
|
||||
produit identique restent qualifiées avec leurs limites déjà rapportées. Aucun
|
||||
nouveau gate, correction, cleanup ou déploiement ajouté pour fabriquer du vert.
|
||||
|
||||
## Prochain minimum utile — décision coordinateur
|
||||
|
||||
Remettre ce **gate rouge unique** et les captures au propriétaire de focus-sync :
|
||||
rendre le script opt-in navigateur autosuffisant à la sérialisation (par exemple
|
||||
source JS explicite sans helper Node externe), sans modifier contrat produit,
|
||||
assertions, délais ou gestes. C'est une recommandation bornée, **pas un patch ou
|
||||
une relance autorisée ici**. Le coordinateur décide s'il faut un suivi séparé ou
|
||||
s'il clôture avec cette limite ; aucune nouvelle boucle d'audit/retry du validateur.
|
||||
172
dev-docs/issue-824-browser-focus-cache-diagnosis.md
Normal file
172
dev-docs/issue-824-browser-focus-cache-diagnosis.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Issue 824 — attribution bornée des échecs navigateur focus et cache HTML
|
||||
|
||||
## Verdict
|
||||
|
||||
Diagnostic borné terminé, sans correction produit ni test.
|
||||
|
||||
- **Focus après insertion Git : course de synchronisation du test confirmée par
|
||||
le contrat du produit; défaut produit #824 non démontré.** Le texte est inséré
|
||||
immédiatement, mais la restitution du focus est volontairement programmée dans
|
||||
une tâche `setTimeout(0)`. L'assertion du test ne synchronise pas cette tâche.
|
||||
- **Cache HTML : non reproduit et non attribué.** Le chemin attendu après
|
||||
`literal(false)` est un hit de cache local synchrone, pas une attente du renderer
|
||||
Markdown. Les preuves disponibles ne permettent pas de choisir entre défaut de
|
||||
réactivité ponctuel, cache Vite/optimisation du harness ou scheduling du run
|
||||
complet.
|
||||
- Une panne différente a été reproduite une fois avant la frontière focus : le
|
||||
widget Monaco a été détaché pendant le clic. Elle confirme que cette fixture a
|
||||
une frontière overlay/DOM instable, mais n'explique pas l'assertion
|
||||
`activeElement` originale et ne doit pas être renommée en régression #824.
|
||||
|
||||
Le gate navigateur complet de la validation combinée reste **non vert**. Les
|
||||
succès ciblés présents ne le remplacent pas.
|
||||
|
||||
## Inputs et identité
|
||||
|
||||
Sources du worktree `issue-824-performance`, HEAD `366830f6`, modifications mission
|
||||
non committées. Node `v25.2.1`, Chromium `153.0.8010.12`, rejets non gérés stricts,
|
||||
concurrence de tests 1. Aucun produit, test, fixture, dépendance ou configuration
|
||||
n'a été modifié.
|
||||
|
||||
Artefact privé :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-focus-cache-diagnosis/diagnosis.json`,
|
||||
SHA256 `F9ABE8BEC0A7A4940B9357448A37AC3AAB1A497BC4C8D8267E0495752ADDB5EA`.
|
||||
Il enregistre les quatre répétitions et les SHA256 des huit inputs lus/exécutés.
|
||||
Les quatre hashes test/fixture pris avant les runs sont identiques à ceux de
|
||||
l'artefact final :
|
||||
|
||||
- `git-history.test.ts` : `1F779351772617138C12694D2236FFADCB557BA85E070E64A04C1CBEE7CC049F`;
|
||||
- `fixtures/git-history.tsx` : `6C73B4FC43D1D4C0B32AB52733919751EBA9FB89BA4E905B542D9B8423C1E1EC`;
|
||||
- `session-rendering.test.ts` : `DE5F5AE9E6F70428860FBD9089E7CC8EF3F87965B6DB6E164EDEEE000626B578`;
|
||||
- `fixtures/user-html.tsx` : `904C3CA2B72B4CE7A2272762557C3BD2D3E06FCC7BDDC0310B9D044F5538E660`.
|
||||
|
||||
Les inputs produit effectivement inspectés et fingerprintés sont
|
||||
`monaco-diff-viewer.tsx`, `session-view.tsx`, `prompt-input.tsx` et `markdown.tsx`;
|
||||
leurs hashes complets sont dans l'artefact. Aucune instrumentation source n'a été
|
||||
nécessaire.
|
||||
|
||||
## Focus Git : attribution
|
||||
|
||||
### Frontière exacte
|
||||
|
||||
1. Le bouton overlay empêche le `mousedown` par défaut afin de ne pas voler la
|
||||
sélection Monaco, puis appelle synchroniquement `onRequestInsertContext`.
|
||||
2. `GitDiffView` produit le texte qualifié; `SessionView.handleInsertPreviewComment`
|
||||
appelle `PromptInputApi.insertComment`.
|
||||
3. `insertComment` appelle `insertBlockContent`. Celui-ci fait immédiatement
|
||||
`setPrompt(nextValue)`, puis programme seulement dans `setTimeout(..., 0)` :
|
||||
`textarea.focus()` et `setSelectionRange()`.
|
||||
4. Le test clique, lit deux fois `composer.inputValue()` puis évalue immédiatement
|
||||
`element === document.activeElement`. Les lectures de valeur ne constituent pas
|
||||
une attente contractuelle de la tâche timer.
|
||||
|
||||
Le run complet original a donc démontré exactement ce que ce séquençage autorise :
|
||||
les deux assertions de contenu passent, puis l'identité de focus est encore fausse
|
||||
à l'instant observé. Il n'a démontré ni perte de brouillon, ni mauvaise cible, ni
|
||||
absence durable de restitution du focus.
|
||||
|
||||
### Replays présents et nouveaux
|
||||
|
||||
La validation combinée avait déjà un replay exact vert et une suite voisine verte.
|
||||
Sur quatre nouvelles répétitions bornées :
|
||||
|
||||
- répétitions 1 et 2 : test complet vert;
|
||||
- répétition 3 : n'atteint pas l'assertion; timeout de 30 s dans `insert.click()`.
|
||||
Playwright rapporte que `<html>` intercepte les pointer events, puis que le
|
||||
bouton overlay est détaché pendant le retry;
|
||||
- répétition 4, focus seul : test complet vert.
|
||||
|
||||
Ainsi l'échec `false !== true` original reste non reproduit dans ce suivi, mais sa
|
||||
cause de test est expliquée par une absence de synchronisation observable dans le
|
||||
code. La panne de clic séparée interdit de qualifier la fixture comme parfaitement
|
||||
stable.
|
||||
|
||||
### Disposition minimale
|
||||
|
||||
Ne pas changer le produit sur cette preuve. Si un gate navigateur entièrement vert
|
||||
est exigé, le plus petit ajustement de test est d'attendre causalement que le
|
||||
textarea devienne `document.activeElement`, dans le délai existant, puis conserver
|
||||
l'assertion d'identité. Ne pas ajouter de sleep, force-click ou retry global.
|
||||
|
||||
La panne overlay distincte mérite, seulement si elle récidive au gate de livraison,
|
||||
une trace opt-in de `widgetPosition`, sélection Monaco, bounding box et événements
|
||||
`focusin/focusout`/pointer autour du clic. Elle ne doit pas être masquée par
|
||||
`click({ force: true })`.
|
||||
|
||||
## Cache HTML : attribution
|
||||
|
||||
### Frontière exacte
|
||||
|
||||
Le fixture monte un `Markdown` avec une part dont `renderCache` contient :
|
||||
|
||||
```text
|
||||
mode = 1:escaped:wrap
|
||||
html = <div class="cached-html">old sanitized HTML</div>
|
||||
```
|
||||
|
||||
En mode initial `literal(true)`, le mode résolu est `1:literal:wrap`; ce cache est
|
||||
donc correctement refusé. Après `literal(false)`, avec `escapeRawHtml` toujours
|
||||
vrai, le mode devient exactement `1:escaped:wrap`. L'effet de `Markdown` doit alors
|
||||
prendre `snapshot.part.renderCache`, exécuter `setHtml(localCache.html)` et retourner
|
||||
avant `renderSnapshot()`. L'apparition de `.cached-html` ne dépend donc ni du
|
||||
chargement du module Markdown, ni de Shiki, ni d'une promesse de highlighting.
|
||||
|
||||
Le timeout original signifie que cette transition synchrone n'a pas été observée
|
||||
dans le DOM. Sans trace de la valeur du signal, du nombre d'exécutions de l'effet,
|
||||
de l'identité du nœud `#cache`, des erreurs page/module ou des mutations DOM, il est
|
||||
impossible de distinguer :
|
||||
|
||||
- effet Solid non relancé ou runtime optimisé incohérent dans ce serveur Vite;
|
||||
- fixture/nœud remplacé pendant le run complet;
|
||||
- autre anomalie de scheduling ou de cache du harness.
|
||||
|
||||
Une lenteur ordinaire de rendu Markdown est écartée par le chemin de cache local.
|
||||
Un défaut produit persistant est affaibli par les preuves : replay exact vert,
|
||||
suite `session-rendering` complète verte (46 tests), puis trois nouvelles
|
||||
répétitions cache **3/3 vertes** sur sources identiques.
|
||||
|
||||
### Disposition minimale
|
||||
|
||||
Aucun patch produit justifié. Ne pas demander davantage de retries ciblés. Si le
|
||||
même timeout réapparaît dans un prochain gate complet autorisé, capturer dans le
|
||||
failure artifact, sans modifier assertion ni délai :
|
||||
|
||||
- valeur courante de `literal` et compteur d'exécutions de l'effet;
|
||||
- `#cache.innerHTML`, connectivité/identité du nœud et MutationObserver;
|
||||
- `pageerror`, console, `requestfailed` et URLs des chunks Vite/Solid chargés;
|
||||
- mode/cache key résolus avant et après le setter.
|
||||
|
||||
Cette trace permettra de tester causalement l'hypothèse de cache d'optimisation
|
||||
Vite partagé. Introduire un `cacheDir` privé peut ensuite être évalué par contrôle
|
||||
négatif; le succès isolé seul ne prouverait pas cette cause.
|
||||
|
||||
## Exécutions bornées
|
||||
|
||||
Commande à deux noms exacts exécutée trois fois, puis focus seul une fois :
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 \
|
||||
--test-name-pattern="^(central diff inserts local lines and revision-qualified history into the real session composer|user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches)$" \
|
||||
packages/ui/tests/browser/git-history.test.ts \
|
||||
packages/ui/tests/browser/session-rendering.test.ts
|
||||
```
|
||||
|
||||
Résultats : focus 3 passages complets verts et 1 timeout de clic préalable; cache
|
||||
3/3 vert. Durées des quatre processus : environ 24,9 s, 22,8 s, 54,7 s et 15,0 s.
|
||||
Sorties naturelles; la troisième sort avec code 1 sur timeout Playwright. Aucun
|
||||
fullbrowser, stress, installation, daemon/profil/base utilisateur, restart,
|
||||
déploiement, commit, push ou sous-agent.
|
||||
|
||||
## Décision pour le gate de livraison
|
||||
|
||||
Conserver séparément :
|
||||
|
||||
1. le fullbrowser 356/3/1 à exit 1;
|
||||
2. ses retries et suites ciblées vertes;
|
||||
3. ce diagnostic, qui attribue la course focus au test mais laisse le timeout
|
||||
cache non qualifié et révèle une panne overlay distincte.
|
||||
|
||||
Si le critère de livraison exige un fullbrowser vert, appliquer uniquement la
|
||||
synchronisation causale du focus après décision du coordinateur, instrumenter le
|
||||
cache/overlay de manière opt-in au prochain gate complet, puis exécuter **un** gate
|
||||
figé. Ne pas présenter les replays ciblés comme un gate final vert.
|
||||
127
dev-docs/issue-824-browser-focus-test-sync.md
Normal file
127
dev-docs/issue-824-browser-focus-test-sync.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Issue 824 — synchronisation du test focus et traces de frontière
|
||||
|
||||
## Résultat
|
||||
|
||||
La seule correction fonctionnelle est dans
|
||||
`packages/ui/tests/browser/git-history.test.ts` : après les deux assertions de
|
||||
contenu, le test attend maintenant causalement que le textarea déjà résolu soit
|
||||
`document.activeElement`, puis conserve l'assertion d'identité existante.
|
||||
|
||||
Cette attente utilise le délai Playwright existant. Aucun sleep, timeout explicite,
|
||||
retry, force-click, changement de sélection ou changement du contrat focus produit
|
||||
n'a été ajouté.
|
||||
|
||||
Le focus corrigé passe **2/2**. Le second run a toutefois reproduit exactement le
|
||||
timeout `.cached-html` indépendant. Le résultat ciblé global est donc un run vert
|
||||
puis un run rouge; il ne constitue pas un gate navigateur vert et ne démontre pas
|
||||
une correction universelle de #824.
|
||||
|
||||
## Diff exact
|
||||
|
||||
### Synchronisation focus
|
||||
|
||||
Après les assertions inchangées du brouillon et du contexte Git :
|
||||
|
||||
```ts
|
||||
const composerElement = (await composer.elementHandle())!
|
||||
await page.waitForFunction(element => element === document.activeElement, composerElement)
|
||||
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
|
||||
```
|
||||
|
||||
Le textarea est capturé après l'insertion. L'attente observe uniquement la
|
||||
restitution asynchrone du focus déjà prévue par `insertBlockContent()`.
|
||||
|
||||
### Instrumentation observationnelle, désactivée par défaut
|
||||
|
||||
`CODENOMAD_BROWSER_BOUNDARY_TRACE=1` active désormais :
|
||||
|
||||
- pour la fixture Git, les événements `focusin`, `focusout`, `pointerdown` et
|
||||
`click`, le retrait éventuel du widget Monaco, l'élément actif final, la
|
||||
connectivité/position des widgets et la sélection Monaco disponible ;
|
||||
- pour la fixture HTML, la valeur `literal`, le DOM/connectivité de `#cache`, les
|
||||
mutations, les points avant/après setter, le `renderCache`, les ressources de
|
||||
module pertinentes, les `pageerror`, consoles et requêtes échouées.
|
||||
|
||||
En mode normal, la fixture HTML expose directement le setter Solid original. Les
|
||||
observers et wrappers de trace ne sont créés que lorsque la variable opt-in ajoute
|
||||
`trace=1`; seul un snapshot en lecture est disponible pour le dump d'échec déjà
|
||||
existant. Assertions, délais et chemin normal restent inchangés.
|
||||
|
||||
Cette instrumentation prépare le prochain gate unique; elle n'a pas été utilisée
|
||||
pour relancer le cas après le timeout, conformément à la limite de deux runs.
|
||||
|
||||
## Validation bornée
|
||||
|
||||
Commande exacte, concurrence 1 et noms exacts, exécutée deux fois :
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 \
|
||||
--test-name-pattern="^(central diff inserts local lines and revision-qualified history into the real session composer|user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches)$" \
|
||||
packages/ui/tests/browser/git-history.test.ts \
|
||||
packages/ui/tests/browser/session-rendering.test.ts
|
||||
```
|
||||
|
||||
Résultats :
|
||||
|
||||
1. exit `0`, 2 pass : focus 12,3 s; cache HTML 9,4 s;
|
||||
2. exit `1`, focus pass 12,7 s; cache HTML timeout 38,8 s sur
|
||||
`page.locator("#cache .cached-html").waitFor()`.
|
||||
|
||||
Le dump du second run est celui d'avant instrumentation et indique
|
||||
`state: undefined`; il ne qualifie donc toujours pas la cause cache. Il établit en
|
||||
revanche que le timeout antérieur est reproductible sur les mêmes sources et doit
|
||||
rester une frontière ouverte, séparée de la correction focus.
|
||||
|
||||
Autres contrôles :
|
||||
|
||||
- `npm run typecheck --workspace @codenomad/ui` : exit `0` après instrumentation ;
|
||||
- `git diff --check` sur les trois fichiers de test/fixture : exit `0`, avec
|
||||
avertissements de conversion LF/CRLF seulement ;
|
||||
- aucun fullbrowser relancé.
|
||||
|
||||
## Empreintes
|
||||
|
||||
Artefacts privés :
|
||||
|
||||
- `C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-focus-test-sync/manifest.json`
|
||||
— SHA256 `8440F3A61D0B59C870E1A64F9AAEFF90F68B5969FD5C77510F82AB2C899C543D` ;
|
||||
- `run-1.log` — SHA256
|
||||
`5354F36A00380DC16397A394C09B58FBD25D4AE3D613EF026024986077080E69` ;
|
||||
- `run-2.log` — SHA256
|
||||
`0499BDEFAB8EC34349CD2C63E1D0402CB1324AF98FF6567E984F5A1F6B15E63B`.
|
||||
|
||||
Le manifest contient les SHA256 avant/après de huit inputs. Seuls les trois inputs
|
||||
de test autorisés ont changé :
|
||||
|
||||
- `packages/ui/tests/browser/git-history.test.ts` ;
|
||||
- `packages/ui/tests/browser/session-rendering.test.ts` ;
|
||||
- `packages/ui/tests/browser/fixtures/user-html.tsx`.
|
||||
|
||||
Les quatre inputs produit fingerprintés sont strictement identiques avant/après :
|
||||
`monaco-diff-viewer.tsx`, `session-view.tsx`, `prompt-input.tsx` et `markdown.tsx`.
|
||||
`fixtures/git-history.tsx` est également inchangé.
|
||||
|
||||
Le manifest de la validation combinée et le manifest du diagnostic précédent sont
|
||||
désormais des preuves **historiques**, car `git-history.test.ts` a volontairement
|
||||
changé. Ils ne fingerprintent pas ce nouvel état de test.
|
||||
|
||||
## Limites et prochain gate
|
||||
|
||||
- La panne distincte de clic/détachement du widget Monaco n'a pas récidivé dans ces
|
||||
deux runs et n'est ni corrigée ni masquée.
|
||||
- Le timeout cache a récidivé une fois; aucun patch Markdown/Solid/Vite n'est
|
||||
justifié sans la nouvelle trace opt-in.
|
||||
- Aucun test au-delà des deux noms ciblés n'a été exécuté. Le fullbrowser précédent
|
||||
reste non vert.
|
||||
|
||||
Après la fin des diagnostics encore en file, exécuter un seul gate navigateur sur
|
||||
sources figées avec `CODENOMAD_BROWSER_BOUNDARY_TRACE=1` et conserver l'artefact
|
||||
complet si cache ou overlay échoue. Ne pas ajouter de retry pour fabriquer un gate
|
||||
vert. Toute correction ultérieure du cache ou de Monaco doit partir de cette trace
|
||||
causale et rester hors de cette tâche.
|
||||
|
||||
## Fichiers volumineux touchés
|
||||
|
||||
- `packages/ui/tests/browser/session-rendering.test.ts` : environ 910 lignes, sous
|
||||
le seuil d'alerte test de 1000 lignes mais proche ; aucun refactor de portée n'a
|
||||
été entrepris.
|
||||
155
dev-docs/issue-824-browser-http.md
Normal file
155
dev-docs/issue-824-browser-http.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Issue #824 — bounded Chromium HTTP/1 admission qualification
|
||||
|
||||
## Result and scope
|
||||
|
||||
**No global freeze reproduced in these five isolated scenarios.** Chromium
|
||||
Windows can progress in session B while session A's prompt/compact ownership
|
||||
preflight is held. Closing a page or browser context retires the old HTTP/SSE
|
||||
observers before the upstream handler is released. A new page connects and reads
|
||||
B before that release; late completion does not forward the abandoned mutation.
|
||||
No additional product correction follows from this result.
|
||||
|
||||
This is **Windows Chromium → Windows Fastify over private loopback HTTP/1.1**,
|
||||
not Electron Linux → Windows LAN qualification, not a desktop process restart,
|
||||
and not qualification of an OpenCode 2.0.19 runtime. No OpenCode daemon, database,
|
||||
provider, shared profile, production backend or installed desktop is used.
|
||||
|
||||
Added paths only:
|
||||
|
||||
- `packages/ui/tests/browser/http-admission-responsiveness.test.ts`
|
||||
- `packages/ui/tests/browser/fixtures/http-admission-responsiveness.tsx`
|
||||
- this report
|
||||
|
||||
## What is real versus supplied
|
||||
|
||||
- Playwright 1.63.0 launches installed Chromium **153.0.8010.12** on Windows;
|
||||
Node **25.2.1** runs private ephemeral Fastify listeners.
|
||||
- The browser addresses Fastify directly. Vite serves fixture modules on another
|
||||
origin; there is **no Vite/Node proxy** in the business-request/SSE path.
|
||||
- CDP `Network.responseReceived` asserts `http/1.1` for the real SSE response,
|
||||
B response, CodeNomad metadata and control witness. Fastify also records request
|
||||
`httpVersion === "1.1"`. These are not intercepted Playwright responses.
|
||||
- Production `registerEventRoutes`, `EventBus`, `ClientConnectionManager` and
|
||||
browser `serverEvents` provide one multiplexed EventSource subscription per
|
||||
page. Event payloads are fixture-controlled; there is no native event relay or
|
||||
native compaction event sequence in this experiment.
|
||||
- Production `registerInstanceProxyRoutes`, request admission, runtime fetch and
|
||||
`WorktreeDeletionFence` implement admission/cancellation. The official
|
||||
`@opencode/client@2.0.21` Promise SDK runs in browser **and** backend. Real
|
||||
browser `createInstanceFetch` performs classification/background scheduling.
|
||||
- A disposable upstream Fastify service supplies native-shaped HTTP responses,
|
||||
including the session `{ data }` wire envelope. Its A GET handler deliberately
|
||||
remains held even after its HTTP response socket closes. This is an induced
|
||||
preflight wait, **not** real native compaction or provider execution.
|
||||
- A small ownership manager supplies only the `/fixture` identity and invokes a
|
||||
real SDK location read. No discovery, filesystem or actual worktree scan is
|
||||
performed. Shared production primitives are imported rather than copying the
|
||||
server disconnect fixture's complete fake SDK tree.
|
||||
- Draft input/session switching are minimal Solid fixture controls. Their switch
|
||||
performs the real SDK B read. This measures browser event/input/network progress,
|
||||
not rendering of the full CodeNomad transcript, session store or composer.
|
||||
- “Info” is the real CodeNomad `/api/meta` route, not native `server.info()`.
|
||||
`/api/info` is not exposed by the workspace proxy allowlist and was not added.
|
||||
|
||||
## Scenarios and causal assertions
|
||||
|
||||
Four cases cross prompt/compact with page/context close. Each starts exactly one
|
||||
SSE plus one A mutation whose real backend SDK session read is held. Before release,
|
||||
the browser edits a draft, switches/loads B, reads `/api/meta` and reads a control
|
||||
witness. A stays pending throughout those successful operations.
|
||||
|
||||
A **separate fifth case** adds five distinct agent catalogue read intents through
|
||||
the existing `createInstanceFetch` policy. Only **two** reach upstream and remain
|
||||
held; three stay queued. Distinct agent URLs avoid Chromium's same-URL cache
|
||||
coalescing. This is a scheduling stress witness, not a claim that normal UI issues
|
||||
five such reads on any particular cadence. No six-held-foreground-request fanout
|
||||
is used to manufacture an ordinary-workflow freeze.
|
||||
|
||||
Before releasing any held upstream handler, closing the page/context must produce:
|
||||
|
||||
- zero SSE registrations, old downstream mutation responses, backend SDK A
|
||||
observers, native A HTTP responses and (fifth case) catalogue HTTP responses;
|
||||
- one observed A admission abort, restored request-abort listener count and no
|
||||
residual admission/forwarding close listener;
|
||||
- a completed deletion-fence operation; A is held **before** fence admission,
|
||||
so this checks non-reservation, not cancellation of an already admitted write;
|
||||
- successful new-page SSE/B/control reads with no old observer restarted.
|
||||
|
||||
Upstream handler closures deliberately remain alive until the test gate is opened
|
||||
(one handler, or three with catalogues). They are counted separately from HTTP
|
||||
observers: their intentional retention must not be mistaken for an orphaned HTTP
|
||||
watcher. After release the test waits for those continuations to finish and checks
|
||||
zero A forwards, environment writes and connection invalidations, with no new
|
||||
catalogue dispatch from the destroyed page.
|
||||
|
||||
## Executed validation
|
||||
|
||||
Existing dependencies/browser reused without installation. From the mission root:
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict --import tsx --test packages/ui/tests/browser/http-admission-responsiveness.test.ts
|
||||
node --unhandled-rejections=strict --import tsx --test packages/ui/tests/browser/http-admission-responsiveness.test.ts packages/ui/tests/browser/event-subscriber-isolation.test.ts
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts
|
||||
npm run typecheck --workspace @codenomad/ui
|
||||
```
|
||||
|
||||
Standalone: **5/5 pass**, zero skips, natural exit ~4.0 s. After adding explicit
|
||||
held-handler continuation accounting, combined gate: **10/10 pass**, zero skips,
|
||||
natural exit ~5.1 s (five new cases plus five existing subscriber-isolation cases).
|
||||
UI source typecheck passes; that tsconfig excludes browser tests/fixtures, which
|
||||
are instead executed via tsx/Vite/Chromium. Neither a full browser-suite gate nor
|
||||
full desktop/native-runtime validation is claimed.
|
||||
The existing server disconnect/admission cases also pass **15/15**, zero skips,
|
||||
natural exit ~0.8 s, without editing their fixture or product sources. Scoped
|
||||
no-index whitespace checks on the three new files emitted only Git's LF/CRLF
|
||||
warnings, no whitespace diagnostics (no-index diff status 1 for new contents).
|
||||
|
||||
Descriptive measurements from the combined run (not CPU latency requirements):
|
||||
|
||||
| Scenario | Input/B/meta/control progress while held | Close cleanup | Backend peak TCP sockets |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| prompt, page close | 92 ms | 13 ms | 3 |
|
||||
| compact, page close | 79 ms | 16 ms | 3 |
|
||||
| prompt, context close | 68 ms | 5 ms | 3 |
|
||||
| compact, context close | 74 ms | 5 ms | 3 |
|
||||
| compact + background budget, context close | 72 ms | 7 ms | 5 |
|
||||
|
||||
Socket counts include idle/preconnect connections and are **not** asserted as busy
|
||||
connection counts. Page close left one idle pooled TCP socket but no pending HTTP
|
||||
observer; context close left zero in this run. New pages had one SSE and two TCP
|
||||
sockets. Assertions are causal (B succeeds before gate release, cleanup occurs
|
||||
before release); 10/30/60-second harness bounds detect deadlock only, not latency
|
||||
regression thresholds.
|
||||
|
||||
## Remaining Linux → Windows qualification
|
||||
|
||||
The smallest useful next experiment needs an approved isolated Windows backend
|
||||
and native storage/provider, plus the actual Linux Electron build. Bind that private
|
||||
backend on a test interface restricted to the Linux host; never expose or use the
|
||||
shared daemon. Record backend/UI/native/Electron/Chromium versions, exact scheme,
|
||||
proxy/VPN configuration and whether authentication uses the remote-window path.
|
||||
|
||||
Concrete read-only external control from Linux, using the approved private URL:
|
||||
|
||||
```sh
|
||||
curl --http1.1 --max-time 10 -sS -D private-meta.headers \
|
||||
-o private-meta.json "$WINDOWS_PRIVATE_URL/api/meta"
|
||||
```
|
||||
|
||||
Provide the isolated backend's normal authentication if required, without saving
|
||||
credentials in the report. This curl process does not consume the renderer's
|
||||
connection pool; compare its progress with renderer Network timings, not as proof
|
||||
that the renderer is healthy. In Electron collect the owned window's existing
|
||||
automation/CDP Network events (no fixed debugging port), protocol, connection IDs,
|
||||
request initiator, queue/stall/connect/TTFB timings and one SSE subscription count.
|
||||
Start with one real A compaction and B switch/draft/meta reads; count background
|
||||
reads separately. Compare close/reopen page, close/reopen remote window and full
|
||||
isolated Electron restart **without restarting Windows backend/native runtime**.
|
||||
Capture admission abort, pending SDK reads, relay backlog, upstream execution and
|
||||
backend event-loop progress before/after each boundary. Do not replay a timed-out
|
||||
mutation. A genuine native 2.0.19 run must identify its binary/storage/provider;
|
||||
the pinned 2.0.21 SDK is not evidence about that runtime.
|
||||
|
||||
The current harness is intentionally loopback-only and self-terminating; changing
|
||||
`CODENOMAD_BROWSER_PATH` selects a local browser, not a remote backend. A persistent
|
||||
LAN-controllable version requires a separately authorized fixture/launch change.
|
||||
134
dev-docs/issue-824-browser-mcp-bootstrap-diagnosis.md
Normal file
134
dev-docs/issue-824-browser-mcp-bootstrap-diagnosis.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# #824 — diagnostic borné du bootstrap MCP navigateur
|
||||
|
||||
## Verdict
|
||||
|
||||
**Timeout de readiness reproduit une fois sur cinq ; cause non attribuée.**
|
||||
Quatre reprises passent et une échoue avant les assertions MCP, avec le même
|
||||
`page.waitForFunction(window.mcpFixture)` à 30 s que le fullbrowser antérieur.
|
||||
Les cinq processus sortent naturellement ; pas de skip/force-exit/force-click.
|
||||
Le suivi est terminé dans son budget, mais n'accepte ni la mission ni le gate
|
||||
fullbrowser, qui conserve ses 356 pass / 3 fail / 1 skip.
|
||||
|
||||
La capture a une faiblesse explicite : son plafond commun de 4 000 observations
|
||||
est saturé par les requêtes de modules **avant les diagnostics terminaux**. Elle
|
||||
ne permet pas d'exclure une erreur/rejection tardive ni de dire si le module est
|
||||
bloqué avant son corps, dans `applyUiSettings` ou dans `render`. Aucun défaut
|
||||
produit confirmé, aucun correctif produit ou harness proposé comme déjà validé.
|
||||
|
||||
## Périmètre et méthode
|
||||
|
||||
2026-10-01, Windows x64, Node `v25.2.1`, Chromium `153.0.8010.12`, worktree
|
||||
`D:/CodeNomad/.codenomad/worktrees/issue-824-performance`, HEAD `366830f6`.
|
||||
`combined-validation` et `validation-closeout` sont déjà rapportées terminées.
|
||||
|
||||
**Aucun fichier produit, test ou fixture source édité.** Seul ce rapport est créé
|
||||
dans le dépôt. Aucun audit ou fullbrowser supplémentaire, aucune dépendance/config/
|
||||
lock modifiée, installation, commit/push/merge, restart/déploiement, suppression de
|
||||
sessions, accès au daemon/base/profil utilisateur ou sous-agent.
|
||||
|
||||
Artefacts privés :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-mcp-bootstrap-4770677d4da549e98e255c79c7ecdba3/`.
|
||||
|
||||
Le script `diagnose.cjs` copie le texte exact de `mcp-motion.test.ts` en privé,
|
||||
préserve assertions, timeouts, clics et options Chromium/Vite, puis compile cette
|
||||
copie TypeScript avec l'esbuild déjà installé. Seuls les imports Node des packages
|
||||
et la racine Vite sont rendus absolus, puisqu'elle n'est plus dans le dossier du
|
||||
test original. Vite sert toujours les vrais modules/stylesheet et la fixture source.
|
||||
|
||||
Observations ajoutées en mémoire dans cette copie, sans catch qui transforme un
|
||||
échec en succès ni `preventDefault` d'erreur :
|
||||
|
||||
- console, pageerror, request/requestfinished/requestfailed ;
|
||||
- statut, headers/content-type, URL et type des réponses des modules Vite ;
|
||||
- erreurs et unhandledrejection dans un init-script de page ;
|
||||
- marqueurs privés par transform Vite : entrée du corps de fixture, avant/après
|
||||
settings, avant render et après publication de `mcpFixture` ;
|
||||
- tentative de snapshot terminal dans le finally, puis fermeture originale.
|
||||
|
||||
Le cache Vite conserve son emplacement/options ordinaires (`node_modules/.vite`),
|
||||
pas une nouvelle politique de cache. Le plugin d'observation privé et l'observer
|
||||
peuvent toutefois influencer timing et clé d'optimisation : ceci n'est **pas** un
|
||||
A/B causal contrôlé du cache ou du coût de bootstrap. Aucune suite complète
|
||||
concurrente repérée lors du contrôle read-only préalable ; les ciblés indépendants
|
||||
du reviewer ne constituent pas une charge de l'hôte maîtrisée.
|
||||
|
||||
## Résultats exécutés — cinq reprises, pas davantage
|
||||
|
||||
Filtre exact :
|
||||
`^eight connected MCP servers stay static at rest \(reduce, 150% scale\)$`.
|
||||
Chaque processus exécute un seul cas avec rejets stricts et concurrence 1. Le
|
||||
budget total du runner est **84,7 s**, sous dix minutes ; aucune relance après
|
||||
ces cinq reprises et aucune assertion/durée artificiellement assouplie.
|
||||
|
||||
| Reprise | Résultat du cas | Exit | Naturelle | Processus, durée observée |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | pass | 0 | oui | 11,25 s |
|
||||
| 2 | **fail readiness 30 s** | 1 | oui | 41,03 s |
|
||||
| 3 | pass | 0 | oui | 12,00 s |
|
||||
| 4 | pass | 0 | oui | 9,63 s |
|
||||
| 5 | pass | 0 | oui | 9,46 s |
|
||||
|
||||
Le runner global sort exit 1 car une reprise échoue : il ne masque pas le rouge.
|
||||
Ces cinq exécutions d'un même scénario ne sont pas cinq scénarios de couverture
|
||||
nouveaux et ne remplacent pas le gate complet antérieur.
|
||||
|
||||
## Ce que la capture démontre / ne démontre pas
|
||||
|
||||
Dans la reprise rouge :
|
||||
|
||||
- Le document `/mcp-motion` et `/tests/browser/fixtures/mcp-motion.tsx` répondent
|
||||
**200**, respectivement HTML et JavaScript. Le serveur a appliqué la transform.
|
||||
Une absence totale de réponse de ces deux entrées n'explique donc pas ce run.
|
||||
- Le préfixe enregistré comprend **1 645 requêtes**, **1 175 réponses 200** et
|
||||
**1 175 requestfinished**. Les modules optimisés Solid/debug répondent aussi 200.
|
||||
De nombreux modules sont des icônes `lucide-solid/dist/source/icons/*.jsx`,
|
||||
conformément à `optimizeDeps.exclude: ['lucide-solid']` existant.
|
||||
- Les seuls messages console retenus sont `[vite] connecting...` et `connected.`.
|
||||
Aucun pageerror, requestfailed ou statut >=400 n'apparaît **dans ce préfixe**.
|
||||
Ceci ne prouve pas leur absence dans la totalité du run.
|
||||
- Le plafond de 4 000 événements est atteint vers 10,14 s après début du processus
|
||||
de test. Il est aussi atteint dans les quatre passes (8,42–10,93 s), donc sa
|
||||
saturation n'est ni un discriminateur du timeout ni une cause de freeze établie.
|
||||
- Le log Node démontre le timeout après `goto`, sur la readiness, pas une assertion
|
||||
« huit serveurs ready », ni une animation perpétuelle du composant.
|
||||
|
||||
**Limite de capture confirmée** : les snapshots/stages, événements ready et erreurs
|
||||
tardives partagent le même plafond avec le réseau. Ils ont été perdus, y compris
|
||||
le snapshot terminal qui aurait permis de localiser la frontière. Ne pas convertir
|
||||
cette absence d'observations en absence d'erreur produit. La quantité de modules,
|
||||
une collision/rebuild du cache Vite, l'ordonnancement, la charge de l'hôte ou une
|
||||
attente settings/render restent des hypothèses, pas des causes démontrées.
|
||||
|
||||
Le fixture source substitue l'inventaire Shell et le client MCP ; aucune compaction
|
||||
native ou connexion à un vrai MCP/OpenCode n'est testée ici. Rien dans ces traces
|
||||
n'attribue le freeze original #824 à MCP ni à Electron Linux → Windows.
|
||||
|
||||
## Empreintes et artefacts
|
||||
|
||||
Avant/après chaque reprise : **1 696 fichiers** tracked + untracked non ignorés,
|
||||
sources/tests/scripts/configs/lock inclus, hors docs/générés/dist/public/deps/cache.
|
||||
Pas de dérive et mêmes hashes de fichiers que la validation combinée. Le format
|
||||
du manifeste privé (map path->SHA seulement) diffère du manifeste combiné enrichi.
|
||||
`results.json` confirme `originalTestUnchanged: true`, `fixtureUnchanged: true`.
|
||||
|
||||
- `initial.json`, `<n>-before.json`, `<n>-after.json`, `<n>-result.json` : empreintes
|
||||
et statuts. SHA initial `c4c4ff2bbc0c022b27258bda2ba085f79062f0cb982341b3ced09d0cd07adaa9`.
|
||||
- `test-original.ts`, `fixture-original.tsx`, `test-observed.ts` et
|
||||
`test-observed.mjs`, plus `harness.json` : provenance et modifications privées.
|
||||
- `<n>.log`, `<n>-trace.json` : logs/traces complets **jusqu'au plafond**, pas traces
|
||||
complètes de la vie de page. `trace-summary.json` identifie la saturation.
|
||||
- `2-module-prefix.json` : réponses d'entrée et modules optimisés du rouge.
|
||||
- `2.log` SHA256 `0830b1ee6d0b74c4640c434529227ce61193fcfe8ca01f41251bef114e264284`.
|
||||
- `2-trace.json` SHA256 `d414677c96cb7ab5b4e2a79b18eff64fa6bf07539e5e205333c94725157e4332`.
|
||||
- `diagnose.cjs` SHA256 `a6f4d4702d13624cf8c1123819ec0877da83b8381a07addbd7436fa84f195c1b`.
|
||||
|
||||
## Prochain minimum utile — décision du coordinateur, pas une relance ici
|
||||
|
||||
Autoriser un nouveau suivi **seulement si l'attribution reste nécessaire** : séparer
|
||||
les événements critiques dans une réserve indépendante du réseau, conserver toujours
|
||||
un snapshot terminal/stages/rejections, et agréger les réponses modules par URL/status
|
||||
plutôt que laisser leur volume éjecter le diagnostic. Avec ce harness corrigé, capturer
|
||||
une nouvelle occurrence avant toute décision de patch ou expérience causal-cache.
|
||||
Ne pas changer les assertions, les 30 s, les gestes ni la politique produit pour
|
||||
obtenir artificiellement du vert. Aucun patch produit minimal ne peut être recommandé
|
||||
sur les preuves actuellement disponibles.
|
||||
165
dev-docs/issue-824-browser-mcp-critical-capture.md
Normal file
165
dev-docs/issue-824-browser-mcp-critical-capture.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# #824 — capture critique du bootstrap MCP
|
||||
|
||||
## Verdict terminal
|
||||
|
||||
**5/5 reprises ciblées passent naturellement, timeout non reproduit.** La faiblesse
|
||||
du collecteur est corrigée et vérifiée, et les cinq traces critiques sont complètes
|
||||
jusqu'à fermeture, sans aucun overflow. L'attribution du timeout historique reste
|
||||
ouverte : aucune nouvelle occurrence rouge n'a permis d'en localiser la frontière.
|
||||
Travail de capture terminé, pas preuve d'élimination de panne ni gate global vert.
|
||||
Le fullbrowser historique reste 356 pass / 3 fail / 1 skip ; le rouge MCP précédent
|
||||
1/5 reste conservé, distinct de ce suivi. Aucune sixième reprise n'est lancée.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Suivi borné de `browser-mcp-bootstrap-diagnosis`, pas une nouvelle validation
|
||||
complète. Les dépendances `browser-mcp-bootstrap-diagnosis` et
|
||||
`browser-focus-test-sync` sont terminales `completed`, sans exécution restante,
|
||||
avant le lancement. Rapport focus-sync lu : ses deux ciblés sont finis, focus
|
||||
2/2, mais timeout cache HTML indépendant au second run. Ses trois modifications
|
||||
de tests/fixture font désormais partie de la nouvelle identité à fingerprinter.
|
||||
|
||||
Sources exclusivement :
|
||||
`D:/CodeNomad/.codenomad/worktrees/issue-824-performance`.
|
||||
|
||||
Artefacts exclusivement privés :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-mcp-critical-20261001/`.
|
||||
Le diagnostic précédent reste intact dans `issue-824-mcp-bootstrap-4770677d4da549e98e255c79c7ecdba3`.
|
||||
|
||||
Aucune édition produit/test/fixture/deps/config/lock ni politique de cache modifiée.
|
||||
Seul ce rapport est créé dans le dépôt. Aucun install, commit/push/merge, restart,
|
||||
déploiement, suppression de sessions, accès au daemon/base/profil utilisateur ou
|
||||
sous-agent. Contrôle read-only des processus Node avant lancement : aucun ciblé
|
||||
browser, runner de validation ou fullbrowser concurrent repéré.
|
||||
|
||||
## Seule modification expérimentale : le collecteur privé
|
||||
|
||||
Le harness précédent est recopié sans altérer ses imports/racine absolus,
|
||||
assertions, délais 30 s, gestes, options Vite/Chromium ou marqueurs privés de
|
||||
fixture. Il sert toujours les vrais modules et styles depuis le worktree.
|
||||
|
||||
Le réseau est désormais agrégé par événement/URL/status/type, avec compteur,
|
||||
première/dernière heure et content-type pour les réponses. Plafond de 8 000 clés
|
||||
distinctes, compteurs totaux et overflow réseau explicites. Pas de body HTTP lu
|
||||
ni de modification de chargement ou optimisation lucide.
|
||||
|
||||
Réserves indépendantes : erreurs 512, lifecycle/ready 256 et consoles 256. Chaque
|
||||
réserve compte son propre overflow. Les réponses >=400 sont aussi classées comme
|
||||
erreurs ; les requestfailed/pageerror/test-error ne dépendent pas du plafond réseau.
|
||||
Le snapshot terminal a son propre emplacement hors de tous ces plafonds, y compris
|
||||
un résultat `observation-error` explicite si son évaluation ne réussit pas.
|
||||
Les erreurs/rejections de page disposent d'un buffer 512 et d'un overflow explicite,
|
||||
indépendants du réseau ; les cinq marqueurs de fixture restent conservés.
|
||||
|
||||
Le finally capture le snapshot avant fermeture, sans absorber l'échec du test.
|
||||
Les observations peuvent influencer le timing : ce n'est pas un A/B causal du
|
||||
cache, de l'ordonnancement ou du coût d'import. L'emplacement et les options du
|
||||
cache Vite ordinaires sont conservés, pas nettoyés ou isolés artificiellement.
|
||||
|
||||
## Préflight sans navigateur
|
||||
|
||||
`collector-selftest.cjs` passe naturellement : 10 001 réponses synthétiques sur
|
||||
un plafond réseau de deux entrées provoquent 9 999 overflows. Malgré cela,
|
||||
pageerror, réponse 504, ready-before et snapshot terminal restent présents dans
|
||||
leurs réserves ; l'overflow erreur indépendant est également vérifié.
|
||||
`collector-selftest-result.json` conserve les résultats. Syntaxe du runner vérifiée.
|
||||
|
||||
## Budget et qualification
|
||||
|
||||
Filtre unique inchangé :
|
||||
`^eight connected MCP servers stay static at rest \(reduce, 150% scale\)$`.
|
||||
Rejets stricts, concurrence 1, au plus cinq runs / dix minutes, arrêt automatique
|
||||
sur la première occurrence rouge avec snapshot/stages critiques sans overflow.
|
||||
Pas de force-exit, skip, force-click ou assouplissement des assertions.
|
||||
|
||||
Le runner fingerprint avant/après les sources produit, tests, scripts, configs et
|
||||
locks tracked + untracked non ignorés, hors docs/générés/deps/caches. Il conserve
|
||||
les statuts exacts, heures, logs, traces et leurs SHA256 ; une dérive n'est jamais
|
||||
présentée comme un gate immutable. Son texte, collecteur, copie observée et code
|
||||
compilé ont leurs propres hashes dans `harness.json`.
|
||||
|
||||
## Résultats frais et localisation observée
|
||||
|
||||
Runner `sh_0f9561ee5001s5HSuUnJ0dLtTb` terminé, exit 0 naturel ; notification finale
|
||||
reçue. Windows x64, Node v25.2.1, Chromium 153.0.8010.12. Cinq processus, un cas
|
||||
chacun, zéro fail/skip/cancelled. Budget total 54,35 s ; pas d'arrêt sur rouge car
|
||||
aucun rouge dans ce suivi. Ce ne sont pas cinq nouveaux scénarios de couverture.
|
||||
|
||||
| Reprise | Exit naturel | Durée processus | Entrée corps fixture, horloge page | Settings | Render → publication |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 0 | 12,04 s | 10,323 s | 29,4 ms | 46,0 ms |
|
||||
| 2 | 0 | 10,20 s | 8,619 s | 32,0 ms | 43,5 ms |
|
||||
| 3 | 0 | 10,42 s | 8,819 s | 26,2 ms | 41,8 ms |
|
||||
| 4 | 0 | 10,11 s | 8,637 s | 30,3 ms | 45,0 ms |
|
||||
| 5 | 0 | 10,40 s | 8,880 s | 29,1 ms | 44,9 ms |
|
||||
|
||||
Dans les cinq passes : `fixture-body → settings-before → settings-after →
|
||||
render-before → fixture-published`, puis `ready-after` observé. Snapshot final
|
||||
`mcpFixture: true`, document complete, huit ready dots, 66 caractères au root.
|
||||
La lecture ready prend 87,3–97,7 ms après son lancement ; `goto(domcontentloaded)`
|
||||
dure 8,625–10,329 s sur l'horloge Node. Ne pas soustraire les horloges Node/page.
|
||||
|
||||
**Localisation du coût dans les passes**, pas de la panne : l'essentiel du temps
|
||||
de navigation observé précède l'entrée du corps de fixture. Cette région comprend
|
||||
fetch/transform/parse et évaluation des dépendances/imports, éventuellement leurs
|
||||
attentes ; les traces ne répartissent pas ce coût. Settings et render arrivent
|
||||
ensuite et achèvent en quelques dizaines de millisecondes. Cela ne prouve pas
|
||||
que le timeout rouge antérieur bloquait dans cette même région plutôt que dans
|
||||
settings/render/ready. Aucun profil CPU ni expérience causal-cache exécuté.
|
||||
|
||||
Chaque run conserve **1 837 requêtes, 1 837 réponses toutes 200, 1 837 finished**, soit
|
||||
5 511 clés événement/URL/status/type, au-delà du vieux plafond commun 4 000 sans
|
||||
perdre le snapshot. Zéro requestfailed, pageerror, test-error, erreur/rejection dans
|
||||
les stages ; overflow réseau/erreurs/lifecycle/console/stages tous zéro. Ceci porte
|
||||
sur ces cinq runs jusqu'au finally, pas sur l'ancien rouge ni sur une vie de page
|
||||
illimitée après fermeture.
|
||||
|
||||
Les consoles **ne sont pas exemptes d'erreurs** : un message EventSource MIME
|
||||
`application/json` au lieu de `text/event-stream` apparaît dans chaque passe,
|
||||
avec quatorze warnings Solid « computations created outside a createRoot or render »
|
||||
et deux messages Vite debug. Le MIME est cohérent avec la route fixture existante
|
||||
qui remplace toutes les requêtes `/api/**` par `{}` JSON. Sa présence dans les cinq
|
||||
passes ne démontre ni sa causalité dans le timeout ni son innocuité universelle.
|
||||
Ces consoles et leurs emplacements sont conservés intégralement ; aucun filtre
|
||||
pour fabriquer du vert.
|
||||
|
||||
## Identité fraîche et pièces terminales
|
||||
|
||||
Les **1 696 fichiers** ont les mêmes hashes à l'initial, avant/après chaque run et
|
||||
au terminal ; zéro drift, y compris entre runs. Le SHA256 des manifests privés
|
||||
`initial.json` et `terminal-manifest.json` est
|
||||
`4628acfc4df16f8e573da48dab23df3614a7a8a55643466e5e6e8e4f6e80105b`.
|
||||
Les seuls écarts avec le diagnostic précédent sont les trois fichiers attendus
|
||||
de focus-sync (`git-history.test.ts`, `session-rendering.test.ts`, `user-html.tsx`).
|
||||
Ils ne sont pas édités par ce suivi ; les anciens manifests restent historiques.
|
||||
|
||||
`harness.json` et `terminal-receipt.json` vérifient les SHA256 exécutés inchangés :
|
||||
|
||||
| Input privé | SHA256 |
|
||||
| --- | --- |
|
||||
| `diagnose.cjs` | `4ffab45b45c46e5d09e6d8f0e2b2259fe04cdfb41a02689826119fbba9b583bc` |
|
||||
| `collector.cjs` | `914cfc1b5b8ab02a98b20157a5c1706056f6ab928b37f0f32bee03a8fe4d08b0` |
|
||||
| `test-observed.ts` | `a76ae27a9fa8ed04c1f0536ab58b809208fda7edb90c7b27b9fff012db0f30da` |
|
||||
| `test-observed.mjs` | `cce160f88fbba195c702e3134bc5a1a638936054dbe8148fe6c774357b380ca1` |
|
||||
|
||||
Test source original SHA256
|
||||
`5820fe789d7470a5c6607998fbf7d068ed25df61611e496e4eec9968537669ec`,
|
||||
fixture original SHA256
|
||||
`f883fa1acd7318b10c5a517e4b1da0983fde384d2581e0046c7232312e299824`.
|
||||
Les copies originales et leurs fichiers source sont inchangés.
|
||||
|
||||
`<n>.log`, `<n>-trace.json`, `<n>-result.json` conservent chacun leur hash vérifié ;
|
||||
`results.json`, `trace-summary.json` et `terminal-receipt.json` indexent les cinq
|
||||
runs, phases, réserves/overflows et sorties. `collector-selftest-result.json`
|
||||
conserve le préflight. Aucun artefact antérieur n'est écrasé.
|
||||
|
||||
## Décision minimale suivante
|
||||
|
||||
**Pas de nouvelle boucle de diagnostic ni patch produit justifié ici.** Le
|
||||
coordinateur peut utiliser ces traces complètes et le harness privé exact comme
|
||||
capture MCP du gate unique s'il l'autorise. Sans occurrence rouge instrumentée,
|
||||
l'attribution reste explicitement non qualifiée ; si elle est un critère obligatoire
|
||||
de livraison, c'est un blocage honnête de ce critère, pas un succès déduit des retries.
|
||||
Ni imports/lucide, cache Vite, charge hôte, settings ou render n'est une cause prouvée.
|
||||
Conserver séparément cache HTML ouvert, Linux Electron → Windows LAN/2.0.19 et
|
||||
sortie naturelle stores non qualifiés. La clôture de mission appartient au coordinateur.
|
||||
156
dev-docs/issue-824-browser-trace-serialization-fix.md
Normal file
156
dev-docs/issue-824-browser-trace-serialization-fix.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Issue 824 — correction de sérialisation de la trace Git
|
||||
|
||||
## Verdict
|
||||
|
||||
Le défaut introduit par l'instrumentation opt-in est corrigé uniquement dans
|
||||
`packages/ui/tests/browser/git-history.test.ts`.
|
||||
|
||||
La cause est démontrée : sous le loader `tsx` réellement installé, le callback
|
||||
TypeScript passé à `page.addInitScript(callback)` devient une fonction dont
|
||||
`toString()` contient :
|
||||
|
||||
```js
|
||||
const describe = __name(target => /* ... */, "describe")
|
||||
```
|
||||
|
||||
Playwright sérialise le corps de la fonction mais pas le helper module-scope
|
||||
`__name`. Une page Chromium minimale reproduit alors exactement
|
||||
`ReferenceError: __name is not defined`, avec le même stack et une trace vide que
|
||||
le gate final.
|
||||
|
||||
Le script est maintenant fourni par `page.addInitScript({ content })` sous forme
|
||||
de source JavaScript explicite et autosuffisante. Aucun helper global n'est injecté
|
||||
pour cacher l'erreur. Une validation Chromium minimale du contenu exact extrait du
|
||||
fichier est verte : aucune erreur page, 200 entrées bornées après saturation, et
|
||||
les observations focus, pointer/click et `widget-removed` restent actives.
|
||||
|
||||
Le gate final précédent demeure **non vert** : 360 total, 358 pass, 1 fail,
|
||||
1 skip, exit 1 naturel. Aucun fullbrowser ni test ciblé Git/session/MCP n'a été
|
||||
relancé dans cette tâche.
|
||||
|
||||
## Changement exact
|
||||
|
||||
Le callback transformable de `addInitScript` est remplacé par la constante
|
||||
`gitFocusBoundaryInitScript`, un `String.raw` contenant une IIFE JavaScript sans
|
||||
types ni closure externe. `describeElement` et `pushTrace` vivent entièrement dans
|
||||
ce contexte navigateur.
|
||||
|
||||
Toutes les observations passent par `pushTrace`, y compris `widget-removed` ; la
|
||||
limite de 200 entrées couvre donc désormais toute la chronologie, et pas seulement
|
||||
les événements focus/pointer.
|
||||
|
||||
Le snapshot final est entouré d'un `try/catch` observationnel. Une évaluation de
|
||||
snapshot indisponible est journalisée comme telle et ne masque plus une assertion
|
||||
déjà en cours ; `page.close()` reste exécuté après ce bloc. Aucun changement aux
|
||||
assertions, à `errors[]`, aux clics, textes, sélections, délais ou à la
|
||||
synchronisation focus précédemment acceptée.
|
||||
|
||||
## Preuve rouge causale
|
||||
|
||||
Artefact privé :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-trace-serialization-fix/red.json`.
|
||||
|
||||
Le fichier `callback-before.ts` recopie exactement le callback remplacé. Il est
|
||||
importé par `node --import tsx`; la fonction obtenue est passée à
|
||||
`page.addInitScript` dans une page `data:` privée.
|
||||
|
||||
Résultat :
|
||||
|
||||
```json
|
||||
{
|
||||
"hasExternalName": true,
|
||||
"errors": ["ReferenceError: __name is not defined ..."],
|
||||
"snapshot": { "trace": [] }
|
||||
}
|
||||
```
|
||||
|
||||
Le champ `serialized` de l'artefact conserve le JavaScript exact, dont
|
||||
`const describe=__name(...)`. SHA256 `red.json` :
|
||||
`1A3271EB26B265784BD84EBF2D4A7546508551BEF04237156CAEF779586C117F`.
|
||||
|
||||
Ce mécanisme est certain, et non une simple corrélation : la transformation exacte,
|
||||
le binding externe absent, l'exception Chromium et l'état vide correspondent tous
|
||||
au gate. L'ajout de `__name` vient de la transformation `tsx` effective ; aucune
|
||||
hypothèse sur Vite, Monaco ou le produit n'est nécessaire.
|
||||
|
||||
## Preuve verte bornée
|
||||
|
||||
`validate.cjs` lit `git-history.test.ts`, extrait le contenu exact du
|
||||
`String.raw`, puis l'injecte avec `{ content }` dans une page Chromium minimale. La
|
||||
page alterne le focus 260 fois, émet `pointerdown` et `click`, puis retire un widget
|
||||
`.git-change-context-widget` après installation du MutationObserver.
|
||||
|
||||
Résultat :
|
||||
|
||||
```json
|
||||
{
|
||||
"hasExternalName": false,
|
||||
"errors": [],
|
||||
"traceLength": 200,
|
||||
"hasFocus": true,
|
||||
"hasPointer": true,
|
||||
"hasWidgetRemoved": true,
|
||||
"lastKinds": ["focusout", "focusin", "pointerdown", "click", "widget-removed"]
|
||||
}
|
||||
```
|
||||
|
||||
Le budget saturé à 200 et l'entrée `widget-removed` finale démontrent à la fois la
|
||||
borne globale et la persistance des listeners/observer après saturation.
|
||||
|
||||
Artefacts :
|
||||
|
||||
- `green.json`, SHA256
|
||||
`D3024DB5252E029100F2C5D99F28A22F5F7AA186B43C254C0F1AC622CB6554F0` ;
|
||||
- `validate.cjs`, SHA256
|
||||
`9AB9E82D41BF8FAE98913DBF2BB9F22C8DA48C61335C47A32B04D7857F336087`.
|
||||
|
||||
Deux premiers lancements du runner privé n'ont pas atteint Chromium : le premier
|
||||
ne résolvait pas `playwright` depuis TEMP, le second contenait des séparateurs de
|
||||
ligne PowerShell littéraux. Ils ont échoué respectivement avec `MODULE_NOT_FOUND`
|
||||
et `SyntaxError`; le runner final résout explicitement les dépendances depuis le
|
||||
worktree. Ces erreurs de préparation ne sont pas comptées comme rouge/vert produit.
|
||||
|
||||
## Contrôles statiques et empreintes
|
||||
|
||||
- `npm run typecheck --workspace @codenomad/ui` : exit 0.
|
||||
- `git diff --check -- packages/ui/tests/browser/git-history.test.ts` : exit 0,
|
||||
avec le seul avertissement LF/CRLF existant.
|
||||
- Aucun fichier produit modifié par cette tâche.
|
||||
|
||||
Manifest privé :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-trace-serialization-fix/manifest.json`,
|
||||
SHA256 `90405BE5AE1A1BF9C328B226AA3634FB7B49B7C5D754A4B23F569193FD551F32`.
|
||||
|
||||
Par rapport au gate final :
|
||||
|
||||
- `git-history.test.ts` :
|
||||
`CD4776C4B035FE2C19A80BC7F562FC916A0B4F3388DAD7F1C0A13F683E18DEE4`
|
||||
→ `DC7C378CC15E54F22F5D7E1A4C485313F0AF9DF536E1544690A33E3B2B2E5264` ;
|
||||
- `session-rendering.test.ts` reste
|
||||
`D50A2932039D3E44FA06BF1E02AA75D108A6C80520CE15724D229A1FEBA4E982` ;
|
||||
- `fixtures/user-html.tsx` reste
|
||||
`3D7616C20EDCFA32A7388E473C877B89089DA76FA482CD2E91C1160F95AAD631`.
|
||||
|
||||
Les quatre empreintes produit suivies restent celles du gate/focus-sync :
|
||||
|
||||
- `monaco-diff-viewer.tsx` : `3EB01E2C6D255E41A92FDCACBF946455B7A87293B19F3389D4FAE5C1381C3142` ;
|
||||
- `session-view.tsx` : `733C02DE156A75ECD0D2F7C6DCD90A25050C0A1FC5D8EFD2F0E975D649C4E9AB` ;
|
||||
- `prompt-input.tsx` : `9C8BE5BE1336A11E3188F94716DC3BAE6B63C1EB96BF20DF1F31913318345328` ;
|
||||
- `markdown.tsx` : `51EE39FE0CC2FF5CF4258221B48919EF41AE9C3592450DF9D3FAF4EF0D2C3456`.
|
||||
|
||||
Ainsi, parmi les trois inputs qui distinguaient le gate final de la validation
|
||||
combinée, seul le script Git opt-in change dans cette tâche. Le manifeste immuable
|
||||
1 696 fichiers du gate reste une preuve historique, pas le manifeste du nouvel
|
||||
état.
|
||||
|
||||
## Limites et prochain pas
|
||||
|
||||
La validation prouve la sérialisation, l'absence de `pageerror`, la borne et
|
||||
l'activité de la capture dans un DOM minimal. Elle ne rejoue pas le composant
|
||||
Monaco, les assertions Git ni le corpus complet, conformément à l'interdiction.
|
||||
|
||||
Le plus petit prochain pas est une décision du coordinateur : conserver le bilan
|
||||
terminal comme non vert avec ce défaut de test causalement corrigé, ou autoriser
|
||||
ultérieurement un unique gate d'acceptation sur une composition figée. Aucun retry,
|
||||
patch produit ou promesse d'élimination universelle de #824 ne découle de cette
|
||||
preuve.
|
||||
261
dev-docs/issue-824-combined-validation.md
Normal file
261
dev-docs/issue-824-combined-validation.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
# #824 — validation combinée fraîche
|
||||
|
||||
## Verdict terminal
|
||||
|
||||
**Le gate navigateur complet frais n'est pas vert** : 356 pass, 3 fail, 1 skip.
|
||||
Serveur (après replay), typechecks, builds, assertions stores et fixture native
|
||||
passent, sur sources inchangées. A/B frais positif. Le replay des trois cas passe
|
||||
3/3 naturellement ; les huit suites ciblées passent **104/104** naturellement.
|
||||
Le travail de validation et sa clôture sont **terminés**, pas « tous les gates verts ».
|
||||
Aucun verdict
|
||||
« tout vert » ne peut être déduit d'un succès ciblé. Les résultats ci-dessous ont
|
||||
été exécutés ici, et ne recyclent ni les anciens 348 passes navigateur ni les
|
||||
mesures des audits précédents.
|
||||
|
||||
2026-10-01, worktree `issue-824-performance`, HEAD
|
||||
`366830f6b4373e6b283cabf7dd04764e87e1cc32`, modifications mission non committées.
|
||||
Les quatre dépendances de revue sont completed ; la re-review indépendante a clos
|
||||
M1. Aucun produit/test/config/lock/dependency édité par ce validateur ; seul ce
|
||||
rapport et les artefacts privés de validation sont sa livraison. Aucun déploiement,
|
||||
restart utilisateur, commit/push, install ou sous-agent.
|
||||
|
||||
## Identité des sources
|
||||
|
||||
Artefacts sous :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-combined-b91c8b66fcc840e1909170c5eeb846b9/`.
|
||||
|
||||
`initial.json` enregistre **1 696 fichiers** : Git tracked **et** untracked non
|
||||
ignorés, y compris nouveaux tests/modules/scripts, configs et lockfiles. Parmi eux,
|
||||
1 379 chemins dans les sources packages, 43 suites browser et 69 scripts. Hash des
|
||||
octets bruts, pas de normalisation des fins de ligne. Exclusions : Markdown/PDF/docs,
|
||||
dist/public/node_modules/cache/release/target. Les dépendances installées ne sont
|
||||
donc pas fingerprintées ni modifiées ; environnement/exécutable Node enregistré.
|
||||
|
||||
SHA256 initial :
|
||||
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
|
||||
Chaque gate publie son manifeste avant/après, son statut, son log et leurs SHA256
|
||||
dans `<gate>-result.json`. Sur les seize gates/tentatives terminés, **aucune dérive**,
|
||||
y compris par rapport au
|
||||
manifeste initial. La comparaison constate l'immutabilité aux bornes ; ce n'est pas
|
||||
un verrou filesystem interdisant une modification transitoire suivie d'un retour.
|
||||
|
||||
## Gates frais terminés
|
||||
|
||||
Windows x64, Node `v25.2.1`. Tous les exits ci-dessous sont enregistrés, pas inférés
|
||||
de l'apparition d'un dernier test vert. Les sous-gates inclus dans une suite complète
|
||||
ne s'additionnent pas à sa couverture.
|
||||
|
||||
| Gate | Résultat | Sortie | Temps |
|
||||
| --- | --- | --- | --- |
|
||||
| serveur complet, premier run | 792 pass / 1 fail / 6 skip, 799 tests | naturelle, exit 1 | 27,8 s |
|
||||
| test Git concerné isolé | 9/9 pass | naturelle, exit 0 | 14,1 s |
|
||||
| serveur complet, replay | **793 pass / 0 fail / 6 skip**, 799 tests | naturelle, exit 0 | 30,8 s |
|
||||
| typecheck UI | pass | naturelle, exit 0 | 8,2 s |
|
||||
| typecheck serveur | pass | naturelle, exit 0 | 4,0 s |
|
||||
| typecheck Electron | pass | naturelle, exit 0 | 1,2 s |
|
||||
| build serveur, incluant UI + plugins pruning/automation | pass | naturelle, exit 0 | 26,0 s |
|
||||
| stores/reducer/admission/pruning | **141/141 pass** | **force-exit**, exit 0 | 7,2 s |
|
||||
| nouveau test store compactage sans force-exit | 11 assertions affichées pass, pas de résumé final | **timeout 30 s**, SIGTERM privé | non qualifiant lifecycle |
|
||||
| event-source-handlers | 4/4 pass | naturelle, exit 0 | 0,23 s |
|
||||
| diff-check tracked | pass, avertissements LF/CRLF seulement | naturelle, exit 0 | 0,04 s |
|
||||
| fixture native complète, CLI explicite 2.0.21 | pass | naturelle, exit 0 | 63,4 s |
|
||||
| navigateur complet | **356 pass / 3 fail / 1 skip**, 360 tests | naturelle, exit 1 | 21 min 38 s |
|
||||
| A/B renderer, trois couples | 30 samples par variante | naturelle, exit 0 | 90,1 s |
|
||||
| trois échecs navigateur, replay par noms exacts | **3/3 pass** | naturelle, exit 0 | 33,1 s |
|
||||
| huit suites navigateur ciblées | **104/104 pass**, zéro skip/cancelled | naturelle, exit 0 | 306,4 s |
|
||||
|
||||
Le premier échec serveur est conservé intégralement dans `server.log` :
|
||||
`git-worktree-config.test.ts`, test « checks a redirected main checkout when opened
|
||||
from an ordinary linked checkout », échoue dans le **afterEach rmSync** ligne 40
|
||||
sur un répertoire privé avec `EPERM`. Ce n'est pas une assertion Git échouée.
|
||||
Les mêmes sources passent ensuite le test isolé et le replay complet. La cause du
|
||||
refus Windows (processus Git/antivirus/autre) n'est pas démontrée ; ne pas effacer ce
|
||||
run ni revendiquer deux suites complètes vertes.
|
||||
|
||||
Les six skips serveur restent les quatre opt-in WSL write (mode 0600, symlink loop,
|
||||
conflit mode-only, directory-before-rename) et les deux permissions/symlink POSIX.
|
||||
Ils ne sont pas couverts sur cet hôte. Le gate store emploie la convention existante
|
||||
`--conditions=browser --test-force-exit`, avec rejets stricts : il qualifie ses
|
||||
assertions, **pas le nettoyage du processus**. La tentative naturelle du fichier
|
||||
nouveau n'a pas résolu cette limite connue de revue. Aucun timer arbitrairement
|
||||
annulé/unrefé, aucun test/produit modifié pour obtenir un exit vert.
|
||||
|
||||
Le build réexécute `npm run build --workspace @neuralnomads/codenomad` : clean du
|
||||
dist généré, Vite UI, copie public, compilation serveur et bundling des deux plugins.
|
||||
Avertissements non bloquants : imports à la fois statiques/dynamiques, chunks >500 kB
|
||||
et données Browserslist anciennes. Aucun rebuild/relaunch de l'application installée.
|
||||
|
||||
## Qualification native privée fraîche
|
||||
|
||||
Commande exacte avec rejets stricts :
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict scripts/test-session-pruning-native.mjs 'C:/Users/Admin/AppData/Roaming/npm/node_modules/@opencode/cli/bin/opencode.exe'
|
||||
```
|
||||
|
||||
Le script vérifie **2.0.21**, lance son propre enfant `serve`, provider, config,
|
||||
HOME/XDG et DB privés, et se termine naturellement. Il ne découvre/redémarre/arrête
|
||||
pas le daemon utilisateur. Fixture conservée :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/opencode/codenomad-pruning-native-BRHy0F`.
|
||||
|
||||
`native.log` confirme subscriber abort/return/throw sans invalider la connexion,
|
||||
relay FIFO/slow recipients/shutdown, worktrees et cache, vraies routes proxy
|
||||
prompt/command/Shell/inbox/cancel/Forms/permissions, pièce jointe 5 MiB, counts/search,
|
||||
outline 1 501 messages, cleanup/retry/restore/fork, découverte et présence du plugin
|
||||
bundlé. C'est **un fixture complet**, pas un nombre de tests à ajouter aux 799.
|
||||
Le restart mentionné dans son log ne concerne que son enfant jetable.
|
||||
|
||||
Cela confirme le bundle et les intégrations sur runtime privé Windows, pas une vraie
|
||||
charge utilisateur/provider, ni Electron Linux → Windows LAN/OpenCode 2.0.19.
|
||||
Les deux anciens fixtures complets et la matrice native compaction du coordinateur
|
||||
restent des preuves historiques distinctes, non additionnées aux résultats ici.
|
||||
|
||||
## Navigateur et A/B
|
||||
|
||||
Replay complet lancé avec `npm run test:browser --workspace @codenomad/ui`, budget
|
||||
60 minutes, sans force-exit/force-click. Avant lancement : demande au coordinateur,
|
||||
journal ancien non trouvé à son chemin exact, puis vérification read-only des
|
||||
processus Node : aucune suite `tests/browser|test:browser` encore en cours. Aucun
|
||||
processus partagé arrêté. Ce replay utilise les sources courantes, pas le vieux gate.
|
||||
|
||||
Le run complet termine naturellement (exit 1), aucun cancelled, aucun drift. Le
|
||||
skip reste « Electron BrowserWindow native zoom retains tab seams and matching
|
||||
scrollbars » : opt-in `CODENOMAD_TEST_ELECTRON` absent (`tab-chrome.test.ts:116`).
|
||||
|
||||
Échecs exacts conservés dans `browser.log` :
|
||||
|
||||
1. `git-history.test.ts:590`, « central diff inserts local lines and revision-qualified
|
||||
history into the real session composer » : assertion ligne 609 `false !== true`
|
||||
sur l'identité textarea/`document.activeElement` après clic Ajouter au prompt.
|
||||
Les deux assertions de texte/référence précédentes passent ; pas de perte de
|
||||
brouillon démontrée par cet échec.
|
||||
2. `mcp-motion.test.ts:47`, « eight connected MCP servers stay static at rest
|
||||
(reduce, 150% scale) » : timeout 30 s sur `window.mcpFixture` avant assertion
|
||||
de géométrie/animation. Cause du non-montage non démontrée.
|
||||
3. `session-rendering.test.ts:82`, « user HTML mode preserves Markdown, pasted
|
||||
disclosures and code source and fences legacy caches » : timeout 30 s ligne
|
||||
104 sur visibilité `#cache .cached-html` après `fixture.literal(false)`.
|
||||
|
||||
Les gates compactage/SSE/HTTP/membership et le sample instrumenté sont verts dans
|
||||
ce même run, mais une régression session-rendering voisine ne l'est pas. Les scopes
|
||||
ne s'additionnent pas aux 360 tests. Le replay ciblé n'altère ni code, timing bounds,
|
||||
clics, exits ou assertions et doit être rapporté séparément.
|
||||
|
||||
### Disposition des trois échecs au replay
|
||||
|
||||
`browser-failures.log` et `browser-failures-result.json` : 3 tests, 3 pass, zéro
|
||||
fail/skip/cancelled, rejets stricts, exit 0 naturel en 33,1 s. Manifeste avant/après
|
||||
identique à l'initial. Filtrage des noms exacts dans les trois mêmes fichiers,
|
||||
**sans modifier leurs fixtures, délais, clics ou assertions**. Ce constat suffit
|
||||
pour dire « non reproduits dans ce replay », pas « résolus » ni « causes connues ».
|
||||
|
||||
| Échec complet | Démontré | Pas démontré / prochain élément minimal |
|
||||
| --- | --- | --- |
|
||||
| insertion Git, focus | contenu et référence insérés ; focus non revenu au textarea à l'instant de l'assertion ; replay pass | Ordre/timing exact de focus non capturé. En cas de récurrence, enregistrer activeElement et les callbacks de restitution de focus autour du clic, sans changer l'assertion. |
|
||||
| MCP readiness | `window.mcpFixture` absent après 30 s dans le complet ; replay pass | Cause d'échec de bootstrap non capturée. Capturer pageerror/console/requestfailed et réponses modules Vite au montage, avant d'attribuer au cache ou au produit. |
|
||||
| user HTML/cache | `#cache .cached-html` pas visible après passage literal(false) ; replay pass | DOM/cache/promise de rendu à cette frontière non capturés. Capturer DOM, état literal et résolution du rendu avant/après toggle au prochain échec. |
|
||||
|
||||
Scheduling/charge de l'hôte, optimisation Vite ou état de cache sont des pistes,
|
||||
pas des causes démontrées. Aucun patch proposé pour fabriquer un gate vert. Une
|
||||
nouvelle suite complète éventuelle relève de la décision du coordinateur après
|
||||
attribution ; le suivi de clôture ne lance aucune suite supplémentaire.
|
||||
|
||||
### Suites ciblées existantes récupérées à la clôture
|
||||
|
||||
Le runner déjà lancé `validate.cjs browser-failures focused` (shell
|
||||
`sh_0f93a730b001Ofu9KyF5KkL76a`, PID 41540 lors du contrôle) est terminé ; son
|
||||
résultat final a été reçu, pas inféré du dernier test affiché. Aucun nouveau run
|
||||
de validation ou audit n'a été lancé lors de `validation-closeout`.
|
||||
|
||||
`focused-result.json` : exit 0 naturel, rejets stricts, 306,4 s, sources avant/après
|
||||
et par rapport à l'initial inchangées. `focused.log` : **104 tests, 104 pass, 0 fail,
|
||||
0 skip, 0 cancelled**. Détail non additif : compactage 5, isolation SSE 5, HTTP/1
|
||||
admission 5, render-cost 13, history-navigation 21, session-rendering 46, task-copy 6,
|
||||
tool-images 3. Le cas user HTML passe donc aussi dans sa suite voisine complète,
|
||||
pas seulement sous le filtre des trois retries. Sample 80 shells sans remount et
|
||||
compteurs/contenus compactage exacts restent vérifiés. Ni ce gate, ni les trois
|
||||
retries ne changent le statut du fullbrowser à exit 1.
|
||||
|
||||
## Pièces terminales et décision suivante
|
||||
|
||||
`terminal-gates.json` indexe les seize statuts, commandes exactes, heures, sorties
|
||||
naturelles/forcées, SHA256 des logs/manifests et dérives. Le runner privé final est
|
||||
conservé sous `validate.cjs`, SHA256
|
||||
`66d119cfe89bd824e3568e5a0447106e06fc5633c2cb0456a86515d9caecb064`.
|
||||
|
||||
Principaux logs SHA256, dans le répertoire d'artefacts ci-dessus :
|
||||
|
||||
| Fichier | SHA256 |
|
||||
| --- | --- |
|
||||
| `browser.log` | `19099de5e3c3ec694334c4667575715d8dc2dc906f80e558cf54773ea0880a1b` |
|
||||
| `browser-failures.log` | `6035e5c53b01dc30cc936d6183d8625ebda890908c5ffe7b24d63e9c8010f130` |
|
||||
| `focused.log` | `bac7cadaf82a5cbf238851a1f5f86eac1b6b431352c0dc5e3161e9db75752628` |
|
||||
| `server.log` (premier échec) | `d8660dcba0935f9a94fdb965eb9a7723a0f9d8000db18e6ea79bbcce96c55247` |
|
||||
| `server-rerun.log` | `8fafb4e813b85cfd8abc74bd7e1e1f6f4b4a9df38bb4a6f7934d9781923f84de` |
|
||||
| `native.log` | `e74886aee42300ee4543a6f2518bc8b714aeec4922f428f4f8ea113efa994ee7` |
|
||||
| `ab.log` | `0d2e7cb864ff89060d552a4aafa3ed0d1da7f1ba2ca17c7fa7068b1d32be9205` |
|
||||
|
||||
Le suivi propriétaire a récupéré ces artefacts, finalisé seulement ce rapport et
|
||||
son index privé, sans edit produit/tests/config/deps, installation, merge, commit,
|
||||
push, suppression de sessions, restart ou déploiement. Les travaux de validation
|
||||
peuvent être rapportés `completed` ; **la qualification globale reste non verte**.
|
||||
Le coordinateur décide de la clôture de mission, pas ce validateur.
|
||||
|
||||
Prochain minimum utile : conserver les trois preuves d'échec global et les retries
|
||||
séparés ; si un fullbrowser vert est un critère de livraison, autoriser un suivi
|
||||
instrumenté des trois frontières décrites plus haut, puis décider d'un nouveau
|
||||
gate complet figé. Ne pas élargir les corrections #824 sur simple hypothèse de
|
||||
flake. La sortie naturelle stores et Linux Electron → Windows LAN/2.0.19 demeurent
|
||||
des limites explicites, pas des gates implicitement acquis.
|
||||
|
||||
### A/B CPU du postimage M1 actuel
|
||||
|
||||
`node scripts/test-render-cost-audit.mjs 3` passe après le fullbrowser : trois
|
||||
couples alternés baseline/fixed, 80 outils × 8 000 caractères, trois warmups puis
|
||||
dix deltas chacun, **instrumentation off**. SHA produit testé
|
||||
`f07ce171a6b1d3394ce94be496b0abd67f8bc0bb48e946eb9173a681c844f401`.
|
||||
Baseline `47f9e43e` a le même blob renderer que `366830f6` (preuve précédente).
|
||||
|
||||
| ms/delta, médiane [min–max], écart-type échantillon | Baseline | Postimage courant |
|
||||
| --- | --- | --- |
|
||||
| main-thread TaskDuration | 324,57 [168,06–743,53], 173,67 | 16,94 [9,93–50,33], 11,27 |
|
||||
| ScriptDuration | 14,51 [7,88–46,20], 9,03 | 0,25 [0,11–32,37], 9,34 |
|
||||
| dispatcher sync | 12,05 [6,70–19,90], 3,70 | 0,20 [0,10–0,50], 0,08 |
|
||||
| layout | 2,88 [1,51–4,27], 0,55 | 0,08 [0,07–0,23], 0,04 |
|
||||
| recalcul style | 10,20 [5,74–13,91], 1,82 | 0,13 [0,10–0,30], 0,05 |
|
||||
|
||||
30 échantillons par variante. CDP cumule jusqu'à deux animation frames ; ce n'est
|
||||
pas une mesure de latence-frame, FPS, heap, débit ou Electron production. Host
|
||||
activity non contrôlée ; pas de seuil absolu CI. Ces chiffres sont nouveaux et
|
||||
séparés des A/B antérieurs au correctif membership. Le fullbrowser instrumenté
|
||||
indépendant compte **80 clones / 0 added / 0 removed** dans chacun de dix deltas,
|
||||
avec assertion anti-remount ; les valeurs zéro du run instrumentation off ne sont
|
||||
pas des compteurs de travail réel.
|
||||
|
||||
Artefact A/B :
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/codenomad-render-cost-srSQOf/metrics.json`,
|
||||
SHA256 `c72a491eba14d5e71178ba2895a7304ce07f98a02d340a53ef9f75f4424b7cd0`,
|
||||
six logs bruts adjacents. `generated-and-ab-hashes.json` identifie aussi les deux
|
||||
plugins bundlés utilisés par la reprise native.
|
||||
|
||||
## Limites et tailles signalées
|
||||
|
||||
- Pas de qualification Electron Linux, LAN/VPN ou runtime 2.0.19, ni preuve
|
||||
d'élimination universelle du freeze signalé. Le test HTTP/1 Chromium est Windows
|
||||
loopback ; native et browser constituent des périmètres distincts.
|
||||
- Buffer compactage sans plafond explicite octets/événements dans une tâche sans
|
||||
yield ; le délai 250 ms n'est pas une garantie de mémoire bornée.
|
||||
- Scan task structurel à chaque révision conserve les bornes 10 000/200 mais peut
|
||||
coûter du CPU même sans changement de membership. Le sample 80 est borné, pas
|
||||
une mesure du pire cas ni de heap/FPS/production desktop.
|
||||
- Un pas SQLite reste synchrone malgré le budget coopératif du scanner outline.
|
||||
- Le hashing async utilise toujours le pool libuv et le CPU/mémoire disponibles.
|
||||
|
||||
Surfaces modifiées par la mission au-dessus du seuil de signalement (~500 lignes),
|
||||
sans refactor de taille par cette validation :
|
||||
|
||||
- `packages/server/src/server/http-server.ts` ~2 346 lignes ;
|
||||
- `packages/server/src/workspaces/manager.ts` ~1 240 ;
|
||||
- `packages/ui/src/stores/instances.ts` ~2 093 ;
|
||||
- `packages/ui/src/stores/opencode-data.ts` ~828 ;
|
||||
- `packages/ui/src/components/tool-call/renderers/task.tsx` ~569.
|
||||
124
dev-docs/issue-824-compaction-ui-fix.md
Normal file
124
dev-docs/issue-824-compaction-ui-fix.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Issue #824: compaction delta reduction correction
|
||||
|
||||
## Outcome and scope
|
||||
|
||||
Native compaction fragments now aggregate **before** the OpenCode Solid reducer,
|
||||
not merely before the visible transcript projection. No server, transport,
|
||||
`task.tsx`, daemon, user profile or desktop deployment changes belong to this
|
||||
correction. The preceding large synchronous backlog is diagnostic evidence,
|
||||
not an acceptance gate or proof of real provider delivery frequency.
|
||||
|
||||
The implementation follows installed `@opencode/client@2.0.21` declarations and
|
||||
its actual Solid reducer: `session.compaction.delta.data.text` appends to the
|
||||
running row's `summary`; `ended.data.text` replaces it authoritatively; `failed`
|
||||
replaces the running row with an error record. Durable start/end metadata is not
|
||||
changed or synthesized.
|
||||
|
||||
## Product changes
|
||||
|
||||
- `stores/compaction-delta-buffer.ts`: one plain accumulator and one 250 ms timer
|
||||
per instance/session. It uses the exact native delta type and joins chunks in
|
||||
arrival order. The interval begins on the first chunk, so later chunks cannot
|
||||
postpone publication indefinitely.
|
||||
- `stores/opencode-data.ts`: incoming fragments immediately advance CodeNomad
|
||||
message/full-data revisions and fence SDK reads, but emit one aggregate to
|
||||
the reducer per interval. Aggregate reentry does not advance admission twice.
|
||||
Same-session non-delta events flush preceding chunks to retain native order;
|
||||
unrelated sessions/instances cannot force a flush. End/failure, delete,
|
||||
committed revert, pruning invalidation, disposal and reconnect cancel pending
|
||||
content where its authority has ended. Idle flushes before reducer retirement.
|
||||
- SDK history reads flush pending chunks before establishing read authority and
|
||||
before accepting responses. Otherwise a native page fetched after admission
|
||||
could already contain buffered text that would be appended a second time.
|
||||
A stale pre-delta page is rejected and receives the existing trailing read.
|
||||
- Inactive sessions with neither a resident transcript nor a native observer
|
||||
do not materialize compaction payloads. Only their scalar admission fences and
|
||||
an unfinished-compaction marker remain. Final activation uses the native page.
|
||||
Activation during an unfinished skipped start recovers the running row via the
|
||||
existing authoritative resync path, rather than inventing a start/summary.
|
||||
The SDK's 20-row resync preserves the already hydrated native 200-row window.
|
||||
Subsequent streaming resumes interval reduction without duplication.
|
||||
- `stores/instances.ts`: remove the old post-reducer timer. Deferred aggregate
|
||||
application projects once immediately; inactive loaded views remain invalidated
|
||||
but are not hydrated. Client replacement/removal destroys the owned native
|
||||
projection, including its pending buffer, rather than cancelling only the
|
||||
now-removed visible projection timer.
|
||||
|
||||
## Deterministic red/green evidence
|
||||
|
||||
The browser fixture instruments the real native emission and visible projection
|
||||
boundaries through a test-only Vite transform. There is no product telemetry API,
|
||||
mock reducer or render reimplementation. Both the full stylesheet and real
|
||||
`SessionView` are mounted. HTTP/client responses are private fakes, and browser
|
||||
requests outside loopback are blocked.
|
||||
|
||||
| Contract | Baseline `366830f6` | Corrected |
|
||||
| --- | ---: | ---: |
|
||||
| 128 tiny active chunks, immediate reducer emissions | 128 | 0 |
|
||||
| Same burst, after first interval | 128 native emissions | 1 emission + 1 visible projection |
|
||||
| 8 chunks spaced by 10 ms, first interval | 8 emissions | 1 emission + 1 projection |
|
||||
| Second spaced interval | another 8 emissions | another 1 emission + 1 projection |
|
||||
| 128 chunks for never-loaded inactive compaction | 128 emissions | 0 emissions, 0 projections, 0 message-list reads |
|
||||
| Running compaction activated midway, subsequent 2 chunks | 2 emissions | 1 emission + 1 projection, exact cumulative text |
|
||||
| Loaded but now inactive session, 4 chunks | 4 emissions | 1 emission, 0 hidden-view projections |
|
||||
|
||||
All five baseline browser assertions fail by exact counts, not timing or timeout.
|
||||
All five corrected cases pass, including exact authoritative final text,
|
||||
remount, immediate per-arrival revisions, no duplicate chunks, and active-session
|
||||
isolation. Total active burst text is only 530 characters, not the preceding
|
||||
32 MiB corpus. Spaced delivery uses genuine distinct task/timer intervals through
|
||||
the native dispatcher, not one giant callback.
|
||||
|
||||
Descriptive ingress sample: baseline 128 chunks took **1.6 ms**, corrected
|
||||
**0.4-0.5 ms** on the same host with other mission activity. These are sampled
|
||||
development-build dispatcher durations, not controlled CPU benchmarks, frame
|
||||
latency, or a claim of eliminating every freeze. No Long Task threshold remains.
|
||||
|
||||
## Executed gates
|
||||
|
||||
```powershell
|
||||
# Current browser regression (5 pass)
|
||||
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts
|
||||
|
||||
# Neighboring real-renderer/window/navigation regressions (67 pass)
|
||||
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/history-navigation.test.ts packages/ui/tests/browser/session-rendering.test.ts
|
||||
|
||||
# Native reducer/admission/pruning gates (141 pass, including 11 new compaction cases)
|
||||
node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/stores/opencode-compaction.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/opencode-data-idle.test.ts packages/ui/src/stores/opencode-data-settlement.test.ts packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-pruning-pagination.test.ts packages/ui/src/stores/session-pruning-projection.test.ts packages/ui/src/stores/session-pruning-events.test.ts
|
||||
|
||||
npm run typecheck --workspace @codenomad/ui
|
||||
git diff --check
|
||||
```
|
||||
|
||||
All commands above pass. The first combined browser invocation had a 120 s
|
||||
harness timeout after 17 passing cases; it is not counted as complete. Re-running
|
||||
the neighboring suites with a 360 s allowance completed all 67 cases naturally.
|
||||
Unit gates use the repository's browser-condition/force-exit convention because
|
||||
imported store timers persist. Diff check has only ordinary LF/CRLF warnings.
|
||||
|
||||
To reproduce baseline red without changing the checkout, extract the two
|
||||
`366830f6` blobs `packages/ui/src/stores/instances.ts` and
|
||||
`packages/ui/src/stores/opencode-data.ts` into a private temporary directory.
|
||||
Set `CODENOMAD_COMPACTION_BASELINE` to it, then run the five browser cases. Vite
|
||||
substitutes those blobs read-only and applies the same exact counters. Leave the
|
||||
variable unset for ordinary corrected runs. Both old and corrected code use the
|
||||
same dependencies, components and fixtures.
|
||||
|
||||
## Remaining limits / review targets
|
||||
|
||||
- This establishes avoided reactive work under modest separate event delivery;
|
||||
it does not qualify real EventSource wire traffic or Electron/Linux against a
|
||||
Windows backend. The companion transport/native diagnostics remain distinct.
|
||||
- Same-session non-delta events and native reads intentionally force a flush.
|
||||
A workload continuously interleaving those boundaries can reduce the coalescing
|
||||
ratio; ordering and read authority win over a universal four-flushes/s promise.
|
||||
- Mid-compaction activation uses the existing quiet/revision-stable native resync
|
||||
policy. Sustained arrivals can delay its authoritative catch-up; the already
|
||||
hydrated visible native page remains displayed. This patch does not replace
|
||||
that broader starvation policy or merge arbitrary in-flight history snapshots.
|
||||
- No user database, daemon, provider calls, native-host timing, heap soak or
|
||||
deployment/restart was used. Source/token loss boundaries are covered by mocked
|
||||
deterministic native records, not an end-to-end real provider compaction run.
|
||||
- File-size warning: touched `stores/opencode-data.ts` is approximately **828**
|
||||
lines and `stores/instances.ts` **2,093** lines. The new narrow buffer module is
|
||||
56 lines; no size-only refactor was attempted.
|
||||
112
dev-docs/issue-824-event-review.md
Normal file
112
dev-docs/issue-824-event-review.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Issue 824 — revue des barrières d'événements
|
||||
|
||||
## Verdict
|
||||
|
||||
Revue indépendante, bornée au diff depuis `366830f6`, de
|
||||
`packages/ui/src/lib/server-events.ts`,
|
||||
`packages/server/src/workspaces/opencode-service.ts` et de leurs nouveaux tests.
|
||||
|
||||
La correction serveur est sûre pour le modèle de partage réel de
|
||||
`@opencode/client` 2.0.21 : l'arrêt d'un abonné ne détruit plus la connexion
|
||||
logique partagée, tandis qu'une vraie fin ou erreur amont invalide encore la
|
||||
génération qui l'a produite. La correction UI isole correctement une exception
|
||||
synchrone levée directement par un callback et conserve l'ordre FIFO. Aucun
|
||||
blocage fonctionnel n'a été trouvé dans ces deux corrections.
|
||||
|
||||
Il reste toutefois une limite UI confirmée et une faiblesse de diagnostic à ne
|
||||
pas présenter comme entièrement résolues.
|
||||
|
||||
## Constats
|
||||
|
||||
### M1 — une erreur Solid différée au flush échappe encore à l'isolation par abonné
|
||||
|
||||
`ServerEvents.notify()` entoure l'appel direct du callback, mais
|
||||
`dispatchBatch()` entoure tous les callbacks d'un `solidBatch`. Une dérivation
|
||||
Solid déclenchée par un setter peut donc lever seulement lors du flush final de
|
||||
`solidBatch`, après le retour de `notify()`. Cette erreur sort à la ligne du
|
||||
`solidBatch`, puis est capturée par le `catch` englobant de
|
||||
`attachEventSourceHandlers`, qui la journalise à tort comme `Failed to parse
|
||||
event`.
|
||||
|
||||
Reproduction isolée avec la distribution navigateur de Solid : un
|
||||
`createMemo` qui lève après `setSignal()` imprime `after-set`, puis l'exception
|
||||
est capturée autour de `batch()`, pas autour du setter. Le fixture navigateur
|
||||
actuel ne couvre qu'un `throw` direct dans le callback et affirme justement
|
||||
l'absence de `Failed to parse event`; il ne détecterait pas cette variante.
|
||||
|
||||
Impact borné : les callbacks natifs et typés du même lot sont invoqués avant le
|
||||
flush, donc l'événement n'est pas supprimé pour ces abonnés. En revanche, le
|
||||
flush réactif peut être interrompu et le diagnostic accuse le transport. Le
|
||||
plus petit suivi utile est un test navigateur où un abonné écrit un signal dont
|
||||
une dérivation lève, puis une décision explicite sur la frontière de batch
|
||||
(isoler aussi le flush par abonné, ou au minimum séparer le diagnostic de
|
||||
dispatch du parsing).
|
||||
|
||||
### L1 — le diagnostic ajouté est désactivé par défaut
|
||||
|
||||
`log.error()` utilise le logger `debug` de l'espace `sse`; les espaces sont
|
||||
désactivés par défaut. Les tests remplacent directement `getLogger("sse").error`
|
||||
et prouvent que la méthode est appelée, mais pas qu'un utilisateur ou un rapport
|
||||
de diagnostic voit l'échec en configuration normale. De plus, `kind` distingue
|
||||
le type d'événement mais pas le bucket wildcard/typé ni l'abonné fautif.
|
||||
|
||||
La capture est préférable à la rupture du flux et ne constitue pas un bug de
|
||||
réactivité. Si ces exceptions doivent être exploitables sur le terrain, ajouter
|
||||
un canal borné réellement collecté et un identifiant de catégorie d'abonné,
|
||||
sans contenu d'événement ni rejeu.
|
||||
|
||||
### L2 — course abort local / panne amont non déterminisée
|
||||
|
||||
Le choix `if (!signal?.aborted) invalidateConnection(connection)` donne à
|
||||
l'abort local la priorité lorsqu'il est simultané à une erreur de `next()`.
|
||||
Avec `SharedEvents`, l'abort retire l'abonné local; une panne physique peut alors
|
||||
être observée seulement par un autre abonné direct, sans invalider immédiatement
|
||||
`OpenCodeSharedService`.
|
||||
|
||||
Le risque paraît faible et auto-récupérable : la tentative suivante du bridge
|
||||
rouvre le flux partagé; son échec non annulé invalide alors la connexion, et les
|
||||
mutations ordinaires invalident également sur erreur. Ne pas modifier la logique
|
||||
sans reproduction. Ajouter au besoin un test à barrières contrôlées où abort et
|
||||
rejet amont concourent, puis vérifier qu'une reconnexion échouée renouvelle bien
|
||||
le client.
|
||||
|
||||
## Propriétés vérifiées
|
||||
|
||||
- Le SDK 2.0.21 partage un seul flux physique et maintient des files par abonné;
|
||||
`return()`/abort suppriment seulement l'abonné et arrêtent la source uniquement
|
||||
quand il ne reste aucun abonné.
|
||||
- `invalidateAfterStream()` distingue correctement :
|
||||
- erreur/EOF de `iterator.next()` non annulé : invalidation;
|
||||
- `return`, `throw` consommateur ou abort local : nettoyage par
|
||||
`iterator.return()` sans invalidation;
|
||||
- événement d'une ancienne génération : `assertCurrent()` rejette avant le
|
||||
`yield`, et l'invalidation conditionnelle ne peut pas évincer le remplacement.
|
||||
- Le `finally` appelle toujours `iterator.return()`; avec le SDK réel cet appel
|
||||
est résolu sans rejet.
|
||||
- Le dispatch UI continue après un `throw` direct, sans rejeu, pour les abonnés
|
||||
wildcard, typés, open et status; le test FIFO/unsubscribe traverse le vrai
|
||||
EventSource, les routes SSE et le réducteur natif.
|
||||
- Les callbacks enregistrés dans le produit sont synchrones; aucun abonné
|
||||
`async` actuel ne crée de rejet de promesse non capturé à cette frontière.
|
||||
|
||||
## Validation exécutée
|
||||
|
||||
- `node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/event-subscriber-isolation.test.ts` — **4/4 vert**.
|
||||
- `npm exec --yes tsx -- --test packages/server/src/workspaces/subscriber-lifetime.test.ts packages/server/src/workspaces/opencode-connection-generation.test.ts` — **10/10 vert**.
|
||||
- `node --import tsx --test packages/server/src/workspaces/opencode-service.test.ts` — **14/14 vert**.
|
||||
- `node --import tsx --test packages/server/src/workspaces/instance-events.test.ts packages/server/src/workspaces/instance-event-routing.test.ts` — **31/31 vert**.
|
||||
- `npm run typecheck --workspace @neuralnomads/codenomad` — vert.
|
||||
- `npm run typecheck --workspace @codenomad/ui` — vert.
|
||||
- `git diff --check` sur les fichiers revus — vert (seuls avertissements de
|
||||
conversion LF/CRLF du worktree).
|
||||
|
||||
Le lancement global `test:browser` avec un filtre placé après le glob a été
|
||||
interrompu à 120 s car le runner a commencé d'autres fixtures; il ne constitue
|
||||
pas un échec de test. Le fichier navigateur ciblé a ensuite été exécuté seul et
|
||||
est vert.
|
||||
|
||||
## Taille des fichiers touchés
|
||||
|
||||
Aucun fichier produit revu ne dépasse le seuil d'avertissement :
|
||||
`server-events.ts` ~219 lignes et `opencode-service.ts` ~360 lignes. Les nouveaux
|
||||
tests font respectivement ~123 et ~116 lignes.
|
||||
139
dev-docs/issue-824-http-admission-fix.md
Normal file
139
dev-docs/issue-824-http-admission-fix.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Issue #824: HTTP preflight cancellation
|
||||
|
||||
## Scope and result
|
||||
|
||||
Baseline: `366830f6`; original red diagnostic retained in commit `1ece148c`.
|
||||
This fixes **orphaned HTTP preflight observers**, not the original global freeze.
|
||||
Initial implementation is in `issue-824-transport`; the seven paths are now
|
||||
integrated uncommitted in `issue-824-performance`. No shared daemon, database,
|
||||
profile, desktop restart or deployment is used.
|
||||
|
||||
When prompt/compact waits on a native ownership read, disconnecting the desktop
|
||||
previously left that read active because disconnect tracking began only after
|
||||
authorization. The original HTTP fixture failed for both operations while an
|
||||
independent session answered in 12/3 ms. This shows a cancellation defect, not
|
||||
proof that native compaction stalls that read in the user's environment.
|
||||
|
||||
## Small correction
|
||||
|
||||
- `server/request-admission.ts` observes request `aborted` and incomplete
|
||||
response `close` from the start of the instance proxy handler. It does not
|
||||
observe ordinary request `close`, which can mean a completely read POST body.
|
||||
- `http-server.ts` propagates the signal to profile negotiation, session,
|
||||
project, PTY/Shell and supported location-ownership native reads. Waiting on
|
||||
shared acquisition or non-cancellable filesystem/inventory work races only
|
||||
this observer against cancellation; it neither cancels nor invalidates the
|
||||
shared service. Late continuations cannot advance admission.
|
||||
- The scope is disposed on every return/error. Mutation fence acquisition and
|
||||
environment/native dispatch have abort guards. The existing 15-second
|
||||
environment deadline and two-second advisory Git-context deadline remain;
|
||||
there is no new timeout on prompts, compaction or execution.
|
||||
- The existing forwarding scope installs its response-close handler before
|
||||
dispatch, without an asynchronous gap; the early scope remains until that
|
||||
handoff settles. `compatibility/proxy.ts` now refuses dispatch when its initial
|
||||
downstream check already found a disconnected response.
|
||||
- No interrupt, replay or daemon cancellation is added. Disconnect after an
|
||||
accepted prompt/compact continues to abandon its HTTP response as before;
|
||||
an upstream operation already admitted can finish independently.
|
||||
- `WorkspaceManager.ownsLocation` accepts an optional signal for its native
|
||||
location check. Existing directory and identity authorization is unchanged.
|
||||
|
||||
## Executed validation
|
||||
|
||||
Windows, Node 25.2.1, private ephemeral loopback listeners. The junction created
|
||||
for the earlier diagnosis was removed with `cmd /c rmdir node_modules` (junction
|
||||
only), followed by a clean `npm ci` in this worktree. The prior missing-dependency
|
||||
typecheck was an installation-state limitation, not a demonstrated source defect.
|
||||
`npm ci` reports 49 dependency vulnerabilities; no out-of-scope upgrade is made.
|
||||
|
||||
Commands:
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
|
||||
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
|
||||
$env:TMP=$env:TEMP
|
||||
node --unhandled-rejections=strict --import tsx --test "packages/server/src/**/*.test.ts"
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- Server typecheck passes after clean installation.
|
||||
- Focused run: **65/65 pass**, natural exit. Twelve real-HTTP regressions and
|
||||
three scope tests include actual Promise SDK HTTP cancellation for prompt and
|
||||
compact, independent-session response while a preflight is held, delayed
|
||||
connection/profile/ownership, pre-environment snapshot, environment write,
|
||||
no forwarding after abort, no connection invalidation/replay, admitted
|
||||
operation completion, deletion-fence release and listener disposal.
|
||||
- Timing thresholds were removed from the independent-session assertion: it
|
||||
must finish while the first read is causally held. Descriptive final aggregate
|
||||
latencies are **22/12 ms**, not a performance guarantee.
|
||||
- Entire server gate: **782 tests, 776 pass, 0 fail, 6 declared skips**, natural
|
||||
exit in ~34.7 s. Skips: four opt-in native WSL write tests and two POSIX file
|
||||
permission/symlink tests. This count includes the focused cases, not additional
|
||||
distinct coverage. Full log: `sh_0f8691e64001fwNIml1REbA1tC.out` in the harness's
|
||||
managed shell storage.
|
||||
- No full desktop/UI build or native Linux-to-Windows qualification is claimed.
|
||||
|
||||
## Remaining limits
|
||||
|
||||
Shared service acquisition and shared inventory work may continue after one
|
||||
observer leaves; cancelling those would harm other consumers. The local route
|
||||
retires immediately and refuses any late mutation admission. A buffered request
|
||||
body is parsed before the route handler; this patch does not change parser cost.
|
||||
|
||||
Next: independent review of cancellation/forwarding ownership, followed by
|
||||
integration validation. Separately measure real compaction, native session-read
|
||||
latency and Chromium HTTP/1 connection occupancy using a private runtime. This
|
||||
patch alone does not establish or eliminate the reported application-wide freeze.
|
||||
|
||||
Touched oversized sources: `server/http-server.ts` ~2346 lines,
|
||||
`workspaces/manager.ts` ~1240 lines. No unrelated size-driven refactor is made.
|
||||
|
||||
## Integration validation
|
||||
|
||||
Source: `d9598e42` on `fix/issue-824-transport`, including the original diagnostic
|
||||
from `1ece148c`. Destination:
|
||||
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` at baseline `366830f6`.
|
||||
All seven paths were applied with the patch tool, without cherry-pick, commit or
|
||||
push. The three modified production files matched the untouched destination
|
||||
baseline before integration. All seven Git blob hashes then matched the source
|
||||
commit exactly; only this report was subsequently extended with integration
|
||||
evidence. No neighboring concurrent changes were rewritten.
|
||||
|
||||
Integrated paths:
|
||||
|
||||
- `packages/server/src/server/http-server.ts`
|
||||
- `packages/server/src/workspaces/manager.ts`
|
||||
- `packages/server/src/opencode/compatibility/proxy.ts`
|
||||
- `packages/server/src/server/request-admission.ts`
|
||||
- `packages/server/src/server/request-admission.test.ts`
|
||||
- `packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts`
|
||||
- `dev-docs/issue-824-http-admission-fix.md`
|
||||
|
||||
The existing clean integration dependencies were reused without installation.
|
||||
Executed from the destination, Windows / Node 25.2.1:
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
git diff --check -- packages/server/src/server/http-server.ts packages/server/src/workspaces/manager.ts packages/server/src/opencode/compatibility/proxy.ts
|
||||
```
|
||||
|
||||
Results: **15/15** scope/disconnect and **65/65** focused pass with natural exits;
|
||||
server typecheck and scoped diff-check pass. The 15 cases are included in the
|
||||
65-case run, not distinct extra coverage. Descriptive witness latencies are
|
||||
12/9 ms in the first run and 10/9 ms in the focused run. No full integration
|
||||
server gate was rerun here; the coordinator's fresh aggregate validation follows
|
||||
the remaining integrations.
|
||||
|
||||
Guard verification: the outer entry checks abort before acquisition begins;
|
||||
`wait()` checks again after each resolved boundary before the caller continues;
|
||||
`clientForRequest()` checks before allocating shared-client work. Explicit guards
|
||||
precede mutation-fence entry, environment write, fetch acquisition handoff and
|
||||
forward dispatch. There is no asynchronous gap between the final forwarding
|
||||
guard and installation of its close listener. Late shared completion is exercised
|
||||
by held connection/profile/ownership/environment tests and cannot advance to a
|
||||
later effect. Work already admitted to the upstream remains outside this local
|
||||
observer's interruption authority.
|
||||
280
dev-docs/issue-824-native-compaction.md
Normal file
280
dev-docs/issue-824-native-compaction.md
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Issue #824 — private native compaction isolation
|
||||
|
||||
## Result
|
||||
|
||||
**No cross-session service freeze reproduced in this bounded scenario on
|
||||
OpenCode 2.0.21 / Windows.** A genuine OpenCode session compaction waited on a
|
||||
deliberately held loopback summary response. While it was still waiting,
|
||||
session B accepted a prompt, executed its model request and completed
|
||||
successfully. Native session reads, activity and server info remained available.
|
||||
|
||||
The subsequent large-context qualification below sends verified 2/8/32 MiB
|
||||
tool payloads from **A itself** to its compaction provider. It observes transient
|
||||
pre-provider latency (up to ~85 ms in the outline-disabled matrix), but no
|
||||
persistent cross-session freeze. This supersedes only the original size limit,
|
||||
not its separate outline measurements.
|
||||
|
||||
This result separates provider waiting from service starvation; it does not
|
||||
identify or disprove the reporter's root cause. It does not qualify OpenCode
|
||||
2.0.19, Electron Linux, a remote Windows HTTP backend, or compaction work beyond
|
||||
the bounded synthetic sizes below. Only the explicitly supplied 2.0.21 executable was tested; no other
|
||||
runtime was installed or discovered.
|
||||
|
||||
## Fixture and isolation
|
||||
|
||||
Added `scripts/test-compaction-isolation-native.mjs` (383 lines). It follows the
|
||||
existing isolated native-pruning fixture architecture:
|
||||
|
||||
- Requires an absolute CLI path. Runs `--version` and a child
|
||||
`serve --hostname 127.0.0.1 --port 0`, never `service start/restart` or service
|
||||
discovery. Authenticated server metadata must match the created child's PID
|
||||
and executable version.
|
||||
- Gives that child fresh HOME, USERPROFILE, XDG roots, config directory and
|
||||
explicit database, all beneath the approved temporary directory. Project
|
||||
config discovery and model-catalog fetching are disabled.
|
||||
- Uses only synthetic prompts/data and an HTTP loopback provider. Fixture hooks
|
||||
add request-kind/session headers and one inert tool; they do not override
|
||||
compaction results. A real provider request with kind `compaction` is held and
|
||||
then streams the summary through OpenCode's normal completion path.
|
||||
- Builds the current session-pruning source into a temporary plugin bundle,
|
||||
explicitly configured against the private database. This tests outline work
|
||||
in the real native plugin host, not just an in-memory standalone reader.
|
||||
- Retains private `result.json` and `server.log` evidence. Finally aborts its
|
||||
event subscriber, releases fixture gates, stops only its child, awaits its
|
||||
exit and closes the provider connections/server. The script exits naturally.
|
||||
- No product edits, shared daemon/base access, user-profile mutation, commit,
|
||||
deployment or restart occurred.
|
||||
|
||||
Contract sources consulted before fixture adaptation: V2 client/API/plugin
|
||||
guides and the installed `@opencode/client@2.0.21` generated declarations
|
||||
(`session.compact/wait/get/active`, import/export and event timestamps), plus
|
||||
`@opencode/plugin` session HTTP hook declarations. Configuration was checked
|
||||
against the V2 config guide, not the legacy JSON schema.
|
||||
|
||||
## Procedure and causal assertions
|
||||
|
||||
1. Create and warm A/B with real native assistant/tool messages.
|
||||
2. Outside the measured intervals, import a separate outline corpus of
|
||||
128 assistant tool messages × 256 KiB textual output (~32 MiB).
|
||||
3. Submit A's compaction and observe its real provider request and
|
||||
`session.compaction.started`. Keep the response gated.
|
||||
4. Sample native info, A/B get and activity concurrently four times.
|
||||
5. Submit B's prompt and separately gate its primary provider request. Assert
|
||||
native activity reports **both sessions running**. Release B only; await its
|
||||
successful outcome and assistant conclusion while A's wait remains pending
|
||||
and no A compaction-ended event exists.
|
||||
6. Run the real outline RPC against the separate corpus during A's provider
|
||||
wait; concurrently sample native `/api/info` as an external heartbeat.
|
||||
7. Keep A held for another second, verify B get/info again, then release A.
|
||||
Require a completed compaction message in native context and an ended event,
|
||||
with no compaction-failed event.
|
||||
|
||||
The test has request/condition deadlines, but does not assert a fragile
|
||||
millisecond performance threshold. Its main assertion is causal: B completes
|
||||
**before the only mechanism that releases A is invoked**.
|
||||
|
||||
## Original small-context measurements
|
||||
|
||||
Two complete measured runs with the one-second extended hold:
|
||||
|
||||
| Measurement | Run `qAF00E` | Final run `cb4Y79` |
|
||||
| --- | ---: | ---: |
|
||||
| A compaction inbox admission | 17.1 ms | 8.1 ms |
|
||||
| Total interval A held, from compact submission | 1485.7 ms | 1473.4 ms |
|
||||
| Four info reads during hold | 3.0–7.0 ms | 2.0–4.5 ms |
|
||||
| Four A get reads during hold | 3.3–5.4 ms | 3.2–5.7 ms |
|
||||
| Four B get reads during hold | 2.6–4.6 ms | 3.3–5.7 ms |
|
||||
| Four native activity reads during hold | 2.0–4.3 ms | 1.8–5.6 ms |
|
||||
| B prompt admission while A held | 6.3 ms | 6.7 ms |
|
||||
| B completion wait after releasing B, A still held | 21.0 ms | 9.9 ms |
|
||||
| A completion after releasing A | 11.9 ms | 12.7 ms |
|
||||
| Outline RPC, 128 × 256 KiB | 132.0 ms | 96.6 ms |
|
||||
| Concurrent outline native-info requests | 7 | 7 |
|
||||
| Maximum outline info-request latency | 12.4 ms | 10.5 ms |
|
||||
|
||||
In the final run, six info requests completed before the outline finished; a
|
||||
seventh began near its end and completed afterward. These are native HTTP round
|
||||
trips, not a main-thread deadline or renderer frame-rate measurement. The
|
||||
working-source pruning bundle SHA-256 was
|
||||
`3222a741d8707ac672c4b6e0c199993437372fbccabac769c7bbf3821e1a510a`.
|
||||
|
||||
Both runs observed one compaction started, 29 real compaction deltas, and one
|
||||
compaction ended. A fast observer records `received - event.created` using
|
||||
same-host wall clocks: held-compaction/both-held session-event maximums were
|
||||
23 ms; summary-release maximum was 10 ms. No growing delivery backlog was
|
||||
observed in those phases. This is **delivery-lag evidence**, not an inspection
|
||||
of an internal queue, a losslessness guarantee, or absence of all backlog.
|
||||
|
||||
The seed/import phase had maximum event-delivery lag of 264/193 ms respectively.
|
||||
It is kept separate: bulk import, local JSON encoding/GC and native work can
|
||||
contribute, and this fixture does not attribute that delay to compaction.
|
||||
|
||||
## Commands and retained evidence
|
||||
|
||||
```powershell
|
||||
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
|
||||
$env:TMP=$env:TEMP
|
||||
node scripts/test-compaction-isolation-native.mjs `
|
||||
'C:\Users\Admin\AppData\Roaming\npm\node_modules\@opencode\cli\bin\opencode.exe'
|
||||
node --check scripts/test-compaction-isolation-native.mjs
|
||||
```
|
||||
|
||||
Final run: PASS, OpenCode 2.0.21, child PID 19312, private endpoint
|
||||
`http://127.0.0.1:63236` (stopped afterward).
|
||||
|
||||
Evidence directories beneath
|
||||
`C:\Users\Admin\AppData\Local\Temp\opencode\opencode\`:
|
||||
|
||||
- `compaction-isolation-qAF00E/result.json` and `server.log`
|
||||
- `compaction-isolation-cb4Y79/result.json` and `server.log`
|
||||
|
||||
An earlier successful development run used a shorter hold. The first setup
|
||||
attempt failed before creating a server because `--version` had already made
|
||||
the private config directory; directory creation is now idempotent. Neither is
|
||||
included in the measured table above.
|
||||
|
||||
## Original investigation limits
|
||||
|
||||
- A's outgoing summary request was only 24,557 bytes. The separate 32 MiB tool
|
||||
corpus stresses outline reads, **not** summary-context assembly. This result
|
||||
excludes neither large-context serialization/parsing nor tool/provider-specific
|
||||
synchronous work during actual compaction.
|
||||
- Manual summary compaction under a synthetic compatible provider was exercised,
|
||||
not provider-native checkpoint APIs or automatic context-overflow recovery.
|
||||
- No CodeNomad HTTP proxy, renderer, reconnection or remote TCP path is present.
|
||||
Their independently reproduced defects must not be dismissed by this result.
|
||||
- The current cooperating outline source was tested; this is not a native
|
||||
baseline/patch A/B or a general CPU-throughput comparison.
|
||||
|
||||
No product correction followed from that small-context non-reproduction. The
|
||||
requested bounded large-A qualification is recorded next; runtime/platform
|
||||
qualification and exact CPU attribution remain separate.
|
||||
|
||||
## Follow-up: real 2/8/32 MiB A context
|
||||
|
||||
### Fixture changes and size validation
|
||||
|
||||
`--context-mib 2|8|32` imports native user/assistant-tool pairs into A, using
|
||||
templates generated by real native tool execution. Each tool has a distinct
|
||||
canary and 256 KiB of ASCII text; one small trailing turn is retained separately.
|
||||
The fixture rereads native context and asserts the exact message/tool counts
|
||||
and total stored output bytes before measuring anything.
|
||||
|
||||
In this stress mode only, automatic compaction is disabled, `keep.tokens=0` and
|
||||
the synthetic model advertises a 16,777,216-token context limit. This avoids
|
||||
testing a silently shortened request instead of the requested byte budget. It
|
||||
is deliberately not a typical production model policy. The V2 compaction guide
|
||||
documents the retained tail and model-budget shortening behavior.
|
||||
|
||||
An initial development check exposed exactly that distinction: without a small
|
||||
trailing turn, the first 2 MiB request contained only 7 of 8 payload markers.
|
||||
The fixture failed rather than reporting the stored size as the compacted size.
|
||||
Putting the current turn after the large summarized prefix corrected the corpus;
|
||||
all subsequent runs assert all markers **and every payload-fill byte** at the
|
||||
actual provider boundary. There is no direct SQLite seeding or mutation.
|
||||
|
||||
| A tool payload | Native messages (pairs + tail) | Payload markers received | Provider JSON bytes | Provider messages |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 2 MiB (2,097,152 bytes) | 18 | 8/8 | 2,123,428 | 26 |
|
||||
| 8 MiB (8,388,608 bytes) | 66 | 32/32 | 8,421,286 | 98 |
|
||||
| 32 MiB (33,554,432 bytes) | 258 | 128/128 | 33,612,826 | 386 |
|
||||
|
||||
Provider message counts differ from stored native counts because lowering adds
|
||||
separate assistant tool-call/tool-result messages and the summary instructions.
|
||||
These are compaction request sizes, not the earlier separate outline corpus.
|
||||
`--skip-outline` omits that separate 32 MiB import/RPC so the assembly matrix is
|
||||
not confused with outline load. Allowed A sizes are capped at 32 MiB, sequential
|
||||
runs create only one private native child/provider each, and no size escalation
|
||||
or runtime installation was performed.
|
||||
|
||||
### Phase measurements and causal checks
|
||||
|
||||
Immediately upon compact submission, info and B-get probes start, together with
|
||||
a B prompt/wait. Their start/finish times are retained, with explicit booleans
|
||||
for completion before provider headers/body. The provider records its handler
|
||||
start (headers observable), complete body receipt and JSON parse separately.
|
||||
The output distinguishes submission-to-arrival from **admission response** to
|
||||
arrival; neither is mislabeled completed compaction duration.
|
||||
|
||||
All three witness calls must be issued before A's provider handler begins.
|
||||
Their issuance is a client-call timestamp, not an upstream route-entry trace.
|
||||
B succeeds before releasing A in every run. An additional B prompt is then
|
||||
tested during the provider hold, both sessions must be running simultaneously,
|
||||
and A's completed context/ended event is required after release. No timing
|
||||
threshold was introduced in product or fixture assertions.
|
||||
|
||||
Two sequential runs at each size (no simultaneous fixture launch):
|
||||
|
||||
| Size / run | Compact admission | Submission → headers / full body | Max info / B-get wholly before headers | Early B prompt admission | Early B wait | B done before headers? |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 2 / `IO0hKV` | 6.4 ms | 31.2 / 33.9 ms | 2.5 / 24.6 ms | 26.4 ms | 27.2 ms | no |
|
||||
| 8 / `mrXUsj` | 8.7 ms | 119.7 / 130.4 ms | 10.2 / 55.9 ms | 59.0 ms | 38.7 ms | yes |
|
||||
| 32 / `I1Py1V` | 31.8 ms | 224.8 / 309.5 ms | 47.4 / 57.3 ms | 9.4 ms | 32.1 ms | yes |
|
||||
| 2 / `gomUTk` | 8.5 ms | 56.2 / 66.1 ms | 3.5 / 36.9 ms | 48.7 ms | 57.1 ms | no |
|
||||
| 8 / `ShUeBE` | 24.3 ms | 163.5 / 173.4 ms | 83.7 / 83.2 ms | 28.5 ms | 100.5 ms | yes |
|
||||
| 32 / `jKWuew` | 45.8 ms | 290.9 / 332.7 ms | 83.2 / 82.3 ms | 11.3 ms | 52.7 ms | yes |
|
||||
|
||||
Requests *started* before headers can straddle arrival; their maxima were
|
||||
84.7/84.3 ms for info/B-get in the final 32 MiB run. The table's wholly-before
|
||||
filter excludes that fixture's provider body parsing from those latency values.
|
||||
For the final 2/8/32 runs, admission-response → provider headers was
|
||||
47.6 / 139.0 / 244.9 ms. Local provider JSON parsing was separately
|
||||
2.7 / 7.3 / 29.9 ms; it is not native assembly CPU.
|
||||
|
||||
During the held-response phase of the matrix, native reads were generally a few
|
||||
milliseconds (maximum 18.1 ms); B prompt admission was 6.1–10.0 ms. B completed
|
||||
without releasing A at every size, then A completed 12.4–19.4 ms after release.
|
||||
All runs observed started, 29 compaction deltas and ended, with no failed event.
|
||||
Observed session-event lag in the assembly sampling phase reached 128 ms, versus
|
||||
28 ms maximum during the B/provider-held phase. These are delivery observations,
|
||||
not internal queue-depth or losslessness measurements.
|
||||
|
||||
### Attribution and limits
|
||||
|
||||
Confirmed: the pre-provider preparation/dispatch window grows with this stress
|
||||
corpus, and unrelated native operations sometimes experience transient latency
|
||||
inside that window. It cannot be attributed to the deliberately held summary
|
||||
response: the provider hold has not begun for the wholly-before samples. B also
|
||||
completes before A reaches the provider in the 8/32 MiB runs, ruling out a
|
||||
continuous global stall throughout that preparation window in these executions.
|
||||
|
||||
Not confirmed: one particular synchronous SQLite step, tokenizer, JSON encoder,
|
||||
lock or upstream function as the cause. The distribution differs by operation
|
||||
and run, with fast info responses alongside slower B-get/admission in some
|
||||
samples. Host scheduling, native GC/storage work and fixture scheduling remain
|
||||
possible contributors. There is no native CPU profile or route-entry trace;
|
||||
HTTP latency alone does not prove uninterrupted event-loop blocking. No upstream
|
||||
or CodeNomad production patch is justified by this bounded evidence alone.
|
||||
|
||||
A compatibility smoke run with **2 MiB A plus the separate outline enabled**
|
||||
also passed (`6OnLuq`). It verified all A bytes plus 13 completed native
|
||||
heartbeats during a 218.2 ms outline RPC. That extra run is outside the matrix:
|
||||
its pre-header B-get/prompt were 89.1/93.5 ms and seed-phase event lag reached
|
||||
2.56 s. This additional variability reinforces the host/seed/GC qualification
|
||||
limits; it must not be hidden or mislabeled a persistent compaction freeze.
|
||||
|
||||
The corpus is synthetic repeated ASCII, with imported historical templates,
|
||||
fixture token usage and an artificially permissive model budget. It tests real
|
||||
assembly of bounded bytes, not the reporter's exact token/JSON/provider workload,
|
||||
automatic recovery, provider-native checkpoints or remote Linux/Windows HTTP.
|
||||
|
||||
### Reproduction / next step
|
||||
|
||||
```powershell
|
||||
node scripts/test-compaction-isolation-native.mjs `
|
||||
'C:\Users\Admin\AppData\Roaming\npm\node_modules\@opencode\cli\bin\opencode.exe' `
|
||||
--context-mib 32 --skip-outline
|
||||
```
|
||||
|
||||
Use `2` and `8` for the other bounded sizes; retain the TEMP/TMP setup above.
|
||||
All seven successful runs retain `result.json` and `server.log` under the same
|
||||
private `compaction-isolation-<run>` namespace. Every child was stopped and its
|
||||
exit awaited; the script naturally exited. `node --check` passes. Only this
|
||||
script and this report were edited for the follow-up.
|
||||
|
||||
Smallest useful next step: profile the **native pre-provider preparation window**
|
||||
and correlate native route-entry/exit and event-loop work under a controlled
|
||||
host, at the same fixed byte budgets. Preserve the verified transmitted bytes
|
||||
and the wholly-before-header filter. Do not patch an unproven upstream hotspot
|
||||
or reinterpret provider waiting as freeze; runtime 2.0.19 and the real remote
|
||||
platform remain separate, untested qualifications.
|
||||
85
dev-docs/issue-824-outline-review.md
Normal file
85
dev-docs/issue-824-outline-review.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Issue #824 — independent outline-reader review
|
||||
|
||||
Baseline reviewed: `366830f6`. Scope: the current `outline-index.ts` change,
|
||||
its responsiveness regression test and profiler, and the related performance
|
||||
claims. No product code was changed during this review.
|
||||
|
||||
## Verdict
|
||||
|
||||
Approved with no blocking finding. The patch addresses the demonstrated
|
||||
event-loop starvation while preserving the outline protocol and its existing
|
||||
correctness fences. The documentation correctly limits the result to improved
|
||||
cooperation: it does not claim lower total CPU, a hard 8 ms deadline, or that
|
||||
this defect alone explains issue #824.
|
||||
|
||||
## Correctness review
|
||||
|
||||
- `.all()` was replaced by iteration over the same header statement; the SQL,
|
||||
parameters, ordering and 512-row limit are unchanged. Projection likewise
|
||||
retains its existing SQL and response mapping.
|
||||
- A direct baseline/current comparison produced byte-equivalent JSON results,
|
||||
including checkpoint digests, for an initial mixed outline, a persisted-known
|
||||
delta after delete/update/append, and an ownership/revert-bounded read.
|
||||
- Existing navigation coverage still verifies the 16,384-entry page bound,
|
||||
cursor snapshot (`through`) behavior, deletion holes, tail checkpoint growth,
|
||||
pruning detection, tool metadata, ownership and staged-revert visibility.
|
||||
- The read transaction still begins before ownership and bounds are read and is
|
||||
held across all cooperative yields, so a page observes one snapshot. An abort
|
||||
from either new loop rejects through `yieldTurn`; iterator cleanup is followed
|
||||
by the existing unconditional `ROLLBACK`. Both new tests observe
|
||||
`db.isTransaction === false` afterward.
|
||||
- Each RPC obtains its own read-only `DatabaseSync` connection in
|
||||
`withHistoryDatabase`. Yielding with this transaction open therefore does not
|
||||
expose a shared connection to another RPC. It can retain a read snapshot for
|
||||
longer in wall-clock time, but work remains bounded and promptly cancellable.
|
||||
- `EXPLAIN QUERY PLAN` for the header query, both with and without the revert
|
||||
`id < ?` predicate, uses
|
||||
`session_message_session_seq_idx (session_id=? AND seq>? AND seq<?)`. The
|
||||
patch does not change query plans or introduce a sort scan.
|
||||
|
||||
## Responsiveness evidence
|
||||
|
||||
The regression design is discriminating rather than timing-sensitive. Its real
|
||||
SQLite view charges deterministic synthetic time whenever payload data is
|
||||
accessed, while `setImmediate` supplies an actual pending cancellation.
|
||||
|
||||
- Current code: both header and projection cancellation cases passed.
|
||||
- The same test copied outside the worktree with only `outline-index.ts`
|
||||
replaced by the exact `366830f6` source failed both cases: the header case
|
||||
consumed 896 expensive payload accesses before cancellation ran; the
|
||||
projection case consumed 384 after its 512 free header accesses.
|
||||
- A smaller independent profile (`128` rows × `256 KiB`) observed current
|
||||
maximum turn gaps of `8/8/8 ms`, versus baseline `40/43/38 ms`. Total scan
|
||||
times were comparable (`43–45 ms` current, `39–43 ms` baseline), supporting
|
||||
the documented cooperation claim without implying throughput improvement.
|
||||
- One SQLite iterator step remains synchronous. A single exceptionally costly
|
||||
row can exceed the 8 ms budget before JavaScript regains control; this
|
||||
limitation is explicitly documented in both code and investigation notes.
|
||||
|
||||
## Validation performed
|
||||
|
||||
```text
|
||||
node --import tsx --test \
|
||||
packages/server/src/opencode/session-pruning/outline-responsiveness.test.ts \
|
||||
packages/server/src/opencode/session-pruning/navigation.test.ts
|
||||
=> 11 passed, 0 failed
|
||||
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
=> passed
|
||||
|
||||
git diff --check 366830f6 -- \
|
||||
packages/server/src/opencode/session-pruning/outline-index.ts
|
||||
=> passed (only Git's existing LF/CRLF working-copy warning)
|
||||
```
|
||||
|
||||
The coordinator's full isolated OpenCode 2.0.21 fixture result was not rerun in
|
||||
this read-only review; the focused native-SQL and contract checks above are
|
||||
independent of that reported qualification.
|
||||
|
||||
## Residual risk / next step
|
||||
|
||||
No adjustment is required for this patch. Retain the explicit limitation that
|
||||
cooperative iteration cannot preempt one SQLite step. If real-session evidence
|
||||
later shows individual JSON projection steps exceeding the acceptable latency,
|
||||
the smallest follow-up is architectural (worker-thread/off-loop projection or a
|
||||
less parse-intensive stored index), not a tighter JavaScript timer threshold.
|
||||
160
dev-docs/issue-824-render-recovery.md
Normal file
160
dev-docs/issue-824-render-recovery.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# #824 — récupération du correctif de rendu des tâches imbriquées
|
||||
|
||||
2026-10-01, worktree `issue-824-performance`, HEAD `366830f6`.
|
||||
|
||||
## Résultat et portée
|
||||
|
||||
Le correctif minimal de `task.tsx` est maintenant présent dans le worktree mission,
|
||||
sans commit/push ni déploiement. Deux régressions rouges sont reproduites sur la
|
||||
source baseline ; la correction passe les **63 scénarios** des quatre suites
|
||||
navigateur ciblées, naturellement, sans force-exit, skip ou force-click ajouté.
|
||||
|
||||
La mesure A/B finale, 30 échantillons par variante, observe une médiane de temps
|
||||
main-thread **353,39 -> 22,72 ms** par delta dans le fixture, pas dans l'application
|
||||
desktop. Le contrôle instrumenté confirme **80 retraits + 80 insertions -> zéro**,
|
||||
avec **80 snapshots conservés**. Ce patch n'élimine pas les relectures natives.
|
||||
|
||||
Seuls ces six fichiers appartiennent à cette tâche :
|
||||
|
||||
| Fichier | Delta / taille |
|
||||
| --- | --- |
|
||||
| `packages/ui/src/components/tool-call/renderers/task.tsx` | +29/-18, 647 lignes |
|
||||
| `packages/ui/tests/browser/render-cost.test.ts` | nouveau, 245 lignes |
|
||||
| `packages/ui/tests/browser/fixtures/render-cost.tsx` | nouveau, 150 lignes |
|
||||
| `packages/ui/tests/browser/fixtures/render-cost-timeline.tsx` | nouveau, 44 lignes |
|
||||
| `scripts/test-render-cost-audit.mjs` | nouveau, 57 lignes |
|
||||
| `dev-docs/issue-824-render-recovery.md` | ce rapport |
|
||||
|
||||
Aucun changement de cette tâche dans les stores, `server-events`, `opencode-service`,
|
||||
styles, dépendances ou serveur. Les modifications concurrentes de ces surfaces dans
|
||||
le worktree commun ne sont pas notre livraison. Aucun daemon partagé, profil,
|
||||
desktop, réseau utilisateur ou base utilisateur utilisé ; aucune dépendance
|
||||
réinstallée/symlinkée. Les HTTP fixtures sont loopback et bloquent les autres origines.
|
||||
|
||||
## Provenance et logique
|
||||
|
||||
Source : `audit-resume-integration`, correction du précédent audit de rendu.
|
||||
Le blob baseline de `task.tsx` est identique à `47f9e43e` et `366830f6` :
|
||||
`dee90df8560b83958dbf6d6f7c40e013bbdb289f`.
|
||||
Le postimage produit canonique Git est identique au correctif audit :
|
||||
`9a427900afcad8f274db5d9a77600836f24a728e`.
|
||||
Les SHA bruts diffèrent pour ce seul fichier à cause des fins de ligne du checkout ;
|
||||
comparaison LF-normalisée et `git diff --no-index` : aucun delta de contenu.
|
||||
|
||||
Les deux fixtures et le runner A/B sont repris byte-for-byte de l'audit. Le test
|
||||
était aussi identique avant une adaptation de harness : chaque serveur utilise
|
||||
désormais un cache Vite temporaire unique, supprimé après fermeture. Cela sépare
|
||||
ses modules optimisés des autres fixtures concurrents, sans changer les assertions.
|
||||
|
||||
La correction conserve les clés affichées pendant une relecture autoritaire,
|
||||
réindexe dès son retour, et maintient une shell par identité de tool avec getters
|
||||
réactifs. Un changement de clé remonte bien une shell neuve. Une page autoritaire
|
||||
vide supprime les étapes ; une erreur de lecture conserve le dernier affichage et
|
||||
le retry explicite. Markdown utilise son hash existant plutôt qu'une révision
|
||||
normalisée à zéro ; la révision de part reste suivie pour les updates in-place.
|
||||
|
||||
## Reproductions fonctionnelles exécutées ici
|
||||
|
||||
Node `v25.2.1`, Windows, Chromium Playwright `153.0.8010.12`, Vite development.
|
||||
|
||||
Contrôle rouge par override Vite de la source exacte baseline, sans réécrire le
|
||||
renderer du checkout : deux assertions échouent avec messages attendus, pas timeout :
|
||||
|
||||
1. `authoritative reprojection must not recreate the tool shell` ;
|
||||
2. `the native record changed but the displayed Markdown is stale`.
|
||||
|
||||
Le sample instrumenté baseline passe son contrôle de 80 étapes et compte 80 ajouts,
|
||||
80 retraits et 80 clones à chacun des dix deltas. La commande rouge a donc un exit 1
|
||||
attendu (2 FAIL + 1 PASS) ; le wrapper de collecte garde cet exit dans le log.
|
||||
|
||||
La suite verte finale passe **63/63**, zéro skip/failure, exit 0 naturel (~202 s) :
|
||||
render-cost (8), session-rendering (46), task-copy (6), tool-images (3). Elle exerce
|
||||
les vrais composants Solid, stores et dispatcher SSE, avec seulement le transport
|
||||
natif et les réponses API synthétiques. Le maintien du scroller pendant MMB, du
|
||||
scrollTop et disclosure, les copies exactes, l'update in-place, l'erreur/retry,
|
||||
la suppression et le changement de clé sont réellement vérifiés.
|
||||
|
||||
`npm run typecheck --workspace @codenomad/ui` passe, y compris au dernier replay.
|
||||
`git diff --check` des fichiers de livraison passe.
|
||||
Le test est admis par le glob existant `tests/browser/*.test.ts` du script package ;
|
||||
aucune modification de gate/manifest n'est nécessaire.
|
||||
|
||||
## A/B CPU actuel — séparé des résultats historiques
|
||||
|
||||
Protocole inchangé : 80 étapes read terminées × 8 000 caractères, collapsed ;
|
||||
3 warmup deltas puis 10 deltas isolés dans chacun des trois couples baseline/fixed.
|
||||
Processus/pages frais, alternance baseline puis fixed ; compteur clone et observer
|
||||
DOM **désactivés** pour les timings. CDP cumule les durées jusqu'après hydration et
|
||||
deux animation frames. Les coûts sync seuls n'incluent pas le travail différé.
|
||||
|
||||
| ms/delta, médiane [min–max], écart-type échantillon | Baseline | Correction |
|
||||
| --- | --- | --- |
|
||||
| main-thread tasks | 353,39 [189,76–737,96], 141,77 | 22,72 [11,00–68,03], 12,39 |
|
||||
| script | 18,65 [14,75–57,85], 11,10 | 0,92 [0,29–40,30], 10,04 |
|
||||
| layout | 3,58 [2,93–5,39], 0,63 | 0,14 [0,09–0,36], 0,05 |
|
||||
| recalcul style | 11,69 [10,39–14,35], 0,94 | 0,20 [0,11–0,32], 0,06 |
|
||||
| dispatcher sync | 17,25 [11,20–33,60], 4,88 | 0,25 [0,10–0,50], 0,11 |
|
||||
|
||||
L'audit antérieur rapportait 369,98 -> 20,64 ms sur 30 échantillons par variante.
|
||||
Ce ne sont pas les échantillons actuels et ils ne sont pas fusionnés avec eux.
|
||||
Le premier A/B actuel était 353,90 -> 17,03 ms ; le replay suivant 378,59 -> 15,61 ms.
|
||||
Ces variations ne sont pas une garantie de latence/FPS et aucun seuil CI absolu
|
||||
n'est ajouté.
|
||||
|
||||
## Tentatives et limites conservées
|
||||
|
||||
Deux reprises A/B supplémentaires ont échoué avant le montage du fixture fixed,
|
||||
sur `page.waitForFunction(window.fixture)` après 30 s. Elles restent dans
|
||||
`codenomad-render-cost-lehbGS/3-fixed.log` et `codenomad-render-cost-FN0wKE/1-fixed.log`
|
||||
sous le temp approuvé. Pas de gain CPU calculé depuis ces lots incomplets.
|
||||
Leurs erreurs sont de démarrage du harness, pas une assertion renderer. La
|
||||
collision du cache Vite partagé est une **hypothèse**, pas une cause démontrée :
|
||||
après isolation du cache, les six invocations A/B et 63 tests passent, mais il n'y
|
||||
a pas eu de contrôle négatif causal du cache en concurrence.
|
||||
|
||||
Des snapshots SHA avant/après des sources ont aussi détecté des mutations de tâches
|
||||
concurrentes (stores compaction, event handlers, serveur et ressources générées).
|
||||
Les cinq fichiers exécutables de notre livraison sont restés inchangés pendant
|
||||
le dernier A/B + suite verte. Ces runs **ne sont pas une qualification d'une
|
||||
composition intégrée entièrement immuable** : refaire le replay indépendant sur
|
||||
une composition figée après intégration des autres correctifs. Les wrappers
|
||||
marquent cette dérive par un exit 1, distinct des enfants A/B et tests, tous exit 0.
|
||||
|
||||
Pas de qualification production-build, Electron/WebView2, Linux/macOS, heap,
|
||||
GPU/FPS, backend réel, compactage natif ou freeze applicatif universel. Le coût des
|
||||
clones/relectures demeure. `task.tsx` reste **647 lignes**, au-dessus du seuil de
|
||||
signalement ~500 ; aucun refactor de taille seul n'a été introduit.
|
||||
|
||||
## Artefacts et commandes
|
||||
|
||||
Tous les chemins ci-dessous sont sous
|
||||
`C:/Users/Admin/AppData/Local/Temp/opencode/` :
|
||||
|
||||
- `issue-824-render-isolated-xn3MnK/red-final.log`
|
||||
SHA256 `947bb2f6beae52db032288e8f855af27fcaa7deb90a3787a85fdf49f82a06c18`.
|
||||
- `issue-824-render-isolated-xn3MnK/green.log`
|
||||
SHA256 `15b7d31be5b99cbc8e3bf62f322fa5b14a1f710e740a5cd0b2a8e0d4c718a7bd`.
|
||||
- `issue-824-render-isolated-xn3MnK/{before,after,result}.json` : dérive de composition.
|
||||
- `codenomad-render-cost-dD0phn/metrics.json` et six logs adjacents : A/B final.
|
||||
SHA256 metrics `f1a2a4a7ce5ca6f042328bc5d8e84fc315be49c90a5f26a67759859d3e801ba8`.
|
||||
|
||||
SHA256 produit brut livré : `task.tsx` =
|
||||
`d130c5fb2219772c5de0c63393744783cdb659734024f88cbe73cafeec818100`.
|
||||
SHA256 test final = `4abe4345a3a5f1a00d0e94544fd02b70851d07efa6b294a55466bc51ef7d2fde`.
|
||||
|
||||
```powershell
|
||||
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
|
||||
$env:TMP=$env:TEMP
|
||||
# Dans packages/ui, override baseline pour les assertions rouges uniquement :
|
||||
$env:CODENOMAD_RENDER_TASK_BASELINE='<temp>/task-baseline.tsx'
|
||||
node --import tsx --test --test-concurrency=1 --test-name-pattern='^(unrelated child text|native page output|audit sample:)' tests/browser/render-cost.test.ts
|
||||
Remove-Item Env:CODENOMAD_RENDER_TASK_BASELINE
|
||||
node --import tsx --test --test-concurrency=1 tests/browser/render-cost.test.ts tests/browser/session-rendering.test.ts tests/browser/task-copy.test.ts tests/browser/tool-images.test.ts
|
||||
# À la racine :
|
||||
node scripts/test-render-cost-audit.mjs 3
|
||||
npm run typecheck --workspace @codenomad/ui
|
||||
```
|
||||
|
||||
Prochaine étape minimale : revue indépendante des six fichiers de cette livraison,
|
||||
puis replay des deux assertions rouges, 63 contrôles verts et A/B sur une composition
|
||||
figée. Aucun restart/déploiement ni élargissement du patch n'est nécessaire.
|
||||
137
dev-docs/issue-824-server-review.md
Normal file
137
dev-docs/issue-824-server-review.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Issue #824 — revue indépendante auth et admission HTTP
|
||||
|
||||
## Verdict
|
||||
|
||||
**Gates auth et admission HTTP : acceptés, sans finding bloquant.**
|
||||
|
||||
La revue porte sur le diff intégré non commité de
|
||||
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` depuis `366830f6`,
|
||||
et non sur les worktrees sources. Les trois fichiers produit transport intégrés
|
||||
ont exactement les mêmes blobs Git que `d9598e42`. Les changements UI, relais
|
||||
d'événements, outline et rendu présents simultanément dans le worktree sont hors
|
||||
périmètre.
|
||||
|
||||
Ces corrections ont une causalité plus étroite que l'intitulé général #824 :
|
||||
|
||||
- l'auth retire les dérivations scrypt HTTP du thread principal Node ;
|
||||
- l'admission HTTP retire un observateur local abandonné pendant ses préflights
|
||||
et empêche ses continuations tardives ;
|
||||
- elles ne démontrent ni la cause du blocage natif pendant une vraie compaction,
|
||||
ni une saturation HTTP/1 Electron Linux → backend Windows, ni l'élimination du
|
||||
freeze global rapporté.
|
||||
|
||||
Les deux rapports d'intégration conservent correctement cette limite et ne
|
||||
promettent pas une correction universelle.
|
||||
|
||||
## Findings prioritaires
|
||||
|
||||
### Aucun finding P0/P1/P2
|
||||
|
||||
Je n'ai trouvé ni autorisation accordée sur un ancien mot de passe, ni replay de
|
||||
mutation, ni invalidation de connexion partagée sur abandon local, ni forwarding
|
||||
après annulation de préflight dans le périmètre revu.
|
||||
|
||||
### Risques résiduels non bloquants
|
||||
|
||||
1. **Saturation du pool libuv non qualifiée.**
|
||||
`packages/server/src/auth/password-hash.ts:58-64` rend le thread HTTP
|
||||
coopératif via `crypto.scrypt`, mais ne supprime pas le coût CPU/mémoire et ne
|
||||
borne pas une rafale de logins. Il n'existait pas de rate limiter sur ces
|
||||
routes dans la baseline et le patch n'en retire aucun. Une politique
|
||||
d'admission serait un changement séparé, non requis pour accepter ce patch.
|
||||
2. **Persistance auth toujours synchrone.**
|
||||
`packages/server/src/auth/auth-store.ts:176-184` conserve les petites
|
||||
écritures synchrones existantes. Les mesures et le correctif concernent la
|
||||
dérivation scrypt, pas une garantie d'absence totale de pause filesystem.
|
||||
3. **Qualification réseau/OS encore absente.**
|
||||
Les tests transport utilisent de vrais sockets loopback et le client SDK,
|
||||
mais pas Electron Linux vers Windows, suspension/relogin, proxy intermédiaire
|
||||
ou limite de connexions Chromium. Le correctif prouve la fuite d'observateur
|
||||
et ses fences, pas le scénario utilisateur complet.
|
||||
|
||||
## Revue auth
|
||||
|
||||
- `packages/server/src/auth/password-hash.ts:23-55` conserve le record v1, le
|
||||
sel 16 octets, la clé 64 octets, les paramètres enregistrés et
|
||||
`crypto.timingSafeEqual`. Le seul chemin synchrone restant est explicitement
|
||||
`hashPasswordSync`, appelé pendant l'initialisation CLI/env avant service.
|
||||
- `packages/server/src/server/routes/auth.ts:101,150` attend les résultats login
|
||||
et password ; la recherche dépôt ne trouve pas d'autre caller produit oublié
|
||||
de `validateLogin`, `validateCredentials`, `setPassword` ou `hashPassword`.
|
||||
- `packages/server/src/auth/auth-store.ts:117-160` sérialise les changements par
|
||||
ordre d'admission. La queue absorbe un rejet uniquement pour permettre le
|
||||
changement suivant, tandis que le caller original reçoit bien l'erreur. Une
|
||||
persistence bootstrap échouée ne supprime pas `bootstrapUsername` et ne publie
|
||||
pas de nouveau cache.
|
||||
- `packages/server/src/auth/auth-store.ts:101-115` relit l'identité du record
|
||||
cached après l'await scrypt. Un login dérivé sur l'ancien record est refusé si
|
||||
une mutation a persisté entre-temps, sans recalcul ni replay.
|
||||
- Les réponses 401/409, le cookie, le statut bootstrap, l'override CLI/env et
|
||||
l'auth désactivée sont couverts. Aucun mécanisme de rate-limit existant n'est
|
||||
modifié.
|
||||
|
||||
## Revue admission HTTP
|
||||
|
||||
- `packages/server/src/server/request-admission.ts:8-14` observe dès l'entrée
|
||||
l'upload `aborted` et le `close` d'une réponse incomplète. Il n'interprète pas
|
||||
le `close` normal de l'IncomingMessage comme une déconnexion.
|
||||
- `packages/server/src/server/request-admission.ts:16-36` fait courir uniquement
|
||||
l'attente locale contre l'abandon. La Promise partagée reste observée ; sa
|
||||
génération n'est ni annulée ni invalidée et un rejet tardif ne devient pas une
|
||||
unhandled rejection.
|
||||
- `packages/server/src/server/http-server.ts:699-975` transmet le signal aux
|
||||
lectures natives qui le supportent (`profile`, session/project, PTY/Shell,
|
||||
ownership/location) et entoure les acquisitions/inventaires non annulables par
|
||||
`wait()`. Chaque frontière résolue revérifie l'abandon avant de poursuivre.
|
||||
- Les guards précédant la fence de mutation, l'environnement et le forwarding
|
||||
sont présents à `http-server.ts:975`, `989-1015` et `1022-1024`. Le `finally`
|
||||
de l'entrée dispose les listeners sur toutes les sorties.
|
||||
- `packages/server/src/opencode/compatibility/proxy.ts:27-40` installe le listener
|
||||
de forwarding avant le fetch puis refuse immédiatement un downstream déjà
|
||||
fermé. Après admission native, la sémantique antérieure demeure : abandon de
|
||||
la réponse HTTP sans replay ni invalidation de la connexion partagée.
|
||||
- `packages/server/src/workspaces/manager.ts:270-286` propage le signal à la
|
||||
validation native de location. Une annulation éventuellement convertie en
|
||||
`false` par son `catch` est réémise par le `wait()` appelant, dont le signal est
|
||||
alors aborté ; elle ne devient donc pas une autorisation ou un 403 tardif.
|
||||
|
||||
## Validation fraîche
|
||||
|
||||
Exécutée depuis le worktree intégré, Windows, Node 25.2.1, sans daemon/profil/base
|
||||
utilisateur, restart ou déploiement :
|
||||
|
||||
```powershell
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/auth/password-hash.test.ts packages/server/src/server/routes/auth-responsiveness.test.ts packages/server/src/server/routes/auth.test.ts
|
||||
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
|
||||
npm run typecheck --workspace @neuralnomads/codenomad
|
||||
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
|
||||
$env:TMP=$env:TEMP
|
||||
node --unhandled-rejections=strict --import tsx --test "packages/server/src/**/*.test.ts"
|
||||
```
|
||||
|
||||
Résultats :
|
||||
|
||||
- auth ciblé : **9/9 pass**, sortie naturelle ;
|
||||
- transport ciblé : **65/65 pass**, sortie naturelle ;
|
||||
- typecheck serveur : **pass** ;
|
||||
- gate serveur complet intégré : **799 tests, 793 pass, 0 fail, 6 skips
|
||||
déclarés**, sortie naturelle en environ 35,7 s ;
|
||||
- `git diff --check` ciblé : aucune erreur whitespace (les commandes
|
||||
`--no-index` retournent normalement 1 parce que les nouveaux fichiers
|
||||
diffèrent de `NUL`; seulement les avertissements LF/CRLF habituels).
|
||||
|
||||
Le gate complet contient aussi les autres changements concurrents déjà intégrés ;
|
||||
il confirme leur composition actuelle avec ces patches, sans attribuer leurs
|
||||
tests aux deux corrections revues.
|
||||
|
||||
## Taille et suite minimale
|
||||
|
||||
Les fichiers touchés déjà surdimensionnés restent
|
||||
`packages/server/src/server/http-server.ts` (~2346 lignes) et
|
||||
`packages/server/src/workspaces/manager.ts` (~1240 lignes). Aucun refactor de
|
||||
taille n'est justifié dans cette correction bornée.
|
||||
|
||||
Suite minimale recommandée : conserver les patches tels quels et poursuivre
|
||||
séparément la qualification avec runtime privé d'une vraie compaction et le
|
||||
scénario Electron Linux → Windows. Ne pas présenter ces deux corrections comme
|
||||
preuve que la cause native ou le freeze global #824 est résolu.
|
||||
119
dev-docs/issue-824-task-membership-fix.md
Normal file
119
dev-docs/issue-824-task-membership-fix.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# #824 — correction M1 de membership des étapes imbriquées
|
||||
|
||||
## Résultat
|
||||
|
||||
Le finding M1 de `issue-824-ui-review.md` est corrigé dans le worktree mission.
|
||||
Les cinq nouvelles régressions échouent sur le renderer livré avant cette tâche,
|
||||
puis **68/68 tests navigateur** passent sur le renderer corrigé, avec rejets stricts,
|
||||
sortie naturelle, aucun skip/force-exit/force-click. Les shells conservés gardent
|
||||
leur disclosure, leur scroller et leur scrollTop après suppression ou déplacement.
|
||||
Le contrôle instrumenté de 80 étapes conserve **zéro retrait/insertion par delta**.
|
||||
|
||||
Cette tâche modifie uniquement :
|
||||
|
||||
- `packages/ui/src/components/tool-call/renderers/task.tsx` ;
|
||||
- `packages/ui/tests/browser/render-cost.test.ts` ;
|
||||
- `packages/ui/tests/browser/fixtures/render-cost.tsx` ;
|
||||
- ce rapport `dev-docs/issue-824-task-membership-fix.md`.
|
||||
|
||||
Aucun store/SDK/serveur/style, daemon, base utilisateur, profil, déploiement ou
|
||||
restart modifié. Aucun commit/push, sous-agent ni installation de dépendances.
|
||||
|
||||
## Cause et correction
|
||||
|
||||
L'index incrémental inférait les changements de membership depuis les nombres de
|
||||
messages/parts et l'identité du message de queue. Une relecture autoritaire peut
|
||||
conserver toutes ces propriétés et supprimer, remplacer, réordonner ou changer le
|
||||
type d'une part. Les shells supprimés se démontaient, mais `childToolKeys` et le
|
||||
compteur/troncature gardaient des clés fantômes. Un ajout retrouvant l'ancienne
|
||||
cardinalité restait invisible.
|
||||
|
||||
Le renderer réutilise maintenant son **scanner structurel borné existant** au point
|
||||
de révision de session. Il ne compare ni copie les payloads/output : seuls les IDs
|
||||
et le type de part déterminent les clés. On supprime les heuristiques de counts/tail
|
||||
plutôt que d'ajouter un autre cache ou une politique de refresh. La liste précédente
|
||||
est rendue telle quelle quand ses clés ordonnées sont identiques.
|
||||
|
||||
Le scan reste limité à **10 000 unités message/part** et s'arrête une fois trouvées
|
||||
201 étapes ; seules les **200 dernières** s'affichent. Les constantes et le budget
|
||||
du scanner restent inchangés. Le coût des IDs/type est payé à chaque révision,
|
||||
même si le texte seul change : c'est le compromis explicite pour reconnaître tout
|
||||
remplacement autoritaire, sans observer/comparer les gros payloads. Aucun gain CPU
|
||||
nouveau ni coût maximal de 10 000 IDs en production n'est revendiqué ici.
|
||||
|
||||
`For` remplace `Index` sur les clés tool : une part retenue suit son identité quand
|
||||
une suppression ou un réordonnancement change sa position. Une identité nouvelle
|
||||
reçoit une shell/disclosure neuve. Les getters réactifs et le hash Markdown de la
|
||||
correction précédente restent inchangés. Une lecture pendante conserve la liste
|
||||
résidente ; le retour autoritaire, une page vide et le changement de child-session
|
||||
continuent à la réconcilier. Le recalcul remet aussi la troncature à jour après shrink.
|
||||
|
||||
Delta produit relatif au renderer **déjà intégré avant cette tâche**, préimage
|
||||
conservée dans le temp : **+12/-90 lignes**. Ce n'est pas un delta contre HEAD, qui
|
||||
ne contient pas encore les correctifs mission. `task.tsx` reste **569 lignes**, au-
|
||||
dessus du seuil d'avertissement ~500 ; aucun refactor de taille indépendant.
|
||||
|
||||
## Preuves exécutées
|
||||
|
||||
Node `v25.2.1`, Windows, Chromium Playwright `153.0.8010.12` ; vrais composants
|
||||
Solid, stores et `loadMessages(..., { force: true })`, native/API réponses privées,
|
||||
réseau hors loopback bloqué. Le message natif `child-message` reste le même ; le
|
||||
fixture remplace sa liste de parts, pas le composant ni l'index.
|
||||
|
||||
Les cinq nouvelles assertions rouges causales, sans seuil timing :
|
||||
|
||||
1. `2 -> 1 -> 2` en gardant step-0 : counter reste `2 steps` au lieu de `1 steps` ;
|
||||
2. même scénario en gardant step-1 (suppression en tête) : même counter faux ;
|
||||
3. remplacement/réordre `[step-0, step-1] -> [step-2, step-0]` à cardinalité égale :
|
||||
nouvelle step-2 absente ;
|
||||
4. changement tool -> text avec ID inchangé : counter reste à 2 au lieu de 1 ;
|
||||
5. shrink de 201 à 199 parts : counter reste `200+ steps` au lieu de `199 steps`.
|
||||
|
||||
Sur correction, tous passent. Les deux premiers vérifient également l'arrivée
|
||||
exactement une fois de la nouvelle step-2, puis l'identité shell/scroller, disclosure
|
||||
ouverte et scrollTop 240 de l'étape retenue. Le troisième vérifie ordre DOM exact et
|
||||
shell retained. Le cinquième vérifie 200 shells initiales, 199 ensuite et disparition
|
||||
du diagnostic de troncature.
|
||||
|
||||
Gate final : 13 render-cost + 46 session-rendering + 6 task-copy + 3 tool-images =
|
||||
**68 scénarios distincts**, ~176 s, exit 0 naturel, aucun failure/skip. Le contrôle
|
||||
perf réel compte, pour chacun de dix deltas, **80 clones, 0 added, 0 removed** ; une
|
||||
assertion explicite interdit maintenant les remounts dans ce sample instrumenté.
|
||||
Ces compteurs sont distincts des A/B CPU antérieurs, qui restent dans leurs rapports
|
||||
et ne sont pas recyclés comme mesures de ce patch.
|
||||
|
||||
`npm run typecheck --workspace @codenomad/ui` passe sur dernier patch.
|
||||
`git diff --check` ciblé passe (avertissement LF/CRLF seulement).
|
||||
Les SHA des trois exécutables de livraison sont identiques avant/après la séquence
|
||||
rouge/verte. Cela qualifie la stabilité de ce lot, pas une composition entière
|
||||
immutable de tous les travaux de mission.
|
||||
|
||||
## Artefacts et replay
|
||||
|
||||
Sous `C:/Users/Admin/AppData/Local/Temp/opencode/` :
|
||||
`issue-824-membership-c5bca1d90b4a4564bd4c7f695b6381bd/` contient :
|
||||
|
||||
- `task-before.tsx` : préimage exacte avant correction M1 ;
|
||||
- `red.log` : 5 échecs d'assertion attendus, exit 1, naturel ;
|
||||
SHA256 `db9316eb888984364cbdd59d2fac8fac74600ed9e5e779f9c86b33869b1fc51a` ;
|
||||
- `green.log` : 68 passes, exit 0, naturel ;
|
||||
SHA256 `e4f5a996b9b576703888b353e14c02940b4dd72d0fe3e056cc54b96fcffe1849` ;
|
||||
- `result.json` : statuts enfants + empreintes avant/après, `changed: []`.
|
||||
|
||||
SHA256 produit livré :
|
||||
`f07ce171a6b1d3394ce94be496b0abd67f8bc0bb48e946eb9173a681c844f401`.
|
||||
SHA256 test : `93d9e95988b53eb1e3934ecd13f4e8d32e0eb797682a866db113797174be5075`.
|
||||
SHA256 fixture : `0df543ea983c23e33a4957ed6732dc81a2e04cf5ede7d4f1281d6ee058360ebb`.
|
||||
|
||||
Depuis `packages/ui`, avec TEMP/TMP pointant vers le temp approuvé :
|
||||
|
||||
```powershell
|
||||
$env:CODENOMAD_RENDER_TASK_BASELINE='<artefact>/task-before.tsx'
|
||||
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 --test-name-pattern='^authoritative task membership' tests/browser/render-cost.test.ts
|
||||
Remove-Item Env:CODENOMAD_RENDER_TASK_BASELINE
|
||||
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 tests/browser/render-cost.test.ts tests/browser/session-rendering.test.ts tests/browser/task-copy.test.ts tests/browser/tool-images.test.ts
|
||||
```
|
||||
|
||||
Prochaine étape : re-review indépendante du finding M1, puis validation combinée
|
||||
sur la composition figée. Pas de qualification desktop/Linux/macOS, daemon réel ou
|
||||
disparition universelle des freezes revendiquée par ce lot.
|
||||
218
dev-docs/issue-824-ui-review.md
Normal file
218
dev-docs/issue-824-ui-review.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Issue 824 — revue indépendante de l'intégration UI
|
||||
|
||||
## Verdict
|
||||
|
||||
**Corrections demandées avant validation finale**, pour un défaut de membership
|
||||
du renderer `task` confirmé ci-dessous. Le coalescing du compactage et la
|
||||
séparation des diagnostics SSE sont acceptables dans leur périmètre; aucun défaut
|
||||
de perte de texte, d'ordre ou de fence n'a été reproduit sur ces surfaces.
|
||||
|
||||
Revue bornée au diff depuis `366830f6` de `opencode-data.ts`, `instances.ts`,
|
||||
`compaction-delta-buffer.ts`, `task.tsx`, `server-events.ts`,
|
||||
`event-source-handlers.ts` et de leurs fixtures. Aucun fichier produit n'a été
|
||||
modifié par cette revue.
|
||||
|
||||
## Findings
|
||||
|
||||
### M1 — une suppression autoritaire d'une part tool laisse une clé fantôme dans le renderer `task`
|
||||
|
||||
Dans `task.tsx`, la mise à jour incrémentale ne traite que
|
||||
`nextPartCount > previousPartCount`. Si une page autoritaire conserve le message
|
||||
mais retire une part tool, `nextPartCount < previousPartCount` ne déclenche ni
|
||||
rescan ni retrait de `childToolKeys`.
|
||||
|
||||
La ligne supprimée disparaît malgré tout parce que `TaskToolCallRow` ne trouve
|
||||
plus sa part et son `Show` se démonte. En revanche, la clé reste dans
|
||||
`childToolKeys` : le compteur et la troncature sont faux, et un ajout ultérieur
|
||||
qui ramène le nombre de parts à sa valeur précédente peut ne pas être indexé.
|
||||
|
||||
Reproduction indépendante avec le vrai composant, le vrai store et
|
||||
`loadMessages(..., { force: true })`, via une transformation Vite en mémoire qui
|
||||
retire seulement `step-0` de la réponse native :
|
||||
|
||||
```json
|
||||
{"count":"2 steps","visible":1,"partIds":["step-1"]}
|
||||
```
|
||||
|
||||
La reproduction directe déjà exposée par le fixture donne le même état. Le test
|
||||
vert actuel `removeTool(0)` affirme uniquement que le DOM de `step-0` est détaché;
|
||||
il n'affirme ni `1 step`, ni la membership, ni l'ajout suivant. Le test de page
|
||||
vide couvre une baisse du nombre de messages et prend une autre branche de full
|
||||
rescan.
|
||||
|
||||
Correction minimale recommandée : full rescan borné dès qu'un nombre de parts
|
||||
d'un message indexé diminue (et, si les pages peuvent remplacer des identités à
|
||||
cardinalité égale, comparer aussi l'identité bornée des parts). Ajouter un cas
|
||||
page autoritaire 2→1 dans le même message, puis ajout d'une nouvelle part 1→2;
|
||||
le shell de la part non supprimée doit rester monté, le compteur doit faire
|
||||
2→1→2 et la nouvelle clé doit apparaître exactement une fois.
|
||||
|
||||
### R1 — l'accumulateur de compactage est borné dans le temps, pas en taille
|
||||
|
||||
Le timer part du premier fragment et n'est jamais repoussé : avec de vrais
|
||||
événements EventSource échelonnés, la résidence est donc bornée à environ 250 ms.
|
||||
En revanche, tant que la boucle ne rend pas la main, le nombre de fragments et la
|
||||
taille de la chaîne concaténée n'ont pas de plafond explicite. Le gros backlog
|
||||
synchrone du diagnostic peut toujours construire un unique payload volumineux.
|
||||
|
||||
Ce n'est pas bloquant pour le correctif revendiqué : le transport produit livre
|
||||
ici un événement par callback, les nouveaux tests de cadence rendent réellement
|
||||
la main, et le résumé final doit de toute façon exister en mémoire. Ne pas appeler
|
||||
le buffer « borné en mémoire ». Si une mesure réelle montre une rafale sans
|
||||
yield, ajouter une limite qui force une réduction intermédiaire sans supprimer ni
|
||||
réordonner de texte; ne pas introduire un drop silencieux.
|
||||
|
||||
### T1 — le test store compactage ne fournit pas une sortie naturelle
|
||||
|
||||
Les 11 assertions passent avec rejets stricts, mais le processus reste vivant
|
||||
au-delà de 120 s sans `--test-force-exit`. Avec l'option de force, 11/11 passent
|
||||
en 0,77 s. Cela correspond à la convention déjà déclarée pour les stores importés
|
||||
et ne démontre pas une fuite du nouveau buffer, mais ce fichier seul ne constitue
|
||||
pas un gate de lifecycle naturel. Les 10 tests navigateur intégrés ci-dessous
|
||||
sortent naturellement.
|
||||
|
||||
## Contrats compactage vérifiés
|
||||
|
||||
- Chaque delta avance immédiatement `messageRevisions`, `fullDataRevisions` et
|
||||
la fence de lecture du reducer observé; l'agrégat réentrant n'avance pas ces
|
||||
révisions une seconde fois.
|
||||
- Un buffer est séparé par couple instance/session. Un événement non-delta de la
|
||||
même session force l'ordre; les autres sessions et instances ne forcent pas le
|
||||
flush.
|
||||
- `ended` et `failed` annulent les fragments car le SDK 2.0.21 remplace le résumé
|
||||
par `ended.data.text` ou remplace la ligne par l'erreur. Delete, reconnect,
|
||||
prune, revert committed et dispose annulent aussi avant qu'un timer puisse
|
||||
ressusciter le contenu.
|
||||
- Une lecture SDK déclenchée avant admission est refusée par révision. Une lecture
|
||||
après admission force d'abord l'agrégat; la page native qui contient déjà ce
|
||||
texte n'est pas réappendue.
|
||||
- Rotation à 200 messages, idle, nouvelle exécution et second start conservent
|
||||
l'ordre. La queue de rotation et la resynchronisation autoritaire restent les
|
||||
fences existantes.
|
||||
- Une session inactive jamais chargée ne crée pas de transcript ni de payload de
|
||||
reducer; seules les fences scalaires et le marqueur de compaction inachevée
|
||||
persistent. End/failed/dispose les retirent. Une activation intermédiaire passe
|
||||
par la page autoritaire, conserve la fenêtre UI de 200 messages et reprend
|
||||
ensuite le streaming sans duplication.
|
||||
- Une session chargée puis inactive réduit l'agrégat mais n'hydrate pas sa vue
|
||||
cachée; son retour recharge le texte exact.
|
||||
|
||||
Les cinq scénarios navigateur utilisent le vrai `SessionView`, le dispatcher
|
||||
natif et les stores. Le cas cadencé livre huit événements dans huit tâches
|
||||
séparées par 10 ms; ce n'est pas uniquement le backlog synchrone historique.
|
||||
|
||||
## Renderer imbriqué : propriétés validées hors M1
|
||||
|
||||
- Les getters réactifs mettent à jour output, copy et erreurs sans remonter le
|
||||
shell d'une clé stable; disclosure, scroller et scrollTop restent conservés.
|
||||
- Le `Show keyed` externe remonte le shell quand `Index` réutilise une position
|
||||
pour une autre clé, donc l'état d'une étape évincée n'est pas transféré.
|
||||
- Une invalidation de lecture conserve le snapshot résident; le full rescan après
|
||||
retour autoritaire et la page vide suppriment les messages obsolètes.
|
||||
- La limite de scan reste 10 000 unités et l'affichage 200 étapes. Le patch ne
|
||||
rend pas le scan ou le nombre de shells non borné.
|
||||
- L'output d'une page native à révision de part zéro utilise de nouveau son hash
|
||||
Markdown et n'affiche pas le cache de l'ancienne page.
|
||||
|
||||
## Diagnostic SSE / flush Solid
|
||||
|
||||
La correction ne prétend plus capturer une dérivation Solid dans le `try/catch`
|
||||
de chaque abonné. Elle sépare correctement les frontières : JSON invalide produit
|
||||
`Failed to parse event`; une exception qui sort du flush de `solidBatch` produit
|
||||
`Failed to dispatch event`. Elle ne déclenche ni reconnexion ni rejeu, et les
|
||||
événements suivants continuent en FIFO. Le test navigateur dérivé traverse le
|
||||
vrai EventSource et confirme les deux événements chez les abonnés natif et typé.
|
||||
|
||||
Les risques déjà acceptés restent inchangés : ce logger `debug` est désactivé par
|
||||
défaut, et la course serveur abort local / erreur amont n'est pas couverte ici.
|
||||
Aucune nouvelle promesse de diagnostic persistant ou d'isolation du flush n'est
|
||||
faite.
|
||||
|
||||
## Gates exécutés
|
||||
|
||||
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts packages/ui/tests/browser/event-subscriber-isolation.test.ts` — **10/10**, sortie naturelle.
|
||||
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 --test-name-pattern="^(unrelated child text|a failed child refresh|native page output|changing an indexed task key)" packages/ui/tests/browser/render-cost.test.ts` — **4/4**, sortie naturelle.
|
||||
- `node --conditions=browser --unhandled-rejections=strict --import tsx --test packages/ui/src/lib/event-source-handlers.test.ts` — **4/4**, sortie naturelle.
|
||||
- `node --conditions=browser --unhandled-rejections=strict --import tsx --test packages/ui/src/stores/opencode-compaction.test.ts` — **11 assertions vertes**, mais timeout de lifecycle à 120 s.
|
||||
- Même test avec `--test-force-exit` — **11/11**, 0 échec.
|
||||
- `npm run typecheck --workspace @codenomad/ui` — vert.
|
||||
- `git diff --check` ciblé — vert, hors avertissements LF/CRLF du worktree.
|
||||
|
||||
Les deux reproductions ad hoc du finding M1 ont utilisé un serveur Vite et un
|
||||
Chromium privés loopback, sans fichier temporaire dans le dépôt, daemon, profil,
|
||||
base utilisateur, restart ou déploiement.
|
||||
|
||||
## Taille des fichiers
|
||||
|
||||
- `packages/ui/src/stores/opencode-data.ts` : ~828 lignes, au-dessus de la cible
|
||||
de 800.
|
||||
- `packages/ui/src/stores/instances.ts` : ~2 093 lignes, très au-dessus de la
|
||||
cible.
|
||||
- `packages/ui/src/components/tool-call/renderers/task.tsx` : ~647 lignes,
|
||||
au-dessus du seuil d'avertissement.
|
||||
- `packages/ui/src/stores/compaction-delta-buffer.ts` : 56 lignes.
|
||||
|
||||
Aucun refactor de taille seul n'est recommandé dans cette mission.
|
||||
|
||||
## Addendum — disposition du finding M1
|
||||
|
||||
### Verdict de suivi
|
||||
|
||||
**M1 est clôturé.** La correction de membership intégrée après la revue initiale
|
||||
traite les suppressions, remplacements et réordonnancements autoritaires sans
|
||||
réintroduire les remounts qui motivaient le correctif de performance. Le verdict
|
||||
« corrections demandées » ci-dessus est donc levé pour cette surface. Les
|
||||
observations R1 et T1 restent des limites non bloquantes, inchangées.
|
||||
|
||||
Cette disposition repose sur une revue indépendante du nouveau diff de
|
||||
`task.tsx`, `render-cost.test.ts` et `fixtures/render-cost.tsx`; elle ne fusionne
|
||||
pas les chiffres A/B historiques avec les mesures de ce suivi.
|
||||
|
||||
### Pourquoi la clé fantôme ne subsiste plus
|
||||
|
||||
- À chaque révision de session, le renderer recalcule les identités structurelles
|
||||
dans le budget existant, au lieu de ne détecter que l'augmentation du nombre de
|
||||
parts. Une baisse 2→1, un retour 1→2, un remplacement/reorder 2→2 et une part
|
||||
passée de `tool` à `text` sont donc tous visibles.
|
||||
- Le scan reste borné par `TASK_MESSAGE_SCAN_LIMIT = 10_000` et collecte au plus
|
||||
`TASK_STEP_RENDER_LIMIT + 1`, puis affiche les 200 dernières clés.
|
||||
- Si la séquence de clés est strictement identique, le setter conserve le tableau
|
||||
précédent. Si elle change, `<For>` associe les lignes aux valeurs de clé plutôt
|
||||
qu'à leur position : les clés retenues conservent leur composant et leur état,
|
||||
tandis que les nouvelles identités obtiennent un shell neuf.
|
||||
- Le snapshot résident reste affiché pendant une lecture invalide; le rescan se
|
||||
fait sur le retour autoritaire. Une erreur conserve toujours le snapshot et le
|
||||
retry existants.
|
||||
|
||||
### Régressions discriminantes
|
||||
|
||||
Les nouveaux cas vrais composants/stores/pages natives vérifient :
|
||||
|
||||
1. suppression puis ajout dans le même message, pour chacune des deux positions
|
||||
initiales : compteur `2→1→2`, nouvelle clé unique, shell/disclosure/scroller et
|
||||
`scrollTop` de la clé retenue inchangés;
|
||||
2. remplacement et reorder à cardinalité égale : ordre exact `[step-2, step-0]`,
|
||||
`step-1` absent et shell de `step-0` conservé;
|
||||
3. identité de part identique dont le type passe de tool à texte : compteur réduit
|
||||
et ligne supprimée;
|
||||
4. fenêtre `201→199` : toujours 200 éléments maximum, puis 199, sans diagnostic de
|
||||
troncature obsolète;
|
||||
5. 80 étapes soumises aux deltas de texte non structurels : chaque échantillon
|
||||
conserve `added=0` et `removed=0`. Les 80 snapshots de données déjà documentés
|
||||
restent présents; aucune nouvelle revendication ne les élimine.
|
||||
|
||||
### Validation fraîche du suivi
|
||||
|
||||
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/render-cost.test.ts` — **13/13**, sortie naturelle (~45 s).
|
||||
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/task-copy.test.ts packages/ui/tests/browser/tool-images.test.ts` — **9/9**, sortie naturelle (~37 s).
|
||||
- `npm run typecheck --workspace @codenomad/ui` — vert.
|
||||
- `git diff --check` sur les trois fichiers revus et ce rapport — vert, hors
|
||||
avertissement LF/CRLF du worktree.
|
||||
|
||||
Échantillon descriptif frais des dix deltas à 80 étapes : temps de tâche entre
|
||||
environ **9,2 et 16,1 ms**, temps synchrone rapporté entre **0,1 et 0,3 ms**, avec
|
||||
`added=0` et `removed=0` pour chaque échantillon. Il s'agit d'un fixture Chromium
|
||||
de développement et non d'un nouveau benchmark desktop ou d'une garantie FPS.
|
||||
|
||||
Aucun daemon, profil, base utilisateur, restart, déploiement, dépendance, commit
|
||||
ou push n'a été utilisé pour ce suivi.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import type { Logger } from "../logger"
|
||||
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
import { hashPassword, hashPasswordSync, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||
|
||||
export interface AuthFile {
|
||||
version: 1
|
||||
|
|
@ -20,6 +20,7 @@ export class AuthStore {
|
|||
private cachedFile: AuthFile | null = null
|
||||
private overrideAuth: AuthFile | null = null
|
||||
private bootstrapUsername: string | null = null
|
||||
private passwordChanges: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ export class AuthStore {
|
|||
const runtime: AuthFile = {
|
||||
version: 1,
|
||||
username: params.username,
|
||||
password: hashPassword(password),
|
||||
password: hashPasswordSync(password),
|
||||
userProvided: true,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
|
@ -97,7 +98,7 @@ export class AuthStore {
|
|||
)
|
||||
}
|
||||
|
||||
validateCredentials(username: string, password: string): boolean {
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const auth = this.load()
|
||||
if (!auth) {
|
||||
return false
|
||||
|
|
@ -107,10 +108,21 @@ export class AuthStore {
|
|||
return false
|
||||
}
|
||||
|
||||
return verifyPassword(password, auth.password)
|
||||
const valid = await verifyPassword(password, auth.password)
|
||||
// A password change can commit while scrypt is off-thread. Do not grant a
|
||||
// session from superseded credentials or retry the login automatically.
|
||||
return valid && this.load() === auth
|
||||
}
|
||||
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||
setPassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
|
||||
// Keep admission order and bootstrap state atomic across asynchronous hashing.
|
||||
// A failed change must not poison subsequent requests.
|
||||
const change = this.passwordChanges.then(() => this.changePassword(params))
|
||||
this.passwordChanges = change.then(() => {}, () => {})
|
||||
return change
|
||||
}
|
||||
|
||||
private async changePassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
|
||||
if (this.overrideAuth) {
|
||||
throw new Error(
|
||||
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||
|
|
@ -127,7 +139,7 @@ export class AuthStore {
|
|||
const created: AuthFile = {
|
||||
version: 1,
|
||||
username: this.bootstrapUsername,
|
||||
password: hashPassword(params.password),
|
||||
password: await hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
|
@ -139,7 +151,7 @@ export class AuthStore {
|
|||
|
||||
const next: AuthFile = {
|
||||
...current,
|
||||
password: hashPassword(params.password),
|
||||
password: await hashPassword(params.password),
|
||||
userProvided: params.markUserProvided,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class AuthManager {
|
|||
return this.tokenManager.consume(token)
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
async validateLogin(username: string, password: string): Promise<boolean> {
|
||||
if (!this.authEnabled) {
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
32
packages/server/src/auth/password-hash.test.ts
Normal file
32
packages/server/src/auth/password-hash.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import assert from "node:assert/strict"
|
||||
import crypto from "node:crypto"
|
||||
import { it } from "node:test"
|
||||
import { hashPassword, verifyPassword, type PasswordHashRecord } from "./password-hash"
|
||||
|
||||
it("produces the existing v1 scrypt format and unchanged default parameters", async () => {
|
||||
const password = "test-password"
|
||||
const record = await hashPassword(password)
|
||||
assert.equal(record.algorithm, "scrypt")
|
||||
assert.equal(Buffer.from(record.saltBase64, "base64").length, 16)
|
||||
assert.equal(record.keyLength, 64)
|
||||
assert.deepEqual(record.params, { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 })
|
||||
const expected = crypto.scryptSync(password, Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
|
||||
assert.equal(record.hashBase64, expected.toString("base64"))
|
||||
assert.equal(await verifyPassword(password, record), true)
|
||||
assert.equal(await verifyPassword("wrong-password", record), false)
|
||||
})
|
||||
|
||||
it("verifies existing records using their saved salt, key length and custom scrypt parameters", async () => {
|
||||
const salt = Buffer.from("custom-salt")
|
||||
const params = { N: 1024, r: 8, p: 2, maxmem: 16 * 1024 * 1024 }
|
||||
const record: PasswordHashRecord = {
|
||||
algorithm: "scrypt", saltBase64: salt.toString("base64"),
|
||||
hashBase64: crypto.scryptSync("existing-password", salt, 32, params).toString("base64"),
|
||||
keyLength: 32, params,
|
||||
}
|
||||
assert.equal(await verifyPassword("existing-password", record), true)
|
||||
assert.equal(await verifyPassword("wrong-password", record), false)
|
||||
assert.equal(await verifyPassword("existing-password", { ...record, hashBase64: Buffer.from("short").toString("base64") }), false)
|
||||
assert.equal(await verifyPassword("existing-password", { ...record, algorithm: "unsupported" as "scrypt" }), false)
|
||||
await assert.rejects(verifyPassword("existing-password", { ...record, params: { ...params, N: 3 } }), /scrypt params/)
|
||||
})
|
||||
|
|
@ -20,11 +20,24 @@ const DEFAULT_SCRYPT_PARAMS = {
|
|||
maxmem: 32 * 1024 * 1024,
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): PasswordHashRecord {
|
||||
// Startup-only: AuthManager initializes its CLI/env override before serving HTTP.
|
||||
export function hashPasswordSync(password: string): PasswordHashRecord {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||
return buildRecord(salt, derived, keyLength, params)
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<PasswordHashRecord> {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const params = DEFAULT_SCRYPT_PARAMS
|
||||
const keyLength = 64
|
||||
const derived = await derivePassword(password, salt, keyLength, params)
|
||||
return buildRecord(salt, derived, keyLength, params)
|
||||
}
|
||||
|
||||
function buildRecord(salt: Buffer, derived: Buffer, keyLength: number, params: PasswordHashRecord["params"]): PasswordHashRecord {
|
||||
return {
|
||||
algorithm: "scrypt",
|
||||
saltBase64: salt.toString("base64"),
|
||||
|
|
@ -34,16 +47,25 @@ export function hashPassword(password: string): PasswordHashRecord {
|
|||
}
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||
export async function verifyPassword(password: string, record: PasswordHashRecord): Promise<boolean> {
|
||||
if (record.algorithm !== "scrypt") {
|
||||
return false
|
||||
}
|
||||
|
||||
const salt = Buffer.from(record.saltBase64, "base64")
|
||||
const expected = Buffer.from(record.hashBase64, "base64")
|
||||
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||
const derived = await derivePassword(password, salt, record.keyLength, record.params)
|
||||
if (expected.length !== derived.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||
}
|
||||
|
||||
function derivePassword(password: string, salt: Buffer, keyLength: number, params: PasswordHashRecord["params"]): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, keyLength, params, (error, derived) => {
|
||||
if (error) reject(error)
|
||||
else resolve(derived)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export async function forwardRuntimeRequest(args: {
|
|||
args.reply.raw.once("close", disconnect)
|
||||
if (args.reply.raw.destroyed || args.request.raw.aborted) controller.abort()
|
||||
try {
|
||||
controller.signal.throwIfAborted()
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of Object.entries(args.headers)) {
|
||||
if (typeof value === "string") headers.set(key, value)
|
||||
|
|
|
|||
|
|
@ -42,13 +42,25 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
|
|||
FROM session_message WHERE ${where} AND seq>? AND seq<=? ORDER BY seq LIMIT 512`)
|
||||
const entries: OutlineEntry[] = [], checkpoints: Array<OutlineCheckpoint & { changed: boolean }> = []
|
||||
let examined = 0
|
||||
// Row counts do not bound time: one assistant can contain megabytes of tool
|
||||
// output. Cooperate during both checkpoint reads and JSON projection. A
|
||||
// single SQLite step is still synchronous, so this is not a hard deadline.
|
||||
let turnStarted = performance.now()
|
||||
do {
|
||||
signal.throwIfAborted()
|
||||
const previous = known.find(item => item.after === after)
|
||||
// Grow the last range to 512 rows before creating another checkpoint.
|
||||
// Otherwise one append per refresh eventually exhausts the manifest budget.
|
||||
const end = Math.min(previous && previous !== known.at(-1) ? previous.through : through, through)
|
||||
const rows = headers.all(...params, after, end)
|
||||
const rows = []
|
||||
for (const row of headers.iterate(...params, after, end)) {
|
||||
signal.throwIfAborted()
|
||||
rows.push(row)
|
||||
if (rows.length % 128 === 0 || performance.now() - turnStarted >= 8) {
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
}
|
||||
}
|
||||
// If a cached range gained >512 rows, split and rebuild it instead of
|
||||
// declaring the unseen suffix unchanged.
|
||||
const last = rows.at(-1)
|
||||
|
|
@ -59,17 +71,22 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
|
|||
|| chunkEnd >= maximum
|
||||
if (changed) {
|
||||
for (const row of project.iterate(...params, after, chunkEnd)) {
|
||||
signal.throwIfAborted()
|
||||
const tools = Number(row.tools)
|
||||
entries.push({ id: String(row.id), seq: Number(row.seq), type: row.type as OutlineEntry["type"],
|
||||
tools, reasoning: Number(row.reasoning),
|
||||
...(tools ? { toolName: boundedToolName(row.tool_name) } : {}) })
|
||||
if (entries.length % 128 === 0) await yieldTurn(undefined, { signal })
|
||||
if (entries.length % 128 === 0 || performance.now() - turnStarted >= 8) {
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
checkpoints.push({ after, through: chunkEnd, digest, changed })
|
||||
after = chunkEnd
|
||||
examined += rows.length
|
||||
await yieldTurn(undefined, { signal })
|
||||
turnStarted = performance.now()
|
||||
// Old ranges shrink after deletions. Reserve a whole block before reading
|
||||
// another; the page budget is not necessarily a multiple of 512 anymore.
|
||||
} while (after < through && examined <= 16384 - 512 && checkpoints.length < 512)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { readSessionOutline } from "./outline-index"
|
||||
|
||||
for (const phase of ["headers", "projection"] as const) test(`outline ${phase} admit pending cancellation before consuming an expensive checkpoint`, async (t) => {
|
||||
const db = new DatabaseSync(":memory:")
|
||||
let reads = 0
|
||||
let clock = 0
|
||||
const controller = new AbortController()
|
||||
let pending: ReturnType<typeof setImmediate> | undefined
|
||||
const freeReads = phase === "projection" ? 512 : 0
|
||||
// Charge a deterministic cost to SQLite payload access instead of asserting
|
||||
// wall-clock timing on CI. The real SQL/iterator/transaction still execute.
|
||||
db.function("payload", (data) => {
|
||||
reads++
|
||||
if (reads > freeReads) {
|
||||
clock += 2
|
||||
pending ??= setImmediate(() => controller.abort())
|
||||
}
|
||||
return data
|
||||
})
|
||||
t.mock.method(performance, "now", () => clock)
|
||||
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
|
||||
CREATE TABLE stored_messages(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER);
|
||||
CREATE UNIQUE INDEX stored_session_seq ON stored_messages(session_id,seq);
|
||||
CREATE VIEW session_message AS SELECT id,session_id,type,seq,payload(data) AS data,time_updated FROM stored_messages;
|
||||
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
|
||||
const insert = db.prepare("INSERT INTO stored_messages VALUES (?,'s','assistant',?,?,0)")
|
||||
for (let i = 0; i < 512; i++) insert.run(`m${i}`, i, '{"content":[{"type":"tool","name":"read"}]}')
|
||||
try {
|
||||
await assert.rejects(readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" },
|
||||
undefined, controller.signal), /abort/i)
|
||||
assert(reads - freeReads < 32, `cancelled scan accessed ${reads - freeReads} expensive payloads before giving the event loop a turn`)
|
||||
assert.equal(db.isTransaction, false, "cancellation releases its read snapshot")
|
||||
} finally { if (pending) clearImmediate(pending); db.close() }
|
||||
})
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
import assert from "node:assert/strict"
|
||||
import http from "node:http"
|
||||
import { test } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode/client"
|
||||
import type { Logger } from "../../logger"
|
||||
import { createRuntimeFetch } from "../../opencode/compatibility/transport"
|
||||
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
|
||||
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function send(url: string, method = "POST") {
|
||||
let request!: http.ClientRequest
|
||||
const result = new Promise<number>((resolve, reject) => {
|
||||
request = http.request(url, { method, agent: false, headers: { "content-type": "application/json" } }, response => {
|
||||
response.resume()
|
||||
response.once("end", () => resolve(response.statusCode!))
|
||||
})
|
||||
request.once("error", reject)
|
||||
request.end(method === "POST" ? "{}" : undefined)
|
||||
})
|
||||
void result.catch(() => undefined)
|
||||
return { request, result }
|
||||
}
|
||||
|
||||
type Phase = "connection" | "profile" | "session" | "ownership" | "snapshot" | "environment" | "forward"
|
||||
|
||||
async function harness(phase: Phase, nativePreflight = false) {
|
||||
const blocked = deferred<void>(), started = deferred<void>(), closed = deferred<void>()
|
||||
const cleanupFinished = deferred<void>()
|
||||
const nativeClosed = deferred<void>()
|
||||
let signal: AbortSignal | undefined
|
||||
const calls = { session: 0, snapshot: 0, environment: 0, forward: 0, complete: 0, invalidation: 0 }
|
||||
const hold = async (at: Phase, requestSignal?: AbortSignal) => {
|
||||
if (phase !== at) return
|
||||
signal = requestSignal
|
||||
started.resolve()
|
||||
// Deliberately ignore cancellation in the fake: the observer must also
|
||||
// retire locally and fence continuations from non-cancellable shared work.
|
||||
await blocked.promise
|
||||
}
|
||||
const upstream = Fastify()
|
||||
upstream.all("/*", async (request, reply) => {
|
||||
if (nativePreflight && request.method === "GET" && request.url === "/api/session/held") {
|
||||
reply.raw.once("close", () => nativeClosed.resolve())
|
||||
await hold("session")
|
||||
return { id: "held", location: { directory: "/repo" } }
|
||||
}
|
||||
calls.forward++
|
||||
await hold("forward")
|
||||
calls.complete++
|
||||
return { path: request.url }
|
||||
})
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const upstreamAddress = upstream.server.address()
|
||||
assert.ok(upstreamAddress && typeof upstreamAddress === "object")
|
||||
const endpoint = `http://127.0.0.1:${upstreamAddress.port}`
|
||||
const native = OpenCode.make({ baseUrl: endpoint, fetch: createRuntimeFetch({ url: endpoint }) })
|
||||
const client = {
|
||||
session: {
|
||||
get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
|
||||
calls.session++
|
||||
if (nativePreflight && sessionID === "held") return native.session.get({ sessionID }, options)
|
||||
if (sessionID !== "witness") await hold("session", options?.signal)
|
||||
return { id: sessionID, location: { directory: "/repo" } }
|
||||
},
|
||||
environment: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
calls.environment++
|
||||
await hold("environment", options?.signal)
|
||||
},
|
||||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
},
|
||||
} as unknown as OpenCodeClient
|
||||
const connection = {
|
||||
endpoint: { url: endpoint }, client,
|
||||
fetch: createRuntimeFetch({ url: endpoint }),
|
||||
assertCurrent() {}, invalidate() { calls.invalidation++ },
|
||||
profile: async (requestSignal?: AbortSignal) => { await hold("profile", requestSignal); return "modern" as const },
|
||||
}
|
||||
const manager = {
|
||||
get: () => ({ id: "workspace", path: "/repo" }),
|
||||
getSharedServiceConnection: async () => { await hold("connection"); return connection },
|
||||
getSharedServiceEndpoint: async () => connection.endpoint,
|
||||
getSharedServiceClient: async () => client,
|
||||
getSharedServiceFetch: async () => connection.fetch,
|
||||
getInstanceAuthorizationHeader: () => undefined,
|
||||
getServiceDirectory: () => "/repo",
|
||||
getServiceDirectoryForPath: async () => "/repo",
|
||||
getServicePathForPath: async () => "/repo",
|
||||
getWorktreeIdentityForPath: async () => "repo",
|
||||
getSessionEnvironment: async (_id: string, requestSignal?: AbortSignal) => {
|
||||
calls.snapshot++
|
||||
await hold("snapshot", requestSignal)
|
||||
return {}
|
||||
},
|
||||
ownsDirectory: async () => true,
|
||||
ownsLocation: async (_id: string, _location: unknown, _client?: unknown, requestSignal?: AbortSignal) => {
|
||||
await hold("ownership", requestSignal)
|
||||
return true
|
||||
},
|
||||
ownsPath: async () => true,
|
||||
} as unknown as InstanceProxyWorkspaceManager
|
||||
const app = Fastify()
|
||||
const captured: Array<{ request: http.IncomingMessage; response: http.ServerResponse; abortedListeners: number; closeListeners: number }> = []
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
captured.push({ request: request.raw, response: reply.raw,
|
||||
abortedListeners: request.raw.listenerCount("aborted"), closeListeners: reply.raw.listenerCount("close") })
|
||||
if (!request.url.endsWith("/witness")) reply.raw.once("close", () => {
|
||||
closed.resolve()
|
||||
// The admission race settles and disposes in the following microtasks.
|
||||
setImmediate(() => cleanupFinished.resolve())
|
||||
})
|
||||
})
|
||||
await app.register(replyFrom)
|
||||
const fence = new WorktreeDeletionFence()
|
||||
registerInstanceProxyRoutes(app, {
|
||||
workspaceManager: manager, worktreeDeletionFence: fence,
|
||||
logger: { debug() {}, error() {}, isLevelEnabled() { return false } } as unknown as Logger,
|
||||
})
|
||||
await app.listen({ host: "127.0.0.1", port: 0 })
|
||||
const address = app.server.address()
|
||||
assert.ok(address && typeof address === "object")
|
||||
const base = `http://127.0.0.1:${address.port}/workspaces/workspace/instance/api/session`
|
||||
return {
|
||||
base, calls, blocked, started, closed, cleanupFinished, captured, fence, nativeClosed,
|
||||
signal: () => signal,
|
||||
async dispose() { blocked.resolve(); await app.close(); await upstream.close() },
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["prompt", "compact"] as const) {
|
||||
test(`disconnect cancels actual Promise SDK HTTP preflight for ${action}`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness("session", true)
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.nativeClosed.promise
|
||||
assert.equal(h.calls.forward, 0)
|
||||
assert.equal(h.calls.environment, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
|
||||
test(`disconnect cancels held ${action} session preflight, without blocking another session`, { timeout: 10_000 }, async t => {
|
||||
const h = await harness("session")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
const witnessStarted = performance.now()
|
||||
const witness = await send(`${h.base}/witness`, "GET").result
|
||||
t.diagnostic(`another session responded while the preflight was held: ${Math.round(performance.now() - witnessStarted)} ms`)
|
||||
assert.equal(witness, 200) // causal independence, not a machine-speed threshold
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
assert.equal(h.signal()?.aborted, true)
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.calls.forward, 1, "only the independent witness reached native forwarding")
|
||||
assert.equal(h.calls.environment, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
for (const phase of ["connection", "profile", "ownership", "snapshot", "environment"] as const) {
|
||||
test(`disconnect retires a held ${phase} boundary and fences late admission`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness(phase)
|
||||
try {
|
||||
const pending = send(`${h.base}/held/prompt`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
if (phase !== "connection") assert.equal(h.signal()?.aborted, true)
|
||||
const callsAtDisconnect = { ...h.calls }
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(h.calls, callsAtDisconnect, "late shared work must not start any subsequent calls")
|
||||
assert.equal(h.calls.forward, 0)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
// A preflight-abandoned mutation cannot keep the deletion fence reserved.
|
||||
await h.fence.run("repo", ["repo"], async () => {})
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
assert.equal(entry.response.listenerCount("close"), entry.closeListeners)
|
||||
}
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ["prompt", "compact"] as const) {
|
||||
test(`disconnect after native ${action} admission does not interrupt, replay or invalidate execution`, { timeout: 10_000 }, async () => {
|
||||
const h = await harness("forward")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/${action}`)
|
||||
await h.started.promise
|
||||
pending.request.destroy()
|
||||
await h.closed.promise
|
||||
await h.cleanupFinished.promise
|
||||
assert.equal(h.calls.forward, 1)
|
||||
h.blocked.resolve()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.calls.complete, 1, "the admitted upstream operation can finish after its HTTP observer leaves")
|
||||
assert.equal(h.calls.forward, 1)
|
||||
assert.equal(h.calls.invalidation, 0)
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
test("normal POST body close preserves admission; success and denial dispose admission listeners", { timeout: 10_000 }, async () => {
|
||||
const h = await harness("session")
|
||||
try {
|
||||
const pending = send(`${h.base}/held/compact`)
|
||||
await h.started.promise
|
||||
assert.equal(h.captured[0].request.complete, true)
|
||||
assert.equal(h.signal()?.aborted, false)
|
||||
h.blocked.resolve()
|
||||
assert.equal(await pending.result, 200)
|
||||
assert.equal(await send(`${h.base}/held/not-allowed`).result, 403)
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
// Node's response pipeline retains its own finished-stream callbacks;
|
||||
// check our admission/forwarding listeners, not those library internals.
|
||||
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
|
||||
}
|
||||
} finally { await h.dispose() }
|
||||
})
|
||||
|
|
@ -48,6 +48,7 @@ import type { SideCarManager } from "../sidecars/manager"
|
|||
import type { PreviewManager } from "../previews/manager"
|
||||
import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge"
|
||||
import { forwardRuntimeRequest } from "../opencode/compatibility/proxy"
|
||||
import { requestAdmission } from "./request-admission"
|
||||
import { LOCATION_CONTEXT_HEADER, locationRequestOptions, readLocationContext } from "../opencode/compatibility/location"
|
||||
import { decodeSessionListScope, prepareLocationImport, readRequestLocations, type SessionListScope } from "../opencode/compatibility/proxy-locations"
|
||||
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||
|
|
@ -430,7 +431,7 @@ export interface InstanceProxyWorkspaceManager {
|
|||
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
|
||||
getSharedServiceClient(): Promise<OpenCodeClient>
|
||||
getSessionEnvironment(id: string, signal?: AbortSignal): Promise<Record<string, string>>
|
||||
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType<WorkspaceManager["ownsLocation"]>
|
||||
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): ReturnType<WorkspaceManager["ownsLocation"]>
|
||||
ownsDirectory(id: string, directory: string): Promise<boolean>
|
||||
ownsPath(id: string, candidate: string): Promise<boolean>
|
||||
}
|
||||
|
|
@ -646,15 +647,31 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
|
|||
})
|
||||
}
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
interface InstanceProxyRequestArgs {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: InstanceProxyWorkspaceManager
|
||||
worktreeDeletionFence: WorktreeDeletionFence
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
async function proxyWorkspaceRequest(args: InstanceProxyRequestArgs) {
|
||||
const admission = requestAdmission(args.request, args.reply)
|
||||
try {
|
||||
admission.signal.throwIfAborted()
|
||||
return await proxyWorkspaceAdmission(args, admission)
|
||||
} catch (error) {
|
||||
if (!admission.signal.aborted) throw error
|
||||
} finally {
|
||||
admission.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission: ReturnType<typeof requestAdmission>) {
|
||||
const { request, reply, workspaceManager, logger, worktreeDeletionFence } = args
|
||||
const { signal } = admission
|
||||
const wait = admission.wait
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
|
|
@ -679,15 +696,18 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
|
||||
const connection = await workspaceManager.getSharedServiceConnection?.(workspaceId)
|
||||
const endpoint = connection?.endpoint ?? await workspaceManager.getSharedServiceEndpoint(workspaceId)
|
||||
const clientForRequest = () => connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient()
|
||||
const connection = await wait(Promise.resolve(workspaceManager.getSharedServiceConnection?.(workspaceId)))
|
||||
const endpoint = connection?.endpoint ?? await wait(workspaceManager.getSharedServiceEndpoint(workspaceId))
|
||||
const clientForRequest = () => {
|
||||
signal.throwIfAborted()
|
||||
return wait(connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient())
|
||||
}
|
||||
if (!endpoint) {
|
||||
reply.code(502).send({ error: "OpenCode service is not ready" })
|
||||
return
|
||||
}
|
||||
|
||||
await connection?.profile()
|
||||
await wait(Promise.resolve(connection?.profile(signal)))
|
||||
let locationContext: LocationRef | undefined
|
||||
try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER]) }
|
||||
catch { return reply.code(400).send({ error: "Invalid location context" }) }
|
||||
|
|
@ -704,34 +724,36 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
const client = await clientForRequest()
|
||||
const active = await client.session.active()
|
||||
const active = await wait(client.session.active({ signal }))
|
||||
const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => {
|
||||
try {
|
||||
const session = await client.session.get({ sessionID: sessionId })
|
||||
return await workspaceManager.ownsLocation(workspaceId, session.location, client) ? [sessionId, status] as const : null
|
||||
const session = await wait(client.session.get({ sessionID: sessionId }, { signal }))
|
||||
return await wait(workspaceManager.ownsLocation(workspaceId, session.location, client, signal)) ? [sessionId, status] as const : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
signal.throwIfAborted()
|
||||
reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null)) })
|
||||
return
|
||||
}
|
||||
if (pathname.replace(/\/+$/, "") === "/api/project") {
|
||||
const projects = await (await clientForRequest()).project.list()
|
||||
const projects = await wait((await clientForRequest()).project.list({ signal }))
|
||||
const ownedProjects = await Promise.all(projects.map(async (project) => {
|
||||
if (!await workspaceManager.ownsDirectory(workspaceId, project.canonical)) return null
|
||||
if (!await wait(workspaceManager.ownsDirectory(workspaceId, project.canonical))) return null
|
||||
const sandboxes = (await Promise.all(project.sandboxes.map(async (directory) => (
|
||||
await workspaceManager.ownsDirectory(workspaceId, directory) ? directory : null
|
||||
await wait(workspaceManager.ownsDirectory(workspaceId, directory)) ? directory : null
|
||||
)))).filter((directory): directory is string => directory !== null)
|
||||
return { ...project, sandboxes }
|
||||
}))
|
||||
signal.throwIfAborted()
|
||||
reply.send(ownedProjects.filter((project): project is NonNullable<typeof project> => project !== null))
|
||||
return
|
||||
}
|
||||
const sessionListHasScope = request.method === "GET"
|
||||
&& pathname.replace(/\/+$/, "") === "/api/session"
|
||||
&& (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project"))
|
||||
const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client)
|
||||
const sessionListScope = await wait(authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client, signal))
|
||||
if (sessionListScope !== "allowed") {
|
||||
reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -793,15 +815,15 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
if (requestLocations.invalid
|
||||
|| !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))
|
||||
|| !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client))) {
|
||||
|| !(await wait(allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories)))
|
||||
|| !(await wait(allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client, signal)))) {
|
||||
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translatedDirectories = new Map<string, string>()
|
||||
for (const directory of new Set(requestLocations.directories)) {
|
||||
const translated = workspaceManager.getServiceDirectoryForPath
|
||||
? await workspaceManager.getServiceDirectoryForPath(workspaceId, directory)
|
||||
? await wait(workspaceManager.getServiceDirectoryForPath(workspaceId, directory))
|
||||
: directory
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Location does not belong to workspace" })
|
||||
|
|
@ -820,12 +842,12 @@ async function proxyWorkspaceRequest(args: {
|
|||
return
|
||||
}
|
||||
if (fileListPath.candidate) {
|
||||
if (!(await workspaceManager.ownsPath(workspaceId, fileListPath.candidate))) {
|
||||
if (!(await wait(workspaceManager.ownsPath(workspaceId, fileListPath.candidate)))) {
|
||||
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translated = workspaceManager.getServicePathForPath
|
||||
? await workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate)
|
||||
? await wait(workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate))
|
||||
: fileListPath.candidate
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
|
||||
|
|
@ -836,7 +858,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
const mutationIdentities = new Set<string>()
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
for (const directory of requestLocations.directories) {
|
||||
const identity = await workspaceManager.getWorktreeIdentityForPath(workspaceId, directory)
|
||||
const identity = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, directory))
|
||||
if (!identity) {
|
||||
reply.code(403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -845,14 +867,14 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
}
|
||||
const serviceBody = replaceRequestDirectories(targetUrl, imported.body, translatedDirectories, pathname, request.method)
|
||||
if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) {
|
||||
if (promptFiles.invalid || !(await wait(allPathsOwned(workspaceManager, workspaceId, promptFiles.paths)))) {
|
||||
reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const translatedPromptPaths = new Map<string, string>()
|
||||
for (const candidate of new Set(promptFiles.paths)) {
|
||||
const translated = workspaceManager.getServicePathForPath
|
||||
? await workspaceManager.getServicePathForPath(workspaceId, candidate)
|
||||
? await wait(workspaceManager.getServicePathForPath(workspaceId, candidate))
|
||||
: candidate
|
||||
if (!translated) {
|
||||
reply.code(403).send({ error: "Prompt file does not belong to workspace" })
|
||||
|
|
@ -867,10 +889,10 @@ async function proxyWorkspaceRequest(args: {
|
|||
directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory,
|
||||
workspaceID: locationContext?.workspaceID ?? explicitLocations.find(location => location.directory === requestedDirectory)?.workspaceID,
|
||||
}
|
||||
const runtimeLocationOptions = locationRequestOptions(runtimeLocation)
|
||||
const runtimeLocationOptions = { ...locationRequestOptions(runtimeLocation), signal }
|
||||
if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") {
|
||||
const result = await (await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd)))
|
||||
const result = await wait((await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
const ownership = await wait(Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))))
|
||||
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
|
||||
return
|
||||
}
|
||||
|
|
@ -878,8 +900,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
const ptyId = getPtyRouteId(pathname)
|
||||
if (ptyId) {
|
||||
try {
|
||||
const pty = await (await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) {
|
||||
const pty = await wait((await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, pty.data.cwd)))) {
|
||||
reply.code(403).send({ error: "PTY does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
|
@ -893,8 +915,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
|
||||
if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") {
|
||||
const result = await (await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd)))
|
||||
const result = await wait((await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
const ownership = await wait(Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))))
|
||||
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
|
||||
return
|
||||
}
|
||||
|
|
@ -902,8 +924,8 @@ async function proxyWorkspaceRequest(args: {
|
|||
const shellId = getShellRouteId(pathname)
|
||||
if (shellId) {
|
||||
try {
|
||||
const shell = await (await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
|
||||
if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) {
|
||||
const shell = await wait((await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
|
||||
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, shell.data.cwd)))) {
|
||||
reply.code(403).send({ error: "Shell does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
|
@ -920,7 +942,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
if (sessionId && !isGlobalFormAction(pathname, request.method)) {
|
||||
let session
|
||||
try {
|
||||
session = await (await clientForRequest()).session.get({ sessionID: sessionId })
|
||||
session = await wait((await clientForRequest()).session.get({ sessionID: sessionId }, { signal }))
|
||||
} catch (error) {
|
||||
if (isInvalidRequestError(error)) {
|
||||
reply.code(400).send({ error: "Invalid session ID" })
|
||||
|
|
@ -934,12 +956,12 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
throw error
|
||||
}
|
||||
if (!(await workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest()))) {
|
||||
if (!(await wait(workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest(), signal)))) {
|
||||
reply.code(403).send({ error: "Session does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const sessionWorktree = await workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory)
|
||||
const sessionWorktree = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory))
|
||||
if (!sessionWorktree) {
|
||||
reply.code(403).send({ error: "Session does not belong to workspace" })
|
||||
return
|
||||
|
|
@ -950,6 +972,7 @@ async function proxyWorkspaceRequest(args: {
|
|||
|
||||
const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId) && !isGlobalFormAction(pathname, request.method))
|
||||
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
signal.throwIfAborted()
|
||||
const releaseMutation = request.method === "GET" || request.method === "HEAD"
|
||||
? undefined
|
||||
: worktreeDeletionFence.enter([...mutationIdentities])
|
||||
|
|
@ -962,53 +985,49 @@ async function proxyWorkspaceRequest(args: {
|
|||
try {
|
||||
connection?.assertCurrent()
|
||||
if (request.method === "POST" && /^\/api\/session\/[^/]+\/(?:prompt|command|shell)\/?$/.test(pathname)) {
|
||||
const disconnected = new AbortController()
|
||||
const onDisconnect = () => disconnected.abort()
|
||||
reply.raw.once("close", onDisconnect)
|
||||
try {
|
||||
try {
|
||||
const signal = AbortSignal.any([disconnected.signal, AbortSignal.timeout(15_000)])
|
||||
if (reply.raw.destroyed || request.raw.aborted) disconnected.abort()
|
||||
signal.throwIfAborted()
|
||||
const variables = await workspaceManager.getSessionEnvironment(workspaceId, signal)
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
await (await clientForRequest()).session.environment({ sessionID: sessionId!, variables }, { signal })
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} catch {
|
||||
// Never log the SDK error: it can contain the complete environment body.
|
||||
releaseMutation?.()
|
||||
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
|
||||
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
|
||||
}
|
||||
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
|
||||
try {
|
||||
connection?.assertCurrent()
|
||||
await syncSessionGitContext(await clientForRequest(), sessionId!, disconnected.signal)
|
||||
} catch {
|
||||
// Advisory context must not turn Git recovery into another send blocker.
|
||||
// Do not log SDK bodies (they may include unrelated session context).
|
||||
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
|
||||
}
|
||||
}
|
||||
disconnected.signal.throwIfAborted()
|
||||
const environmentSignal = AbortSignal.any([signal, AbortSignal.timeout(15_000)])
|
||||
const variables = await wait(workspaceManager.getSessionEnvironment(workspaceId, environmentSignal))
|
||||
const client = await clientForRequest()
|
||||
environmentSignal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} finally {
|
||||
reply.raw.off("close", onDisconnect)
|
||||
await wait(client.session.environment({ sessionID: sessionId!, variables }, { signal: environmentSignal }))
|
||||
environmentSignal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
// Never log the SDK error: it can contain the complete environment body.
|
||||
releaseMutation?.()
|
||||
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
|
||||
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
|
||||
}
|
||||
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
|
||||
try {
|
||||
connection?.assertCurrent()
|
||||
await wait(syncSessionGitContext(await clientForRequest(), sessionId!, signal))
|
||||
} catch {
|
||||
// Advisory context must not turn Git recovery into another send blocker.
|
||||
// Do not log SDK bodies (they may include unrelated session context).
|
||||
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
connection?.assertCurrent()
|
||||
}
|
||||
if (connection || workspaceManager.getSharedServiceFetch) {
|
||||
const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader)
|
||||
delete headers[LOCATION_CONTEXT_HEADER]
|
||||
if (locationContext) Object.assign(headers, locationRequestOptions({ ...locationContext, directory: translatedDirectories.get(locationContext.directory)! }, { includeDirectory: true })?.headers)
|
||||
if (globalFormLocation) headers["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!)
|
||||
const runtimeFetch = connection?.fetch ?? await wait(workspaceManager.getSharedServiceFetch!())
|
||||
signal.throwIfAborted()
|
||||
return await forwardRuntimeRequest({
|
||||
request, reply, url: targetUrl.toString(), body, headers,
|
||||
fetch: connection?.fetch ?? await workspaceManager.getSharedServiceFetch!(), release: releaseMutation,
|
||||
fetch: runtimeFetch, release: releaseMutation,
|
||||
invalidate: () => connection ? connection.invalidate() : workspaceManager.invalidateSharedServiceConnection?.(),
|
||||
})
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
return reply.from(targetUrl.toString(), {
|
||||
...(body !== request.body ? { body } : {}),
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
|
|
@ -1148,9 +1167,9 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
|
|||
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient) {
|
||||
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient, signal?: AbortSignal) {
|
||||
const unique = new Map(locations.map((location) => [JSON.stringify([location.directory, location.workspaceID]), location]))
|
||||
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client)))).every(Boolean)
|
||||
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client, signal)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
|
||||
|
|
@ -1199,6 +1218,7 @@ async function authorizeSessionList(
|
|||
manager: InstanceProxyWorkspaceManager,
|
||||
workspaceId: string,
|
||||
client?: OpenCodeClient,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"allowed" | "invalid" | "foreign"> {
|
||||
if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed"
|
||||
const cursors = targetUrl.searchParams.getAll("cursor")
|
||||
|
|
@ -1209,10 +1229,11 @@ async function authorizeSessionList(
|
|||
// A project cursor embeds its original wide scope; never reinterpret it as
|
||||
// a directory cursor when Git authority is unavailable.
|
||||
if (scope.project && !(await readGitStatus()).available) return "foreign"
|
||||
signal?.throwIfAborted()
|
||||
for (const key of ["directory", "location[directory]", "project", "subpath"]) {
|
||||
targetUrl.searchParams.delete(key)
|
||||
}
|
||||
return ownsSessionListScope(manager, workspaceId, scope, client)
|
||||
return ownsSessionListScope(manager, workspaceId, scope, client, signal)
|
||||
}
|
||||
|
||||
const projects = targetUrl.searchParams.getAll("project")
|
||||
|
|
@ -1228,10 +1249,11 @@ async function authorizeSessionList(
|
|||
const project = projects[0]
|
||||
const subpath = subpaths[0]
|
||||
if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid"
|
||||
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client)
|
||||
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client, signal)
|
||||
if (ownership !== "allowed") return ownership
|
||||
if (!(await readGitStatus()).available) {
|
||||
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client)) return "foreign"
|
||||
signal?.throwIfAborted()
|
||||
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client, signal)) return "foreign"
|
||||
// Narrow the initial query, preserving native directory-scoped pagination.
|
||||
// Unlike a project cursor this request has no already-established scope.
|
||||
const root = manager.getServiceDirectory?.(workspaceId) ?? manager.get(workspaceId)?.path
|
||||
|
|
@ -1252,20 +1274,25 @@ async function ownsSessionListScope(
|
|||
workspaceId: string,
|
||||
scope: SessionListScope,
|
||||
client?: OpenCodeClient,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"allowed" | "foreign"> {
|
||||
signal?.throwIfAborted()
|
||||
if (scope.directory) {
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client, signal)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
if (!scope.project) return "foreign"
|
||||
const project = (await (client ?? await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project)
|
||||
const serviceClient = client ?? await manager.getSharedServiceClient()
|
||||
signal?.throwIfAborted()
|
||||
const project = (await serviceClient.project.list({ signal })).find((candidate) => candidate.id === scope.project)
|
||||
signal?.throwIfAborted()
|
||||
if (!project) return "foreign"
|
||||
const directory = scope.subpath === undefined
|
||||
? project.canonical
|
||||
: /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical)
|
||||
? path.win32.resolve(project.canonical, scope.subpath)
|
||||
: path.posix.resolve(project.canonical, scope.subpath)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory }, client)
|
||||
const owned = await manager.ownsLocation(workspaceId, { directory }, client, signal)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
|
||||
|
|
|
|||
45
packages/server/src/server/request-admission.test.ts
Normal file
45
packages/server/src/server/request-admission.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { test } from "node:test"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import { requestAdmission } from "./request-admission"
|
||||
|
||||
function fixture() {
|
||||
const request = Object.assign(new EventEmitter(), { aborted: false })
|
||||
const reply = Object.assign(new EventEmitter(), { destroyed: false, writableFinished: false })
|
||||
const admission = requestAdmission({ raw: request } as unknown as FastifyRequest, { raw: reply } as unknown as FastifyReply)
|
||||
return { request, reply, admission }
|
||||
}
|
||||
|
||||
test("normal upload close and completed response close do not revoke admission", () => {
|
||||
const h = fixture()
|
||||
h.request.emit("close")
|
||||
assert.equal(h.admission.signal.aborted, false)
|
||||
h.reply.writableFinished = true
|
||||
h.reply.emit("close")
|
||||
assert.equal(h.admission.signal.aborted, false)
|
||||
h.admission.dispose()
|
||||
assert.equal(h.request.listenerCount("aborted"), 0)
|
||||
assert.equal(h.reply.listenerCount("close"), 0)
|
||||
})
|
||||
|
||||
test("aborted uploads and incomplete response close retire a held observer and dispose listeners", async () => {
|
||||
for (const upload of [true, false]) {
|
||||
const h = fixture()
|
||||
const pending = h.admission.wait(new Promise<void>(() => {}))
|
||||
const rejected = assert.rejects(pending, { name: "AbortError" })
|
||||
if (upload) h.request.emit("aborted")
|
||||
else h.reply.emit("close")
|
||||
await rejected
|
||||
h.admission.dispose()
|
||||
assert.equal(h.request.listenerCount("aborted"), 0)
|
||||
assert.equal(h.reply.listenerCount("close"), 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("already disconnected admission observes late rejected work without unhandled rejection", async () => {
|
||||
const h = fixture()
|
||||
h.reply.emit("close")
|
||||
await assert.rejects(h.admission.wait(Promise.reject(new Error("late shared failure"))), { name: "AbortError" })
|
||||
h.admission.dispose()
|
||||
})
|
||||
41
packages/server/src/server/request-admission.ts
Normal file
41
packages/server/src/server/request-admission.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
|
||||
/** Cancellation belongs to this HTTP observer, never to shared connection work.
|
||||
* IncomingMessage.close also fires after a normal request body; only an aborted
|
||||
* upload or the downstream response closing before completion revokes admission.
|
||||
*/
|
||||
export function requestAdmission(request: FastifyRequest, reply: FastifyReply) {
|
||||
const controller = new AbortController()
|
||||
const abort = () => controller.abort()
|
||||
const close = () => { if (!reply.raw.writableFinished) abort() }
|
||||
request.raw.once("aborted", abort)
|
||||
reply.raw.once("close", close)
|
||||
if (request.raw.aborted || reply.raw.destroyed) abort()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
async wait<T>(pending: Promise<T>): Promise<T> {
|
||||
if (controller.signal.aborted) {
|
||||
// A caller may already have allocated the Promise. Observe its rejection
|
||||
// even when admission was revoked immediately before this boundary.
|
||||
void pending.catch(() => undefined)
|
||||
controller.signal.throwIfAborted()
|
||||
}
|
||||
let rejectAbort!: () => void
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = () => reject(controller.signal.reason)
|
||||
controller.signal.addEventListener("abort", rejectAbort, { once: true })
|
||||
})
|
||||
try {
|
||||
const result = await Promise.race([pending, aborted])
|
||||
controller.signal.throwIfAborted()
|
||||
return result
|
||||
} finally {
|
||||
controller.signal.removeEventListener("abort", rejectAbort)
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
request.raw.off("aborted", abort)
|
||||
reply.raw.off("close", close)
|
||||
},
|
||||
}
|
||||
}
|
||||
272
packages/server/src/server/routes/auth-responsiveness.test.ts
Normal file
272
packages/server/src/server/routes/auth-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import assert from "node:assert/strict"
|
||||
import crypto from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { performance } from "node:perf_hooks"
|
||||
import { it } from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import pino from "pino"
|
||||
import { AuthManager } from "../../auth/manager"
|
||||
import { registerAuthRoutes } from "./auth"
|
||||
|
||||
const username = "fixture-user"
|
||||
const password = "fixture-password"
|
||||
|
||||
async function fixture(options: { persist?: boolean; generateToken?: boolean; password?: string; dangerouslySkipAuth?: boolean } = {}) {
|
||||
const tempRoot = path.join(os.tmpdir(), "opencode")
|
||||
await fs.mkdir(tempRoot, { recursive: true })
|
||||
const directory = await fs.mkdtemp(path.join(tempRoot, "codenomad-auth-responsive-"))
|
||||
// An existing v1 record, produced independently with the old synchronous API.
|
||||
const salt = Buffer.from("0123456789abcdef")
|
||||
const params = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }
|
||||
if (options.persist !== false) await fs.writeFile(path.join(directory, "auth.json"), JSON.stringify({
|
||||
version: 1,
|
||||
username,
|
||||
password: {
|
||||
algorithm: "scrypt", saltBase64: salt.toString("base64"),
|
||||
hashBase64: crypto.scryptSync(password, salt, 64, params).toString("base64"),
|
||||
keyLength: 64, params,
|
||||
},
|
||||
userProvided: true,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}))
|
||||
const manager = new AuthManager({
|
||||
configPath: path.join(directory, "config.yaml"), username, generateToken: false, ...options,
|
||||
}, pino({ level: "silent" }))
|
||||
const app = Fastify({ logger: false })
|
||||
registerAuthRoutes(app, { authManager: manager })
|
||||
return {
|
||||
app, manager, directory,
|
||||
async close() {
|
||||
await app.close()
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function observeWork<T>(work: () => T, state: { pending: number; completed: number; maxCallMs: number }): T {
|
||||
state.pending++
|
||||
const start = performance.now()
|
||||
try {
|
||||
const result = work()
|
||||
state.maxCallMs = Math.max(state.maxCallMs, performance.now() - start)
|
||||
if (result && typeof (result as unknown as Promise<unknown>).then === "function") {
|
||||
return Promise.resolve(result).finally(() => { state.pending--; state.completed++ }) as T
|
||||
}
|
||||
state.pending--
|
||||
state.completed++
|
||||
return result
|
||||
} catch (error) {
|
||||
state.pending--
|
||||
state.completed++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
it("services a witness endpoint while a bounded burst of login verifications is pending", async (t) => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const state = { pending: 0, completed: 0, maxCallMs: 0 }
|
||||
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
|
||||
const validate = f.manager.validateLogin.bind(f.manager)
|
||||
let probe: Promise<{ pending: number; completed: number }> | undefined
|
||||
let probeMs = 0
|
||||
f.manager.validateLogin = (...args) => {
|
||||
if (!probe) {
|
||||
const start = performance.now()
|
||||
probe = new Promise((resolve, reject) => setImmediate(() => {
|
||||
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
|
||||
probeMs = performance.now() - start
|
||||
resolve(response.json())
|
||||
}, reject)
|
||||
}))
|
||||
}
|
||||
return observeWork(() => validate(...args), state)
|
||||
}
|
||||
const start = performance.now()
|
||||
const responses = await Promise.all(Array.from({ length: 8 }, () => f.app.inject({
|
||||
method: "POST", url: "/api/auth/login", payload: { username, password: "invalid-password" },
|
||||
})))
|
||||
const witness = await probe!
|
||||
t.diagnostic(JSON.stringify({ operation: "login", totalMs: performance.now() - start, probeMs, ...state, witness }))
|
||||
for (const response of responses) {
|
||||
assert.equal(response.statusCode, 401)
|
||||
assert.deepEqual(response.json(), { error: "Invalid credentials" })
|
||||
assert.equal(response.headers["set-cookie"], undefined)
|
||||
}
|
||||
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive verifications finish")
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("awaits login results for valid credentials, rejects wrong usernames and authenticates the cookie", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
{ username, password },
|
||||
{ username: "not-the-user", password },
|
||||
{ username, password: "not-the-password" },
|
||||
].map((payload) => f.app.inject({ method: "POST", url: "/api/auth/login", payload })))
|
||||
assert.deepEqual(responses.map((response) => response.statusCode), [200, 401, 401])
|
||||
assert.deepEqual(responses[0].json(), { ok: true })
|
||||
const cookie = String(responses[0].headers["set-cookie"])
|
||||
assert.match(cookie, /HttpOnly; Path=\/; SameSite=Lax/)
|
||||
const status = await f.app.inject({ method: "GET", url: "/api/auth/status", headers: { cookie } })
|
||||
assert.deepEqual(status.json(), { authenticated: true, username, passwordUserProvided: true })
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects a pending login verified against a password replaced before verification returns", async (t) => {
|
||||
const f = await fixture()
|
||||
let release: (() => void) | undefined
|
||||
let pendingLogin: Promise<unknown> | undefined
|
||||
try {
|
||||
let notifyDerived!: () => void
|
||||
const derived = new Promise<void>((resolve) => { notifyDerived = resolve })
|
||||
const scrypt = crypto.scrypt.bind(crypto)
|
||||
let held = false
|
||||
// Run real scrypt, but hold the first old-password callback until the new
|
||||
// password is durably committed. No scheduler or elapsed-time assumptions.
|
||||
const scryptMock = t.mock.method(crypto, "scrypt", (
|
||||
input: crypto.BinaryLike, salt: crypto.BinaryLike, keyLength: number,
|
||||
options: crypto.ScryptOptions, callback: (error: Error | null, key: Buffer) => void,
|
||||
) => scrypt(input, salt, keyLength, options, (error, key) => {
|
||||
if (input === password && !held) {
|
||||
held = true
|
||||
release = () => { release = undefined; callback(error, key) }
|
||||
notifyDerived()
|
||||
} else {
|
||||
callback(error, key)
|
||||
}
|
||||
}))
|
||||
const login = f.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } })
|
||||
.then((response) => response)
|
||||
pendingLogin = login
|
||||
await derived
|
||||
const session = f.manager.createSession(username)
|
||||
const changed = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "replacement-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(changed.statusCode, 200)
|
||||
assert.equal(await f.manager.validateLogin(username, "replacement-password"), true)
|
||||
release!()
|
||||
const stale = await login
|
||||
assert.equal(stale.statusCode, 401, "a successful derivation of superseded credentials must not authorize a login")
|
||||
assert.deepEqual(stale.json(), { error: "Invalid credentials" })
|
||||
assert.equal(stale.headers["set-cookie"], undefined)
|
||||
assert.equal(scryptMock.mock.callCount(), 3, "do not replay the old login against the replacement password")
|
||||
assert.equal(await f.manager.validateLogin(username, password), false)
|
||||
} finally {
|
||||
release?.()
|
||||
await pendingLogin
|
||||
t.mock.restoreAll()
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves unauthenticated, runtime-override and disabled-auth password errors", async () => {
|
||||
for (const options of [{}, { password }, { dangerouslySkipAuth: true }]) {
|
||||
const f = await fixture(options)
|
||||
try {
|
||||
if (!options.dangerouslySkipAuth) {
|
||||
const unauthorized = await f.app.inject({ method: "POST", url: "/api/auth/password", payload: { password } })
|
||||
assert.equal(unauthorized.statusCode, 401)
|
||||
assert.deepEqual(unauthorized.json(), { error: "Unauthorized" })
|
||||
}
|
||||
if (options.password || options.dangerouslySkipAuth) {
|
||||
const session = f.manager.createSession(username)
|
||||
const response = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "next-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(response.statusCode, 409)
|
||||
assert.match(response.body, options.password ? /provided via CLI\/env/ : /authentication is disabled/)
|
||||
assert.match(String(response.headers["content-type"]), /^text\/plain/)
|
||||
}
|
||||
if (options.password) assert.equal(await f.manager.validateLogin(username, password), true)
|
||||
if (options.dangerouslySkipAuth) assert.equal(await f.manager.validateLogin("any", "anything"), true)
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps bootstrap password changes ordered and recovers the queue after a persistence failure", async () => {
|
||||
const f = await fixture({ persist: false, generateToken: true })
|
||||
try {
|
||||
assert.equal(await f.manager.validateLogin(username, password), false)
|
||||
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
|
||||
const authPath = path.join(f.directory, "auth.json")
|
||||
await fs.mkdir(authPath)
|
||||
const session = f.manager.createSession(username)
|
||||
const failed = await f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", payload: { password: "failed-password" },
|
||||
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
|
||||
})
|
||||
assert.equal(failed.statusCode, 409)
|
||||
assert.match(String(failed.headers["content-type"]), /^text\/plain/)
|
||||
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
|
||||
await fs.rmdir(authPath)
|
||||
const changes = ["first-password", "second-password", "final-password"]
|
||||
await Promise.all(changes.map((next) => f.manager.setPassword(next)))
|
||||
assert.equal(await f.manager.validateLogin(username, changes[0]), false)
|
||||
assert.equal(await f.manager.validateLogin(username, changes[2]), true)
|
||||
const persisted = JSON.parse(await fs.readFile(authPath, "utf8"))
|
||||
assert.equal(persisted.username, username)
|
||||
assert.equal(persisted.userProvided, true)
|
||||
// Independent synchronous verification proves persisted format compatibility.
|
||||
const record = persisted.password
|
||||
assert.equal(crypto.scryptSync(changes[2], Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
|
||||
.toString("base64"), record.hashBase64)
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("services a witness endpoint during password derivation and awaits persistence", async (t) => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
const session = f.manager.createSession(username)
|
||||
const cookie = `${f.manager.getCookieName()}=${session.id}`
|
||||
const state = { pending: 0, completed: 0, maxCallMs: 0 }
|
||||
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
|
||||
const setPassword = f.manager.setPassword.bind(f.manager)
|
||||
let probe: Promise<{ pending: number; completed: number }> | undefined
|
||||
let probeMs = 0
|
||||
f.manager.setPassword = (...args) => {
|
||||
if (!probe) {
|
||||
const start = performance.now()
|
||||
probe = new Promise((resolve, reject) => setImmediate(() => {
|
||||
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
|
||||
probeMs = performance.now() - start
|
||||
resolve(response.json())
|
||||
}, reject)
|
||||
}))
|
||||
}
|
||||
return observeWork(() => setPassword(...args), state)
|
||||
}
|
||||
const start = performance.now()
|
||||
const responses = await Promise.all(Array.from({ length: 4 }, (_, index) => f.app.inject({
|
||||
method: "POST", url: "/api/auth/password", headers: { cookie },
|
||||
payload: { password: `new-password-${index}` },
|
||||
})))
|
||||
const witness = await probe!
|
||||
t.diagnostic(JSON.stringify({ operation: "password", totalMs: performance.now() - start, probeMs, ...state, witness }))
|
||||
for (const response of responses) {
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(response.json(), { ok: true, username, passwordUserProvided: true })
|
||||
}
|
||||
assert.equal(await f.manager.validateLogin(username, "new-password-3"), true)
|
||||
const persisted = JSON.parse(await fs.readFile(path.join(f.directory, "auth.json"), "utf8"))
|
||||
assert.equal(persisted.version, 1)
|
||||
assert.equal(persisted.password.algorithm, "scrypt")
|
||||
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive password changes finish")
|
||||
} finally {
|
||||
await f.close()
|
||||
}
|
||||
})
|
||||
|
|
@ -98,7 +98,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const body = LoginSchema.parse(request.body ?? {})
|
||||
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||
const ok = await deps.authManager.validateLogin(body.username, body.password)
|
||||
if (!ok) {
|
||||
reply.code(401).send({ error: "Invalid credentials" })
|
||||
return
|
||||
|
|
@ -147,7 +147,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
|
||||
const body = PasswordSchema.parse(request.body ?? {})
|
||||
try {
|
||||
const status = deps.authManager.setPassword(body.password)
|
||||
const status = await deps.authManager.setPassword(body.password)
|
||||
reply.send({ ok: true, ...status })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
|
|
|||
|
|
@ -267,10 +267,12 @@ export class WorkspaceManager {
|
|||
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory))
|
||||
}
|
||||
|
||||
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): Promise<boolean> {
|
||||
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): Promise<boolean> {
|
||||
signal?.throwIfAborted()
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published) return false
|
||||
const directory = await this.getServiceDirectoryForPath(id, location.directory)
|
||||
signal?.throwIfAborted()
|
||||
if (!directory) return false
|
||||
if (location.workspaceID === undefined) return true
|
||||
// The directory fence alone cannot authorize a legacy native workspace.
|
||||
|
|
@ -279,8 +281,8 @@ export class WorkspaceManager {
|
|||
try {
|
||||
const requested = readLocationRef({ ...location, directory })
|
||||
const resolved = readLocationRef(client
|
||||
? await client.location.get({ location: { directory } }, locationRequestOptions(requested))
|
||||
: await this.sharedService.validateLocation(requested, undefined, record[WORKSPACE_STATE].serviceOptions))
|
||||
? await client.location.get({ location: { directory } }, { ...locationRequestOptions(requested), signal })
|
||||
: await this.sharedService.validateLocation(requested, { signal }, record[WORKSPACE_STATE].serviceOptions))
|
||||
return sameLocation(requested, resolved)
|
||||
} catch {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export class OpenCodeSharedService {
|
|||
try {
|
||||
connection = await this.connect(serviceOptions, requestOptions?.deadlineAt)
|
||||
const nativeRequestOptions = requestOptions?.signal ? { signal: requestOptions.signal } : undefined
|
||||
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection)
|
||||
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection, requestOptions?.signal)
|
||||
} catch (error) {
|
||||
if (connection && !requestOptions?.signal?.aborted) this.invalidateConnection(connection)
|
||||
throw error
|
||||
|
|
@ -291,14 +291,28 @@ export class OpenCodeSharedService {
|
|||
}
|
||||
}
|
||||
|
||||
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection) {
|
||||
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection, signal?: AbortSignal) {
|
||||
const iterator = events[Symbol.asyncIterator]()
|
||||
try {
|
||||
for await (const event of events) {
|
||||
while (true) {
|
||||
let result: IteratorResult<OpenCodeEvent>
|
||||
try {
|
||||
result = await iterator.next()
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) this.invalidateConnection(connection)
|
||||
throw error
|
||||
}
|
||||
if (result.done) {
|
||||
if (!signal?.aborted) this.invalidateConnection(connection)
|
||||
return
|
||||
}
|
||||
connection.assertCurrent()
|
||||
yield event
|
||||
// Consumer return/throw and subscriber-local abort are not failures of
|
||||
// the shared source. Uncancelled iterator EOF/errors still invalidate.
|
||||
yield result.value
|
||||
}
|
||||
} finally {
|
||||
this.invalidateConnection(connection)
|
||||
await iterator.return?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
116
packages/server/src/workspaces/subscriber-lifetime.test.ts
Normal file
116
packages/server/src/workspaces/subscriber-lifetime.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test, type TestContext } from "node:test"
|
||||
import type { ServerResponse } from "node:http"
|
||||
import Fastify from "fastify"
|
||||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
import { OpenCodeSharedService } from "./opencode-service"
|
||||
|
||||
async function transport(t: TestContext) {
|
||||
const upstream = Fastify()
|
||||
const streams: ServerResponse[] = []
|
||||
upstream.get("/api/event", (_request, reply) => {
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.flushHeaders()
|
||||
reply.hijack()
|
||||
streams.push(reply.raw)
|
||||
reply.raw.write(`data: ${JSON.stringify({ id: "connected", created: Date.now(), type: "server.connected", data: {} })}\n\n`)
|
||||
})
|
||||
upstream.get("/api/info", async () => ({ version: "fixture" }))
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
|
||||
const service = new OpenCodeSharedService()
|
||||
// Only CLI lifecycle/upstream are fixtures. SDK sharing, cancellation, SSE
|
||||
// parsing and the CodeNomad connection fences remain real.
|
||||
const client = await service.client({ kind: "lifecycle", identity: "isolated-event-transport",
|
||||
lifecycle: { discover: async () => endpoint, ensure: async () => endpoint },
|
||||
})
|
||||
t.after(async () => {
|
||||
await service.shutdown()
|
||||
for (const stream of streams) stream.destroy()
|
||||
await upstream.close()
|
||||
})
|
||||
return { service, client, streams,
|
||||
send(event: OpenCodeEvent) { streams.at(-1)!.write(`data: ${JSON.stringify(event)}\n\n`) },
|
||||
}
|
||||
}
|
||||
|
||||
for (const mode of ["abort", "return", "throw"] as const) {
|
||||
test(`one subscriber's ${mode} cannot invalidate another subscriber or the healthy client`, async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const other = f.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await other.return?.() })
|
||||
assert.equal((await other.next()).value?.type, "server.connected")
|
||||
const local = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { local.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
assert.equal(f.streams.length, 1, "both consumers must share one physical HTTP stream")
|
||||
if (mode === "abort") {
|
||||
const waiting = wrapped.next()
|
||||
local.abort()
|
||||
assert.equal((await waiting).done, true)
|
||||
} else if (mode === "throw") {
|
||||
const failure = new Error("consumer failure")
|
||||
await assert.rejects(wrapped.throw!(failure), error => error === failure)
|
||||
} else await wrapped.return?.()
|
||||
assert.equal((await f.client.server.info()).version, "fixture")
|
||||
assert.equal((await f.service.acquire()).client, f.client)
|
||||
const next = other.next()
|
||||
const renamed = { id: "after-local-stop", created: Date.now(), type: "session.renamed" as const,
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 as const },
|
||||
data: { sessionID: "session", title: "still shared" },
|
||||
}
|
||||
f.send(renamed)
|
||||
assert.deepEqual((await next).value, renamed)
|
||||
})
|
||||
}
|
||||
|
||||
test("an already-aborted subscriber never opens a stream or invalidates the healthy connection", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
assert.equal((await wrapped.next()).done, true)
|
||||
assert.equal((await f.service.acquire()).client, f.client)
|
||||
assert.equal(f.streams.length, 0)
|
||||
})
|
||||
|
||||
test("genuine upstream EOF invalidates its connection but late cleanup cannot invalidate its replacement", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
const ended = wrapped.next()
|
||||
f.streams[0].end()
|
||||
assert.equal((await ended).done, true)
|
||||
const replacement = await f.service.acquire()
|
||||
assert.notEqual(replacement.client, f.client)
|
||||
await wrapped.return?.()
|
||||
assert.equal(await f.service.acquire(), replacement)
|
||||
})
|
||||
|
||||
test("abrupt upstream transport failure still invalidates its connection", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
const failed = wrapped.next()
|
||||
f.streams[0].destroy()
|
||||
await assert.rejects(failed)
|
||||
assert.notEqual((await f.service.acquire()).client, f.client)
|
||||
})
|
||||
|
||||
test("an obsolete iterator cannot invalidate its replacement connection or publish queued events", async t => {
|
||||
const f = await transport(t)
|
||||
const controller = new AbortController()
|
||||
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
|
||||
t.after(async () => { controller.abort(); await wrapped.return?.() })
|
||||
assert.equal((await wrapped.next()).value?.type, "server.connected")
|
||||
f.service.invalidate()
|
||||
const replacement = await f.service.acquire()
|
||||
await assert.rejects(wrapped.next())
|
||||
assert.equal(await f.service.acquire(), replacement)
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Copy } from "lucide-solid"
|
||||
import type { ToolState } from "../../../types/tool-state"
|
||||
|
|
@ -82,20 +82,23 @@ function TaskToolCallRow(props: {
|
|||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const rendered = createMemo(() => {
|
||||
const part = toolPart()
|
||||
if (!part) return null
|
||||
return props.renderToolCall({
|
||||
toolCall: part as any,
|
||||
messageId: messageId(),
|
||||
messageVersion: messageVersion(),
|
||||
partVersion: partVersion(),
|
||||
sessionId: props.sessionId,
|
||||
// Keep one shell for this key. Native page reprojections and unrelated text
|
||||
// revisions must update its props, not discard disclosure/scroll state.
|
||||
// Rows follow their tool identity, not their position in the bounded window.
|
||||
// A new identity still needs a fresh shell with its default disclosure.
|
||||
// Nonzero-arity Show callbacks mount untracked; getters update the child props.
|
||||
return <Show when={props.toolKey} keyed>{(_key) => (
|
||||
<Show when={toolPart()}>{(_part) => props.renderToolCall({
|
||||
get toolCall() { partVersion(); return toolPart()! },
|
||||
get messageId() { return messageId() },
|
||||
get messageVersion() { return messageVersion() },
|
||||
// Native page hydration resets the per-part revision to zero. Let
|
||||
// Markdown use its content hash instead of pinning changed output to 0;
|
||||
// the getter above still fences versioned in-place snapshot updates.
|
||||
get sessionId() { return props.sessionId },
|
||||
forceCollapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
return <>{rendered()}</>
|
||||
})}</Show>
|
||||
)}</Show>
|
||||
}
|
||||
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
|
|
@ -229,15 +232,9 @@ export const taskRenderer: ToolRenderer = {
|
|||
const [childToolsTruncated, setChildToolsTruncated] = createSignal(false)
|
||||
|
||||
let indexedSessionId = ""
|
||||
let indexedMessageCount = 0
|
||||
let indexedMessageTail = ""
|
||||
const indexedPartCounts = new Map<string, number>()
|
||||
|
||||
function resetChildToolIndex(nextSessionId: string) {
|
||||
indexedSessionId = nextSessionId
|
||||
indexedMessageCount = 0
|
||||
indexedMessageTail = ""
|
||||
indexedPartCounts.clear()
|
||||
setChildToolKeys([])
|
||||
setChildToolsTruncated(false)
|
||||
}
|
||||
|
|
@ -265,15 +262,11 @@ export const taskRenderer: ToolRenderer = {
|
|||
keys.unshift(`${messageId}::${partId}`)
|
||||
}
|
||||
if (idx >= oldestScannedIndex) setChildToolsTruncated(true)
|
||||
indexedPartCounts.set(messageId, partIds.length)
|
||||
return keys
|
||||
}
|
||||
|
||||
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
|
||||
indexedSessionId = sessionId
|
||||
indexedMessageCount = messageIds.length
|
||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||
indexedPartCounts.clear()
|
||||
setChildToolsTruncated(false)
|
||||
|
||||
const nextKeys: string[] = []
|
||||
|
|
@ -284,93 +277,33 @@ export const taskRenderer: ToolRenderer = {
|
|||
const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget)
|
||||
for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex])
|
||||
}
|
||||
for (let index = oldestScannedIndex; index < messageIds.length; index += 1) {
|
||||
const messageId = messageIds[index]
|
||||
if (!indexedPartCounts.has(messageId)) indexedPartCounts.set(messageId, store.getMessage(messageId)?.partIds.length ?? 0)
|
||||
}
|
||||
setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length)))
|
||||
setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT))
|
||||
const keys = nextKeys.slice(-TASK_STEP_RENDER_LIMIT)
|
||||
setChildToolKeys(previous => previous.length === keys.length && previous.every((key, index) => key === keys[index]) ? previous : keys)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const id = childSessionId()
|
||||
const loaded = childSessionLoaded()
|
||||
|
||||
if (!id || !loaded) {
|
||||
if (indexedSessionId) {
|
||||
resetChildToolIndex("")
|
||||
}
|
||||
if (!id || (indexedSessionId && indexedSessionId !== id)) {
|
||||
resetChildToolIndex("")
|
||||
}
|
||||
if (!id) return
|
||||
if (!loaded) {
|
||||
// Invalidation requests a fresh page, but its resident display snapshot
|
||||
// is still valid. Do not unmount every step while that read is pending.
|
||||
// Rebuild membership after the authoritative page arrives.
|
||||
return
|
||||
}
|
||||
|
||||
// We use the session revision as the reactive change point, but avoid
|
||||
// rescanning the entire session on every update.
|
||||
// Authoritative pages can remove, replace or reorder parts without changing
|
||||
// message counts. Revalidate only bounded structural identities (not output
|
||||
// payloads); unchanged keys retain their array and keyed tool shells.
|
||||
store.getSessionRevision(id)
|
||||
|
||||
untrack(() => {
|
||||
const messageIds = store.getSessionMessageIds(id)
|
||||
|
||||
if (!indexedSessionId || indexedSessionId !== id) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
|
||||
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
|
||||
if (messageIds.length < indexedMessageCount) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
if (indexedMessageCount > 0) {
|
||||
const expectedTailIndex = indexedMessageCount - 1
|
||||
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
|
||||
fullRescanChildTools(id, messageIds)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const appendedKeys: string[] = []
|
||||
const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT }
|
||||
|
||||
// Scan any new messages appended since last index.
|
||||
const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT)
|
||||
if (appendedStart > indexedMessageCount) setChildToolsTruncated(true)
|
||||
for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) {
|
||||
const messageId = messageIds[idx]
|
||||
appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget))
|
||||
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
|
||||
setChildToolsTruncated(true)
|
||||
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the bounded indexed window so out-of-order updates are not missed.
|
||||
const existingCount = Math.min(indexedMessageCount, messageIds.length)
|
||||
const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT)
|
||||
for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) {
|
||||
const messageId = messageIds[idx]
|
||||
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
|
||||
const record = store.getMessage(messageId)
|
||||
const nextPartCount = record?.partIds.length ?? 0
|
||||
if (nextPartCount > previousPartCount) {
|
||||
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget))
|
||||
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
|
||||
setChildToolsTruncated(true)
|
||||
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexedMessageCount = messageIds.length
|
||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||
if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) {
|
||||
const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT))
|
||||
for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId)
|
||||
}
|
||||
|
||||
if (appendedKeys.length > 0) {
|
||||
if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true)
|
||||
setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT))
|
||||
}
|
||||
fullRescanChildTools(id, store.getSessionMessageIds(id))
|
||||
})
|
||||
})
|
||||
const promptContent = createMemo(() => {
|
||||
|
|
@ -591,12 +524,12 @@ export const taskRenderer: ToolRenderer = {
|
|||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<Index each={childToolKeys()}>
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key()}
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
|
|
@ -604,7 +537,7 @@ export const taskRenderer: ToolRenderer = {
|
|||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Index>
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,23 @@ const logger = {
|
|||
}
|
||||
|
||||
describe("attachEventSourceHandlers", () => {
|
||||
it("separates malformed data from callback failures without reconnect or replay", () => {
|
||||
const source = new FakeEventSource()
|
||||
const errors: string[] = []
|
||||
const received: unknown[] = []
|
||||
let reconnects = 0
|
||||
attachEventSourceHandlers(source as unknown as EventSource, {
|
||||
onEvent(event) { received.push(event); throw new Error("consumer failed") },
|
||||
onError() { reconnects++ },
|
||||
logger: { warn() {}, error(message) { errors.push(message) } },
|
||||
})
|
||||
source.onmessage!(new MessageEvent("message", { data: "malformed" }))
|
||||
source.onmessage!(new MessageEvent("message", { data: '{"type":"fixture"}' }))
|
||||
assert.deepEqual(errors, ["Failed to parse event", "Failed to dispatch event"])
|
||||
assert.deepEqual(received, [{ type: "fixture" }])
|
||||
assert.equal(reconnects, 0)
|
||||
})
|
||||
|
||||
it("requests reconnect when EventSource emits close", () => {
|
||||
const source = new FakeEventSource()
|
||||
let reconnects = 0
|
||||
|
|
|
|||
|
|
@ -20,11 +20,19 @@ export function attachEventSourceHandlers(source: EventSource, options: EventSou
|
|||
let disconnected = false
|
||||
|
||||
source.onmessage = (event) => {
|
||||
let payload: WorkspaceEventPayload
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
options.onEvent(payload)
|
||||
payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
} catch (error) {
|
||||
options.logger.error("Failed to parse event", error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
options.onEvent(payload)
|
||||
} catch (error) {
|
||||
// Deferred reactive flush errors occur after individual subscribers have
|
||||
// returned. They are application failures, not malformed transport data.
|
||||
options.logger.error("Failed to dispatch event", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class ServerEvents {
|
|||
}
|
||||
logSse("Events stream connected")
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
this.openHandlers.forEach((handler) => handler())
|
||||
this.notify(this.openHandlers, undefined, "open")
|
||||
},
|
||||
onPing: (payload) => {
|
||||
if (generation !== this.connectGeneration) {
|
||||
|
|
@ -149,8 +149,20 @@ class ServerEvents {
|
|||
}
|
||||
|
||||
private dispatch(event: WorkspaceEventPayload) {
|
||||
this.handlers.get("*")?.forEach((handler) => handler(event))
|
||||
this.handlers.get(event.type)?.forEach((handler) => handler(event))
|
||||
this.notify(this.handlers.get("*"), event, event.type)
|
||||
this.notify(this.handlers.get(event.type), event, event.type)
|
||||
}
|
||||
|
||||
private notify<T>(handlers: Set<(value: T) => void> | undefined, value: T, kind: string): void {
|
||||
handlers?.forEach((handler) => {
|
||||
try {
|
||||
handler(value)
|
||||
} catch (error) {
|
||||
// A feature failure must not discard another feature's native event or
|
||||
// interrupt the connection lifecycle. Do not replay failed handlers.
|
||||
log.error("Backend event subscriber failed", { kind, error })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private dispatchBatch(events: WorkspaceEventPayload[]) {
|
||||
|
|
@ -167,7 +179,7 @@ class ServerEvents {
|
|||
}
|
||||
|
||||
private emitTransportStatus(status: WorkspaceEventTransportStatus) {
|
||||
this.statusHandlers.forEach((handler) => handler(status))
|
||||
this.notify(this.statusHandlers, status, "transport-status")
|
||||
}
|
||||
|
||||
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
|
||||
|
|
|
|||
56
packages/ui/src/stores/compaction-delta-buffer.ts
Normal file
56
packages/ui/src/stores/compaction-delta-buffer.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { OpenCodeEvent } from "@opencode/client"
|
||||
|
||||
type CompactionDelta = Extract<OpenCodeEvent, { type: "session.compaction.delta" }>
|
||||
const INTERVAL_MS = 250
|
||||
|
||||
// Only payload aggregation lives here. Revision/read authority remains with the
|
||||
// native reducer owner, which cancels or flushes this buffer at its boundaries.
|
||||
export function createCompactionDeltaBuffer<Context>(reduce: (event: CompactionDelta, context: Context) => void) {
|
||||
const pending = new Map<string, {
|
||||
event: CompactionDelta
|
||||
context: Context
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}>()
|
||||
const keyFor = (instanceId: string, sessionId: string) => `${instanceId}\0${sessionId}`
|
||||
|
||||
const flush = (instanceId: string, sessionId: string) => {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (!item) return
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
reduce(item.event, item.context)
|
||||
}
|
||||
return {
|
||||
flush,
|
||||
push(instanceId: string, sessionId: string, event: CompactionDelta, context: Context) {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (item) {
|
||||
item.event = { ...event, data: { ...event.data, text: item.event.data.text + event.data.text } }
|
||||
item.context = context
|
||||
return
|
||||
}
|
||||
pending.set(key, {
|
||||
event,
|
||||
context,
|
||||
timeout: setTimeout(() => flush(instanceId, sessionId), INTERVAL_MS),
|
||||
})
|
||||
},
|
||||
cancel(instanceId: string, sessionId: string) {
|
||||
const key = keyFor(instanceId, sessionId)
|
||||
const item = pending.get(key)
|
||||
if (!item) return
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
},
|
||||
clear(instanceId: string) {
|
||||
const prefix = `${instanceId}\0`
|
||||
for (const [key, item] of pending) {
|
||||
if (!key.startsWith(prefix)) continue
|
||||
clearTimeout(item.timeout)
|
||||
pending.delete(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -139,51 +139,6 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
|||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
const COMPACTION_PROJECTION_INTERVAL_MS = 250
|
||||
const pendingCompactionProjections = new Map<string, {
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
project: () => void
|
||||
}>()
|
||||
|
||||
function compactionProjectionKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}\0${sessionId}`
|
||||
}
|
||||
|
||||
function scheduleCompactionProjection(instanceId: string, sessionId: string, project: () => void): void {
|
||||
const key = compactionProjectionKey(instanceId, sessionId)
|
||||
const pending = pendingCompactionProjections.get(key)
|
||||
if (pending) {
|
||||
pending.project = project
|
||||
return
|
||||
}
|
||||
|
||||
const next = {
|
||||
project,
|
||||
timeout: setTimeout(() => {
|
||||
pendingCompactionProjections.delete(key)
|
||||
next.project()
|
||||
}, COMPACTION_PROJECTION_INTERVAL_MS),
|
||||
}
|
||||
pendingCompactionProjections.set(key, next)
|
||||
}
|
||||
|
||||
function cancelCompactionProjection(instanceId: string, sessionId: string): void {
|
||||
const key = compactionProjectionKey(instanceId, sessionId)
|
||||
const pending = pendingCompactionProjections.get(key)
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timeout)
|
||||
pendingCompactionProjections.delete(key)
|
||||
}
|
||||
|
||||
function clearCompactionProjections(instanceId: string): void {
|
||||
const prefix = `${instanceId}\0`
|
||||
for (const [key, pending] of pendingCompactionProjections) {
|
||||
if (!key.startsWith(prefix)) continue
|
||||
clearTimeout(pending.timeout)
|
||||
pendingCompactionProjections.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Interruption queues per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequest[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
|
|
@ -1218,7 +1173,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
|||
clearSessionCatalogState(id)
|
||||
clearCommands(id)
|
||||
clearInstanceMetadata(id)
|
||||
clearCompactionProjections(id)
|
||||
destroyOpenCodeData(id)
|
||||
volatileInstanceRefreshes.delete(id)
|
||||
for (const sessionId of sessions().get(id)?.keys() ?? []) invalidateSessionMessageLoad(id, sessionId)
|
||||
}
|
||||
|
|
@ -1282,7 +1237,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) {
|
|||
clearSettledForms(id)
|
||||
clearPendingFormQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
clearCompactionProjections(id)
|
||||
destroyOpenCodeData(id)
|
||||
clearPermissionAutoAcceptForInstance(id)
|
||||
clearSyncedYoloSessionsForInstance(id)
|
||||
initialHydrations.delete(id)
|
||||
|
|
@ -1953,10 +1908,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
? event.data.form.sessionID
|
||||
: undefined
|
||||
const isCompactionDelta = event.type === "session.compaction.delta"
|
||||
if (event.type === "server.connected") clearCompactionProjections(instanceId)
|
||||
if (sessionId && (event.type === "session.compaction.ended" || event.type === "session.compaction.failed")) {
|
||||
cancelCompactionProjection(instanceId, sessionId)
|
||||
}
|
||||
const projectMessages = (data: ReturnType<typeof applyOpenCodeDataEvent>, preserveOmitted = true, force = false) => {
|
||||
if (sessionId && event.type.startsWith("session.")
|
||||
&& activeSessionId().get(instanceId) !== sessionId
|
||||
|
|
@ -1964,7 +1915,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
invalidateSessionMessageLoad(instanceId, sessionId)
|
||||
}
|
||||
if (isCompactionDelta && !force) {
|
||||
if (sessionId) scheduleCompactionProjection(instanceId, sessionId, () => projectMessages(data, preserveOmitted, true))
|
||||
return
|
||||
}
|
||||
if (sessionId && (force || event.type.startsWith("session.")) && (
|
||||
|
|
@ -1992,10 +1942,14 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
}
|
||||
}
|
||||
}
|
||||
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, project, (next) => {
|
||||
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, (next) => {
|
||||
if (isCompactionDelta) projectMessages(next, true, true)
|
||||
else project(next)
|
||||
}, (next) => {
|
||||
projectMessages(next, false, true)
|
||||
schedulePendingRequestReconciliation(instanceId)
|
||||
})
|
||||
}, Boolean(sessionId && activeSessionId().get(instanceId) !== sessionId
|
||||
&& !messageStoreBus.getInstance(instanceId)?.getSessionMessageIds(sessionId).length))
|
||||
project(data)
|
||||
if (sessionId && (event.type === "permission.asked" || event.type === "permission.replied")) {
|
||||
const current = data.session.permission.list(sessionId) ?? []
|
||||
|
|
|
|||
171
packages/ui/src/stores/opencode-compaction.test.ts
Normal file
171
packages/ui/src/stores/opencode-compaction.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
import { sseManager } from "../lib/sse-manager.ts"
|
||||
import {
|
||||
applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent,
|
||||
getOpenCodeMessageRevision, invalidateOpenCodeSessionContent,
|
||||
} from "./opencode-data.ts"
|
||||
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
function fixture(instanceId: string) {
|
||||
let sequence = 0, projections = 0
|
||||
const client: any = {
|
||||
session: { active: async () => ({}), get: async () => ({ id: "s" }),
|
||||
inbox: { list: async () => [] }, form: { list: async () => [] } },
|
||||
permission: { list: async () => [] },
|
||||
message: { list: async () => ({ data: [], cursor: {} }) },
|
||||
}
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
let data!: ReturnType<typeof applyOpenCodeDataEvent>
|
||||
const emit = (type: string, payload: any = {}) => {
|
||||
data = applyOpenCodeDataEvent(instanceId, "/fixture", {
|
||||
type, id: `event-${++sequence}`, created: sequence,
|
||||
data: { sessionID: "s", ...payload }, location: { directory: "/fixture" },
|
||||
} as any, () => { projections++ })
|
||||
return data
|
||||
}
|
||||
return {
|
||||
emit, client, projections: () => projections,
|
||||
data: () => data,
|
||||
start: (id = "compact") => emit("session.compaction.started", { inputID: id, reason: "manual", recent: "" }),
|
||||
delta: (text: string) => emit("session.compaction.delta", { text }),
|
||||
summary: (id = "compact") => (data.session.message.get("s", id) as any)?.summary,
|
||||
cleanup: () => { destroyOpenCodeData(instanceId); sdkManager.destroyClientsForInstance(instanceId) },
|
||||
}
|
||||
}
|
||||
|
||||
test("compaction chunks are fenced immediately, joined once, and preserve same-session order", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const f = fixture("compaction-order")
|
||||
try {
|
||||
f.start()
|
||||
const revision = getOpenCodeMessageRevision("compaction-order", "s")
|
||||
for (let index = 0; index < 128; index++) f.delta(String(index))
|
||||
assert.equal(getOpenCodeMessageRevision("compaction-order", "s"), revision + 128)
|
||||
assert.equal(f.summary(), "")
|
||||
assert.equal(f.projections(), 0)
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(f.summary(), Array.from({ length: 128 }, (_, i) => String(i)).join(""))
|
||||
assert.equal(f.projections(), 1)
|
||||
f.delta("prefix")
|
||||
f.start("second")
|
||||
assert(f.summary()!.endsWith("prefix"), "second start flushes preceding running compaction")
|
||||
f.delta("second text")
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(f.summary("second"), "second text")
|
||||
assert.equal(f.projections(), 3)
|
||||
f.delta("pending")
|
||||
f.emit("session.compaction.ended", { reason: "manual", text: "final", recent: "" })
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.summary("second"), "final")
|
||||
assert.equal(f.projections(), 3, "ended supersedes buffered fragments without replay")
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
||||
for (const boundary of ["failed", "delete", "reconnect", "prune", "revert", "dispose"] as const) {
|
||||
test(`pending compaction text cannot survive ${boundary}`, ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const instanceId = `compaction-${boundary}`
|
||||
const f = fixture(instanceId)
|
||||
try {
|
||||
f.start()
|
||||
f.delta("must not reappear")
|
||||
if (boundary === "failed") f.emit("session.compaction.failed", { reason: "manual", error: { type: "fixture", message: "failed" } })
|
||||
if (boundary === "delete") f.emit("session.deleted")
|
||||
if (boundary === "reconnect") f.emit("server.connected")
|
||||
if (boundary === "prune") invalidateOpenCodeSessionContent(instanceId, "s")
|
||||
if (boundary === "revert") f.emit("session.revert.committed", { to: "compact" })
|
||||
if (boundary === "dispose") destroyOpenCodeData(instanceId)
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.projections(), 0)
|
||||
if (boundary === "revert" || boundary === "delete") assert.equal(f.summary(), undefined)
|
||||
f.start("fresh")
|
||||
f.delta("new")
|
||||
ctx.mock.timers.tick(250)
|
||||
if (boundary !== "prune") assert.equal(f.summary("fresh"), "new")
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
}
|
||||
|
||||
test("idle flushes pending compaction before retiring its reducer", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const f = fixture("compaction-idle")
|
||||
try {
|
||||
f.start()
|
||||
f.delta("last text")
|
||||
f.emit("session.idle")
|
||||
assert.equal(f.summary(), "last text")
|
||||
finishOpenCodeDataEvent("compaction-idle", { type: "session.idle", data: { sessionID: "s" } } as any)
|
||||
ctx.mock.timers.tick(500)
|
||||
assert.equal(f.projections(), 1)
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
||||
test("same session IDs in distinct instances keep separate buffers and flush authority", ctx => {
|
||||
ctx.mock.timers.enable({ apis: ["setTimeout"] })
|
||||
const first = fixture("compaction-instance-first"), second = fixture("compaction-instance-second")
|
||||
try {
|
||||
first.start(); second.start()
|
||||
first.delta("A"); second.delta("B")
|
||||
first.emit("session.usage.updated", { cost: 0, tokens: {} })
|
||||
assert.equal(first.summary(), "A")
|
||||
assert.equal(second.summary(), "")
|
||||
ctx.mock.timers.tick(250)
|
||||
assert.equal(second.summary(), "B")
|
||||
assert.equal(first.projections(), 1)
|
||||
assert.equal(second.projections(), 1)
|
||||
} finally { first.cleanup(); second.cleanup() }
|
||||
})
|
||||
|
||||
test("SDK history reads flush pending chunks and reject a pre-delta page without duplication", async () => {
|
||||
const instanceId = "compaction-native-read"
|
||||
const previous = sseManager.getStatuses
|
||||
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
|
||||
const f = fixture(instanceId)
|
||||
const replies: Array<(value: any) => void> = []
|
||||
const page = (summary: string) => ({ data: [{ id: "compact", type: "compaction", status: "running",
|
||||
reason: "manual", summary, recent: "", model, time: { created: 1 } }], cursor: {} })
|
||||
f.client.message.list = () => new Promise(resolve => replies.push(resolve))
|
||||
try {
|
||||
const data = f.start()
|
||||
const read = data.session.message.sync("s")
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(replies.length, 1)
|
||||
f.delta("A")
|
||||
replies[0](page(""))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(replies.length, 2, "admitted buffered delta fences stale SDK read")
|
||||
replies[1](page("A"))
|
||||
await read
|
||||
assert.equal(f.summary(), "A", "authoritative page must not double-append buffered text")
|
||||
f.delta("B")
|
||||
data.session.message.invalidate("s")
|
||||
const nextRead = data.session.message.sync("s")
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(f.summary(), "AB", "pending text is reduced before a new native read")
|
||||
replies[2](page("AB"))
|
||||
await nextRead
|
||||
assert.equal(f.summary(), "AB")
|
||||
assert.equal(f.projections(), 2)
|
||||
} finally { f.cleanup(); sseManager.getStatuses = previous }
|
||||
})
|
||||
|
||||
test("rotation at the 200-message boundary retains exactly one running compaction and its pending chunks", async () => {
|
||||
const f = fixture("compaction-rotation")
|
||||
try {
|
||||
for (let index = 0; index < 200; index++) {
|
||||
f.emit("session.step.started", { assistantMessageID: `m${index}`, agent: "build", model })
|
||||
f.emit("session.step.ended", { assistantMessageID: `m${index}`, finish: "stop" })
|
||||
}
|
||||
f.start()
|
||||
f.delta("A"); f.delta("B")
|
||||
// Same-session terminal delivery forces native order while the start is
|
||||
// still queued behind the bounded SDK snapshot rotation.
|
||||
f.emit("session.usage.updated", { cost: 0, tokens: {} })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(f.summary(), "AB")
|
||||
assert.equal(f.data().session.message.list("s").length, 200)
|
||||
assert.equal(f.data().session.message.list("s").filter(message => message.type === "compaction").length, 1)
|
||||
} finally { f.cleanup() }
|
||||
})
|
||||
|
|
@ -8,12 +8,14 @@ import { MESSAGE_WINDOW_PAGE_SIZE } from "./message-v2/message-window"
|
|||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { createCompactionDeltaBuffer } from "./compaction-delta-buffer"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type DataEntry = {
|
||||
data: Data
|
||||
emit: (event: OpenCodeEvent) => void
|
||||
fencePendingReads: (sessionId: string) => void
|
||||
syncMessages: (sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) => Promise<boolean>
|
||||
syncAuthoritative: (sessionId: string, isCurrent: () => boolean) => Promise<boolean>
|
||||
dispose: () => void
|
||||
|
|
@ -57,6 +59,18 @@ const messageRevisions = new Map<string, number>()
|
|||
const fullDataRevisions = new Map<string, number>()
|
||||
const instanceGenerations = new Map<string, number>()
|
||||
const instanceDataRevisions = new Map<string, ReturnType<typeof createSignal<number>>>()
|
||||
const unobservedCompactions = new Set<string>()
|
||||
type CompactionDeltaContext = {
|
||||
instanceId: string
|
||||
directory: string
|
||||
onDeferred?: (data: Data) => void
|
||||
onResynced?: (data: Data) => void
|
||||
}
|
||||
const coalescedCompactionEvents = new WeakSet<object>()
|
||||
const compactionDeltas = createCompactionDeltaBuffer<CompactionDeltaContext>((event, context) => {
|
||||
coalescedCompactionEvents.add(event)
|
||||
applyOpenCodeDataEvent(context.instanceId, context.directory, event, context.onDeferred, context.onResynced)
|
||||
})
|
||||
let nextInstanceGeneration = 0
|
||||
|
||||
function messageRevisionKey(instanceId: string, sessionId: string): string {
|
||||
|
|
@ -130,6 +144,10 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
|
||||
throw new Error("Stale read from disposed OpenCode projection")
|
||||
}
|
||||
// A current native page can already contain buffered text.
|
||||
// Reduce it before taking read authority, never append it
|
||||
// again to a page that was fetched after delta admission.
|
||||
compactionDeltas.flush(instanceId, input.sessionID)
|
||||
const snapshot = messageSnapshots.get(input.sessionID)
|
||||
if (snapshot) return { data: [...snapshot].reverse(), cursor: {} }
|
||||
const revision = eventRevisions.get(input.sessionID) ?? 0
|
||||
|
|
@ -137,6 +155,7 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
|
||||
throw new Error("Stale read from disposed OpenCode projection")
|
||||
}
|
||||
compactionDeltas.flush(instanceId, input.sessionID)
|
||||
if ((eventRevisions.get(input.sessionID) ?? 0) !== revision) continue
|
||||
return response
|
||||
}
|
||||
|
|
@ -167,6 +186,9 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
|
|||
emit(details: OpenCodeEvent) {
|
||||
emit(details)
|
||||
},
|
||||
fencePendingReads(sessionId: string) {
|
||||
eventRevisions.set(sessionId, (eventRevisions.get(sessionId) ?? 0) + 1)
|
||||
},
|
||||
async syncMessages(sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) {
|
||||
messageSnapshots.set(sessionId, messages)
|
||||
try {
|
||||
|
|
@ -573,6 +595,7 @@ export function applyOpenCodeDataEvent(
|
|||
event: OpenCodeEvent,
|
||||
onDeferred?: (data: Data) => void,
|
||||
onResynced?: (data: Data) => void,
|
||||
skipUnobservedCompaction = false,
|
||||
): Data {
|
||||
if (event.type === "server.connected") destroyOpenCodeData(instanceId)
|
||||
const primary = ensureData(instanceId, directory)
|
||||
|
|
@ -581,6 +604,20 @@ export function applyOpenCodeDataEvent(
|
|||
// with sync:false, feeding these events to createData starts HTTP refreshes.
|
||||
if (!/^(session|permission|form)\./.test(event.type)) return primary.data
|
||||
const sessionId = eventSessionId(event)
|
||||
const coalesced = coalescedCompactionEvents.delete(event)
|
||||
if (typeof sessionId === "string" && !coalesced) {
|
||||
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed"
|
||||
|| event.type === "session.deleted" || event.type === "session.revert.committed") {
|
||||
// Terminal events replace summary text. Destructive events must not let
|
||||
// a delayed fragment resurrect a removed or invalidated compaction.
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
} else if (event.type !== "session.compaction.delta") {
|
||||
// A second start or another same-session event observes preceding text
|
||||
// in native order; unrelated sessions never force a flush.
|
||||
compactionDeltas.flush(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
if (event.type === "session.deleted" && typeof sessionId === "string") {
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
const transcript = transcriptEntries.get(key)
|
||||
|
|
@ -595,24 +632,49 @@ export function applyOpenCodeDataEvent(
|
|||
fullDataRevisions.delete(key)
|
||||
return transcript?.entry.data ?? primary.data
|
||||
}
|
||||
if (typeof sessionId === "string" && !coalesced) {
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
|
||||
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
|
||||
if (skipUnobservedCompaction && event.type.startsWith("session.compaction.") && !transcriptEntries.has(key)) {
|
||||
// No native reducer/visible transcript is observing this session. The
|
||||
// next activation loads authoritative messages instead of storing deltas.
|
||||
if (event.type === "session.compaction.started") unobservedCompactions.add(key)
|
||||
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed") unobservedCompactions.delete(key)
|
||||
return primary.data
|
||||
}
|
||||
}
|
||||
const transcript = typeof sessionId === "string"
|
||||
? ensureTranscript(instanceId, sessionId, directory)
|
||||
: undefined
|
||||
const entry = transcript?.entry ?? primary
|
||||
if (transcript && typeof sessionId === "string") {
|
||||
if (event.type === "session.compaction.started") unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
// A new execution must not inherit a preceding idle event's deferred cleanup.
|
||||
if (event.type === "session.execution.started" || eventMayAppendMessage(event)) transcript.retireWhenDrained = false
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
|
||||
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
|
||||
}
|
||||
if (transcript && typeof sessionId === "string") {
|
||||
if (onResynced) transcript.onResynced = onResynced
|
||||
if (event.type === "session.compaction.delta" && unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))) {
|
||||
// Activation during a previously unobserved compaction has no start row
|
||||
// in the SDK reducer. Recover its exact native summary instead of making
|
||||
// up a start or appending a delta to a REST page that already includes it.
|
||||
transcript.preserveNativePageOnResync = true
|
||||
collapseTranscriptQueue(instanceId, sessionId, transcript)
|
||||
return transcript.entry.data
|
||||
}
|
||||
if (transcript.needsAuthoritativeResync || transcript.resyncing) {
|
||||
collapseTranscriptQueue(instanceId, sessionId, transcript)
|
||||
return transcript.entry.data
|
||||
}
|
||||
if (event.type === "session.compaction.delta" && !coalesced) {
|
||||
// Admission fences both CodeNomad and SDK reads immediately, even though
|
||||
// the reactive payload is reduced only once per interval.
|
||||
entry.fencePendingReads(sessionId)
|
||||
compactionDeltas.push(instanceId, sessionId, event, { instanceId, directory, onDeferred, onResynced })
|
||||
return entry.data
|
||||
}
|
||||
if (transcript.rotating
|
||||
|| (eventMayAppendMessage(event, transcript.entry.data.session.message.list(sessionId)
|
||||
.filter((message) => message.type === "compaction" && message.status === "running").length)
|
||||
|
|
@ -623,6 +685,7 @@ export function applyOpenCodeDataEvent(
|
|||
}
|
||||
}
|
||||
entry.emit(event)
|
||||
if (coalesced) onDeferred?.(entry.data)
|
||||
return entry.data
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +694,8 @@ export function getOpenCodeMessageRevision(instanceId: string, sessionId: string
|
|||
}
|
||||
|
||||
export function invalidateOpenCodeSessionContent(instanceId: string, sessionId: string): void {
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
bumpMutationRevision(key)
|
||||
messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
|
||||
|
|
@ -720,6 +785,8 @@ export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent
|
|||
|
||||
export function destroyOpenCodeData(instanceId: string, sessionId?: string): void {
|
||||
if (sessionId !== undefined) {
|
||||
compactionDeltas.cancel(instanceId, sessionId)
|
||||
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
|
||||
const key = messageRevisionKey(instanceId, sessionId)
|
||||
const transcript = transcriptEntries.get(key)
|
||||
if (transcript) {
|
||||
|
|
@ -734,6 +801,10 @@ export function destroyOpenCodeData(instanceId: string, sessionId?: string): voi
|
|||
instanceDataRevision(instanceId)[1]((current) => current + 1)
|
||||
return
|
||||
}
|
||||
compactionDeltas.clear(instanceId)
|
||||
for (const key of unobservedCompactions) {
|
||||
if (key.startsWith(`${instanceId}\0`)) unobservedCompactions.delete(key)
|
||||
}
|
||||
instanceGenerations.set(instanceId, ++nextInstanceGeneration)
|
||||
entries.get(instanceId)?.dispose()
|
||||
entries.delete(instanceId)
|
||||
|
|
|
|||
145
packages/ui/tests/browser/compaction-responsiveness.test.ts
Normal file
145
packages/ui/tests/browser/compaction-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), {
|
||||
name: "compaction-counts",
|
||||
enforce: "pre",
|
||||
async transform(source, id) {
|
||||
if (id.endsWith("/stores/instances.ts") && process.env.CODENOMAD_COMPACTION_BASELINE) {
|
||||
return readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/instances.ts`, "utf8")
|
||||
}
|
||||
if (!id.endsWith("/stores/opencode-data.ts")) return
|
||||
if (process.env.CODENOMAD_COMPACTION_BASELINE) source = await readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/opencode-data.ts`, "utf8")
|
||||
// Observe the actual native reducer boundary, not a reimplemented reducer.
|
||||
return source.replace("for (const listener of listeners)", `
|
||||
if (details.type === "session.compaction.delta") {
|
||||
const counts = (window as any).compactionCounts?.deltas
|
||||
if (counts) counts[details.data.sessionID] = (counts[details.data.sessionID] ?? 0) + 1
|
||||
}
|
||||
for (const listener of listeners)`)
|
||||
.replace("const source = data.session.message.list(sessionId)", `
|
||||
const counts = (window as any).compactionCounts?.projections
|
||||
if (counts) counts[sessionId] = (counts[sessionId] ?? 0) + 1
|
||||
const source = data.session.message.list(sessionId)`)
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use("/compaction", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await vite.transformIndexHtml("/compaction", '<html><body><div id="root" style="display:flex;width:1100px;height:740px"></div><script type="module" src="/tests/browser/fixtures/compaction-responsiveness.tsx"></script></body></html>'))
|
||||
})
|
||||
},
|
||||
}], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/compaction`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function fixture(run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.route("**/*", route => {
|
||||
const request = new URL(route.request().url())
|
||||
if (request.hostname !== "127.0.0.1") return route.abort()
|
||||
if (request.pathname.startsWith("/api/")) return route.fulfill({ json: {} })
|
||||
return route.continue()
|
||||
})
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
await page.waitForFunction(() => Boolean((window as any).compactionFixture), undefined, { timeout: 30_000 })
|
||||
await page.waitForTimeout(300)
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
const snapshot = (page: Page) => page.evaluate(() => (window as any).compactionFixture.snapshot())
|
||||
const joined = (count: number) => Array.from({ length: count }, (_, index) => `[${index}]`).join("")
|
||||
|
||||
test("128 active deltas reduce and project once, preserve immediate revision, and end authoritatively", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
|
||||
const before = await snapshot(page)
|
||||
const dispatchMs = await page.evaluate(() => (window as any).compactionFixture.burst("active"))
|
||||
const pending = await snapshot(page)
|
||||
console.log(JSON.stringify({ scenario: "active128-ingress", dispatchMs, counts: pending.counts }))
|
||||
assert.equal(pending.revision - before.revision, 128)
|
||||
assert.equal(pending.counts.deltas.active ?? 0, 0)
|
||||
assert.equal(pending.counts.projections.active ?? 0, 0)
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active?.length > 0)
|
||||
const flushed = await snapshot(page)
|
||||
assert.equal(flushed.active, joined(128))
|
||||
assert.equal(flushed.counts.deltas.active, 1)
|
||||
assert.equal(flushed.counts.projections.active, 1)
|
||||
assert.equal(flushed.messageLists, 0)
|
||||
console.log(JSON.stringify({ scenario: "active128", dispatchMs, counts: flushed.counts }))
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.burst("active", 4); f.end("active", "authoritative final") })
|
||||
await page.waitForTimeout(300)
|
||||
const final = await snapshot(page)
|
||||
assert.equal(final.active, "authoritative final")
|
||||
assert.equal(final.counts.deltas.active, 1, "terminal final text cancels pending fragments")
|
||||
}))
|
||||
|
||||
test("spaced native events coalesce per interval without losing chunks or affecting the other session", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
|
||||
for (let phase = 1; phase <= 2; phase++) {
|
||||
await page.evaluate(() => (window as any).compactionFixture.phase("active"))
|
||||
await page.waitForFunction(phase => (window as any).compactionFixture.snapshot().active ===
|
||||
Array.from({ length: 8 }, (_, index) => `[${index}]`).join("").repeat(phase), phase)
|
||||
const state = await snapshot(page)
|
||||
assert.equal(state.counts.deltas.active, phase)
|
||||
assert.equal(state.counts.projections.active, phase)
|
||||
assert.equal(state.messageLists, 0)
|
||||
}
|
||||
}))
|
||||
|
||||
test("never-loaded inactive compaction allocates no reducer payload; activation and remount hydrate exact final text", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.start("inactive"); f.burst("inactive"); f.end("inactive", "saved summary") })
|
||||
await page.waitForTimeout(300)
|
||||
const inactive = await snapshot(page)
|
||||
assert.deepEqual(inactive.counts, { deltas: {}, projections: {} })
|
||||
assert.equal(inactive.inactive, undefined)
|
||||
assert.equal(inactive.messageLists, 0)
|
||||
await page.evaluate(() => (window as any).compactionFixture.activate("inactive"))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
|
||||
await page.evaluate(() => (window as any).compactionFixture.unmount())
|
||||
await page.evaluate(() => (window as any).compactionFixture.remount())
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
|
||||
assert.equal((await snapshot(page)).active, undefined)
|
||||
}))
|
||||
|
||||
test("activation during an unobserved compaction hydrates then resumes exact streaming summary", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.seed("inactive", 199); f.start("inactive"); f.burst("inactive", 4); f.activate("inactive") })
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3]")
|
||||
await page.evaluate(() => (window as any).compactionFixture.burst("inactive", 2))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1]")
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.burst("inactive", 2) })
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1][0][1]")
|
||||
const resumed = await snapshot(page)
|
||||
assert.equal(resumed.counts.deltas.inactive, 1)
|
||||
assert.equal(resumed.counts.projections.inactive, 1)
|
||||
assert.equal(resumed.messageLists, 0)
|
||||
assert.equal(resumed.ids.length, 200, "SDK's 20-row recovery must retain the hydrated native 200-row window")
|
||||
}))
|
||||
|
||||
test("a loaded but inactive reducer batches text, does not hydrate its hidden view, and returns without duplication", () => fixture(async page => {
|
||||
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.activate("inactive"); f.resetCounts(); f.burst("active", 4) })
|
||||
await page.waitForTimeout(300)
|
||||
const hidden = await snapshot(page)
|
||||
assert.equal(hidden.counts.deltas.active, 1)
|
||||
assert.equal(hidden.counts.projections.active ?? 0, 0)
|
||||
assert.equal(hidden.active, "", "hidden visible snapshot is revalidated, not hydrated by every delta")
|
||||
await page.evaluate(() => (window as any).compactionFixture.activate("active"))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3]")
|
||||
await page.evaluate(() => (window as any).compactionFixture.burst("active", 2))
|
||||
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3][0][1]")
|
||||
}))
|
||||
140
packages/ui/tests/browser/event-subscriber-isolation.test.ts
Normal file
140
packages/ui/tests/browser/event-subscriber-isolation.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import Fastify, { type FastifyInstance } from "fastify"
|
||||
import pino from "pino"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { EventBus } from "../../../server/src/events/bus"
|
||||
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
|
||||
import { registerEventRoutes } from "../../../server/src/server/routes/events"
|
||||
|
||||
let backend: FastifyInstance, server: ViteDevServer, browser: Browser, connections: ClientConnectionManager, url: string
|
||||
const bus = new EventBus()
|
||||
const clients = new Set<() => void>()
|
||||
before(async () => {
|
||||
const logger = pino({ level: "silent" })
|
||||
// Send a body frame immediately so the Vite proxy forwards idle stream headers.
|
||||
bus.publish({ type: "instance.eventStatus", instanceId: "fixture", status: "connected", generation: 1 })
|
||||
connections = new ClientConnectionManager(logger)
|
||||
backend = Fastify()
|
||||
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
|
||||
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
|
||||
})
|
||||
backend.get("/api/auth/status", async () => ({ authenticated: true }))
|
||||
await backend.listen({ host: "127.0.0.1", port: 0 })
|
||||
const target = `http://127.0.0.1:${(backend.server.address() as { port: number }).port}`
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "event-subscriber-isolation", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (_request, response) => {
|
||||
response.setHeader("Content-Type", "text/html")
|
||||
response.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/event-subscriber-isolation.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { "/api": { target } } },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
connections?.shutdown()
|
||||
for (const close of clients) close()
|
||||
await server?.close()
|
||||
await backend?.close()
|
||||
})
|
||||
|
||||
function emit(id: string) {
|
||||
bus.publish({ type: "instance.event", instanceId: "fixture", event: {
|
||||
id, created: Date.now(), type: "session.renamed", data: { sessionID: "session", title: id },
|
||||
durable: { aggregateID: "session", seq: 1, version: 1 }, location: { directory: "/fixture" },
|
||||
} })
|
||||
}
|
||||
async function setup(page: Page, mode: string) {
|
||||
const pageErrors: string[] = []
|
||||
page.on("pageerror", error => pageErrors.push(error.message))
|
||||
await page.goto(`${url}?mode=${mode}`)
|
||||
await page.locator("[data-events]").waitFor()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().statuses.includes("connected"))
|
||||
return pageErrors
|
||||
}
|
||||
|
||||
test("a failing wildcard subscriber cannot discard a valid SSE event before native and typed subscribers", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "event")
|
||||
emit("first")
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"], "real SSEManager must receive both events despite another subscriber's error")
|
||||
assert.deepEqual(state.typed, ["first", "second"])
|
||||
assert.equal(state.errors.length, 2, "each failed callback is diagnosed, without replaying the event")
|
||||
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a deferred Solid derivation error is diagnosed as dispatch, not malformed SSE, without replay", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "derived")
|
||||
emit("first")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().errors.length === 1)
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"])
|
||||
assert.deepEqual(state.typed, ["first", "second"])
|
||||
assert.equal(state.errors[0][0], "Failed to dispatch event")
|
||||
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a failing open subscriber cannot suppress later reconnect reconciliation subscribers", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "open")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 1)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 1)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null)
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 2)
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("a failing status subscriber cannot abort restart or prevent later transport-status notifications", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "status")
|
||||
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null, "restart must still close and replace its connection")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.ok(state.statuses.includes("disconnected"))
|
||||
emit("after-restart")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 1)
|
||||
assert.deepEqual(await page.evaluate(() => (window as any).fixture.snapshot().native), ["after-restart"])
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("normal subscribers retain SSE FIFO and unsubscribe removes only its own listener", async () => {
|
||||
const page = await browser.newPage()
|
||||
try {
|
||||
const pageErrors = await setup(page, "normal")
|
||||
emit("first")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().typed.length === 1)
|
||||
await page.evaluate(() => (window as any).fixture.unsubscribe())
|
||||
emit("second")
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(state.native, ["first", "second"])
|
||||
assert.deepEqual(state.typed, ["first"])
|
||||
assert.deepEqual(state.errors, [])
|
||||
assert.deepEqual(pageErrors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
103
packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx
Normal file
103
packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import SessionView from "../../../src/components/session/session-view"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { sessions, setSessions, setActiveSession, setProviders } from "../../../src/stores/session-state"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { getOpenCodeMessageRevision } from "../../../src/stores/opencode-data"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const instanceId = "compaction-fixture", model = { providerID: "fixture", id: "fixture" }
|
||||
let sequence = 1000, messageLists = 0
|
||||
const nativeMessages = new Map<string, any[]>()
|
||||
const [selected, select] = createSignal("active")
|
||||
const [visible, show] = createSignal(true)
|
||||
const client: any = {
|
||||
session: {
|
||||
active: async () => ({}), inbox: { list: async () => [] },
|
||||
form: { list: async () => [] },
|
||||
get: async ({ sessionID }: any) => ({ id: sessionID, title: sessionID,
|
||||
location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
|
||||
instructions: { entry: { remove: async () => {}, put: async () => {} } },
|
||||
},
|
||||
model: { default: async () => ({ data: model }) },
|
||||
permission: { list: async () => [] },
|
||||
message: { list: async ({ sessionID, limit = 200 }: any) => {
|
||||
messageLists++
|
||||
return { data: structuredClone((nativeMessages.get(sessionID) ?? []).slice(-limit)).reverse(), cursor: {} }
|
||||
} },
|
||||
}
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setSessions(previous => new Map(previous).set(instanceId, new Map(["active", "inactive"].map(id => [id, {
|
||||
id, instanceId, parentId: null, title: id, agent: "build", model: { providerId: "fixture", modelId: "fixture" },
|
||||
status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
|
||||
version: "1", projectID: "fixture", location: { directory: "/fixture" }, cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
|
||||
}]))))
|
||||
setProviders(previous => new Map(previous).set(instanceId, [{ id: "fixture", name: "Fixture", models: [
|
||||
{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } },
|
||||
] }]))
|
||||
setActiveSession(instanceId, "active")
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}>
|
||||
<SessionView sessionId={selected()} activeSessions={sessions().get(instanceId)!} instanceId={instanceId}
|
||||
instanceFolder="/fixture" escapeInDebounce={false} isActive={true} />
|
||||
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
|
||||
const emit = (sessionID: string, type: string, data: any = {}) => {
|
||||
if (type === "session.compaction.started") nativeMessages.set(sessionID, [...(nativeMessages.get(sessionID) ?? []), { id: `compact-${sessionID}`,
|
||||
type: "compaction", status: "running", reason: "manual", summary: "", recent: "", time: { created: sequence } }])
|
||||
if (type === "session.compaction.delta") nativeMessages.get(sessionID)!.at(-1).summary += data.text
|
||||
;(sseManager as any).handleEvent(instanceId, {
|
||||
id: `event_${++sequence}`, type, created: sequence, location: { directory: "/fixture" }, data: { sessionID, ...data },
|
||||
})
|
||||
}
|
||||
const text = (sessionID: string) => {
|
||||
const message = store.getMessage(`compact-${sessionID}`)
|
||||
return message?.partIds.map(id => (message.parts[id].data as any).text).join("")
|
||||
}
|
||||
;(window as any).compactionFixture = {
|
||||
resetCounts: () => { (window as any).compactionCounts = { deltas: {}, projections: {} }; messageLists = 0 },
|
||||
seed: (sessionID: string, count: number) => nativeMessages.set(sessionID, Array.from({ length: count }, (_, index) => ({
|
||||
id: `history-${sessionID}-${String(index).padStart(4, "0")}`, type: "user", text: `History ${index}`, time: { created: index },
|
||||
}))),
|
||||
snapshot: () => ({ counts: (window as any).compactionCounts, messageLists,
|
||||
active: text("active"), inactive: text("inactive"), ids: store.getSessionMessageIds(selected()),
|
||||
revision: getOpenCodeMessageRevision(instanceId, selected()) }),
|
||||
start: (sessionID = "active") => {
|
||||
emit(sessionID, "session.compaction.started", { inputID: `compact-${sessionID}`, reason: "manual", recent: "" })
|
||||
},
|
||||
burst: (sessionID: string, count = 128) => {
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < count; index++) emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
|
||||
return performance.now() - started
|
||||
},
|
||||
phase: async (sessionID: string) => {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
},
|
||||
end: (sessionID: string, summary: string) => {
|
||||
nativeMessages.set(sessionID, [{ id: `compact-${sessionID}`, type: "compaction", status: "completed",
|
||||
reason: "manual", summary, recent: "", model, time: { created: 1 } }])
|
||||
emit(sessionID, "session.compaction.ended", { reason: "manual", text: summary, recent: "", model })
|
||||
},
|
||||
fail: (sessionID: string) => emit(sessionID, "session.compaction.failed", {
|
||||
inputID: `compact-${sessionID}`, reason: "manual", error: { type: "fixture", message: "cancelled" },
|
||||
}),
|
||||
activate: (sessionID: string) => {
|
||||
show(false)
|
||||
select(sessionID)
|
||||
setActiveSession(instanceId, sessionID)
|
||||
show(true)
|
||||
},
|
||||
unmount: () => show(false),
|
||||
remount: () => show(true),
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { createMemo, createRoot, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { getLogger } from "../../../src/lib/logger"
|
||||
|
||||
const mode = new URLSearchParams(location.search).get("mode")
|
||||
const native: string[] = [], typed: string[] = [], statuses: string[] = [], errors: unknown[][] = []
|
||||
let faults = 0, opens = 0
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const changed = () => setVersion(value => value + 1)
|
||||
const fail = () => { faults++; changed(); throw new Error("synthetic subscriber failure") }
|
||||
// Replace diagnostics only; dispatch, transport, native reducer entry and the
|
||||
// browser's EventSource are production implementations.
|
||||
getLogger("sse").error = (...args) => { errors.push(args) }
|
||||
if (mode === "event") serverEvents.on("*", event => { if (event.type === "instance.event") fail() })
|
||||
if (mode === "derived") createRoot(() => {
|
||||
const [broken, setBroken] = createSignal(false)
|
||||
createMemo(() => { if (broken()) throw new Error("synthetic deferred derivation failure") })
|
||||
serverEvents.on("*", event => {
|
||||
if (event.type !== "instance.event") return
|
||||
faults++
|
||||
changed()
|
||||
setBroken(true)
|
||||
})
|
||||
})
|
||||
if (mode === "open") serverEvents.onOpen(fail)
|
||||
if (mode === "status") serverEvents.onTransportStatus(status => { if (status === "disconnected") fail() })
|
||||
sseManager.onNativeSessionEvent = (_id, event) => { native.push(event.id); changed() }
|
||||
const unsubscribe = serverEvents.on("instance.event", event => {
|
||||
if (event.type === "instance.event") typed.push(event.event.id)
|
||||
changed()
|
||||
})
|
||||
serverEvents.onOpen(() => { opens++; changed() })
|
||||
serverEvents.onTransportStatus(status => { statuses.push(status); changed() })
|
||||
;(window as any).fixture = {
|
||||
snapshot: () => ({ native, typed, statuses, faults, opens, errors }),
|
||||
restart: () => {
|
||||
try { serverEvents.restart("isolated subscriber fixture"); return null }
|
||||
catch (error) { return error instanceof Error ? error.message : String(error) }
|
||||
},
|
||||
unsubscribe,
|
||||
}
|
||||
render(() => <pre data-events>{version()}{JSON.stringify({ native, typed, statuses, faults, opens })}</pre>, document.getElementById("root")!)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { OpenCode } from "@opencode/client"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createInstanceFetch } from "../../../src/lib/sdk-manager"
|
||||
import { serverEvents } from "../../../src/lib/server-events"
|
||||
|
||||
// Deliberately small transport fixture, not a replacement session store/UI.
|
||||
// All API requests and SSE use the real production browser adapters.
|
||||
const baseUrl = `${location.origin}/workspaces/workspace/instance/`
|
||||
const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) })
|
||||
const [selected, setSelected] = createSignal("A")
|
||||
const [loaded, setLoaded] = createSignal("")
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [opens, setOpens] = createSignal(0)
|
||||
const [events, setEvents] = createSignal(0)
|
||||
const [pending, setPending] = createSignal(false)
|
||||
const errors: string[] = []
|
||||
let catalogueSettled = 0
|
||||
serverEvents.onOpen(() => setOpens(value => value + 1))
|
||||
serverEvents.on("instance.eventStatus", () => setEvents(value => value + 1))
|
||||
|
||||
const fixture = {
|
||||
start(action: "prompt" | "compact") {
|
||||
setPending(true)
|
||||
const operation = action === "prompt"
|
||||
? client.session.prompt({ sessionID: "A", text: "private transport fixture" })
|
||||
: client.session.compact({ sessionID: "A" })
|
||||
void operation.catch(error => errors.push(String(error))).finally(() => setPending(false))
|
||||
},
|
||||
catalogues() {
|
||||
// Five distinct catalogue intents, not foreground session reads: production
|
||||
// createInstanceFetch must admit only two and leave the other three queued.
|
||||
// Distinct URLs avoid Chromium's same-URL cache request coalescing.
|
||||
for (let index = 0; index < 5; index++) {
|
||||
void client.agent.get({ agentID: `fixture-${index}` }).catch(error => errors.push(String(error))).finally(() => catalogueSettled++)
|
||||
}
|
||||
},
|
||||
async info() { return (await fetch("/api/meta")).json() },
|
||||
async witness() { return (await fetch("/api/witness")).json() },
|
||||
snapshot() { return { selected: selected(), loaded: loaded(), draft: draft(), opens: opens(), events: events(), pending: pending(), catalogueSettled, errors } },
|
||||
}
|
||||
;(window as any).fixture = fixture
|
||||
|
||||
render(() => <main>
|
||||
<input aria-label="Draft" value={draft()} onInput={event => setDraft(event.currentTarget.value)} />
|
||||
<button onClick={() => {
|
||||
setSelected("B")
|
||||
void client.session.get({ sessionID: "B" }).then(session => setLoaded(session.id)).catch(error => errors.push(String(error)))
|
||||
}}>Switch to B</button>
|
||||
<output data-selected>{selected()}</output>
|
||||
<output data-loaded>{loaded()}</output>
|
||||
</main>, document.getElementById("root")!)
|
||||
44
packages/ui/tests/browser/fixtures/render-cost-timeline.tsx
Normal file
44
packages/ui/tests/browser/fixtures/render-cost-timeline.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import MessageTimeline, { type TimelineSegment } from "../../../src/components/message-timeline"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { serverApi } from "../../../src/lib/api-client"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const count = Math.min(50_000, Math.max(10, Number(new URLSearchParams(location.search).get("count") ?? 10_000)))
|
||||
const segments: TimelineSegment[] = Array.from({ length: count }, (_, index) => ({
|
||||
id: `marker-${index}`, messageId: `message-${Math.floor(index / 3)}`, type: index % 3 === 0 ? "user" : index % 3 === 1 ? "tool" : "assistant",
|
||||
label: String(index), tooltip: `Marker ${index}`, toolName: index % 3 === 1 ? "read" : undefined,
|
||||
toolPartIds: index % 3 === 1 ? [`tool-${index}`] : undefined, totalChars: 0,
|
||||
}))
|
||||
// Seed one resident preview; all other excerpts are deterministic HTTP-boundary
|
||||
// answers, never transcript loads. This also exercises Markdown in the tooltip.
|
||||
const store = messageStoreBus.getOrCreate("render-timeline")
|
||||
store.upsertMessage({ id: "message-0", sessionId: "timeline", role: "user", status: "complete", createdAt: 1,
|
||||
parts: [{ id: "preview-text", type: "text", text: "**Resident preview**\n\n" + "bounded excerpt ".repeat(1000) }] })
|
||||
const requests: number[] = []
|
||||
serverApi.fetchOutlinePreviews = async (_instance, _session, ids) => {
|
||||
requests.push(ids.length)
|
||||
return { status: "previews", entries: ids.map(id => ({ id, text: "**Local excerpt**", tools: "Read tool excerpt" })) } as any
|
||||
}
|
||||
const [active, setActive] = createSignal("marker-0")
|
||||
let selected = "", activeCounter = 0
|
||||
await applyUiSettings({ locale: "en" })
|
||||
const start = performance.now()
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider><div class="message-timeline-sidebar" style={{ height: "600px", width: "60px" }}>
|
||||
<MessageTimeline instanceId="render-timeline" sessionId="timeline" segments={segments}
|
||||
activeSegmentId={active()} onSegmentClick={segment => { selected = segment.id }} />
|
||||
</div></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
const mountMs = performance.now() - start
|
||||
;(window as any).fixture = {
|
||||
measure: (updates: number) => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < updates; index++) setActive(`marker-${++activeCounter % 15}`)
|
||||
return performance.now() - start
|
||||
},
|
||||
snapshot: () => ({ count, selected, active: active(), requests, mountMs }),
|
||||
}
|
||||
157
packages/ui/tests/browser/fixtures/render-cost.tsx
Normal file
157
packages/ui/tests/browser/fixtures/render-cost.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { batch } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import MessageSection from "../../../src/components/message-section"
|
||||
import { applyUiSettings } from "./ui-settings"
|
||||
import { sdkManager } from "../../../src/lib/sdk-manager"
|
||||
import { addInstance } from "../../../src/stores/instances"
|
||||
import { setSessions, setActiveSession } from "../../../src/stores/session-state"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { loadMessages } from "../../../src/stores/session-api"
|
||||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
const mode = params.get("mode") ?? "task"
|
||||
const count = Math.min(1000, Math.max(1, Number(params.get("count") ?? 12)))
|
||||
const outputSize = Math.min(2_000_000, Math.max(1000, Number(params.get("size") ?? 8000)))
|
||||
const instanceId = "render-cost", parentId = "parent", childId = "child"
|
||||
const model = { providerID: "fixture", id: "fixture" }
|
||||
const output = Array.from({ length: Math.ceil(outputSize / "Output 00000 with deterministic text\n".length) }, (_, index) => `Output ${String(index).padStart(5, "0")} with deterministic text\n`).join("").slice(0, outputSize)
|
||||
const tool = (index: number) => ({ id: `step-${index}`, type: "tool", name: "read",
|
||||
time: { created: index + 1, completed: index + 2 },
|
||||
state: { status: "completed", input: { filePath: `file-${index}.txt` }, metadata: {}, content: [{ type: "text", text: output }] } })
|
||||
const history: Record<string, any[]> = mode === "task" ? {
|
||||
[parentId]: [{ id: "parent-message", type: "assistant", agent: "build", model, time: { created: 1, completed: 2 },
|
||||
content: [{ id: "parent-task", type: "tool", name: "subagent", time: { created: 1, completed: 2 },
|
||||
state: { status: "completed", input: { agent: "explore", description: "Deterministic child" }, metadata: { sessionId: childId }, content: [{ type: "text", text: "Task result" }] } }] }],
|
||||
[childId]: [{ id: "child-message", type: "assistant", agent: "build", model, time: { created: 3 },
|
||||
content: Array.from({ length: count }, (_, index) => tool(index)) }],
|
||||
} : {
|
||||
[parentId]: Array.from({ length: count }, (_, index) => ({ id: `history-${index}`, type: "assistant", agent: "build", model,
|
||||
time: { created: index + 1, completed: index + 2 }, content: [{ type: "text", text: `History ${index}\n\n` + "A deterministic paragraph.\n\n".repeat(2 + index % 5) }] })),
|
||||
}
|
||||
const requests: string[] = []
|
||||
let holdChildReads = false
|
||||
let failChildRead = false
|
||||
const heldReads: Array<() => void> = []
|
||||
const client: any = { session: {
|
||||
active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
|
||||
get: async ({ sessionID }: any) => ({ id: sessionID, location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
|
||||
}, message: { list: async ({ sessionID, limit = 200, cursor }: any) => {
|
||||
requests.push(sessionID)
|
||||
if (sessionID === childId && holdChildReads) await new Promise<void>(resolve => heldReads.push(resolve))
|
||||
if (sessionID === childId && failChildRead) { failChildRead = false; throw new Error("Deterministic read failure") }
|
||||
const messages = history[sessionID] ?? []
|
||||
const end = cursor ? Number(cursor) : messages.length, start = Math.max(0, end - limit)
|
||||
return { data: messages.slice(start, end).reverse(), cursor: start ? { next: String(start) } : {} }
|
||||
} } }
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
|
||||
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setSessions(previous => new Map(previous).set(instanceId, new Map(Object.keys(history).map(id => [id, {
|
||||
id, instanceId, parentId: id === parentId ? null : parentId, title: id, location: { directory: "/fixture" },
|
||||
status: "idle", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, time: { created: 1, updated: 1 },
|
||||
} as any]))))
|
||||
setActiveSession(instanceId, parentId)
|
||||
await applyUiSettings({ locale: "en", showMessageTimeline: mode !== "task", toolInputsVisibility: "hidden",
|
||||
toolCallExpansionDefaults: { preset: "custom", thinking: "collapsed", tools: { task: "expanded", read: "collapsed" } } })
|
||||
for (const id of Object.keys(history)) await loadMessages(instanceId, id, { force: true })
|
||||
|
||||
// Instrument a native operation, not a replacement renderer/store. Only ToolCall
|
||||
// snapshot inputs are counted; timings below also work with instrumentation off.
|
||||
let snapshots = 0, snapshotMs = 0
|
||||
const instrumented = params.get("instrument") !== "off"
|
||||
const clone = window.structuredClone.bind(window)
|
||||
if (instrumented) window.structuredClone = ((value: any, options?: StructuredSerializeOptions) => {
|
||||
const matched = value?.type === "tool" && value.id?.startsWith("step-")
|
||||
const start = performance.now(), result = clone(value, options)
|
||||
if (matched) { snapshots++; snapshotMs += performance.now() - start }
|
||||
return result
|
||||
}) as typeof structuredClone
|
||||
const clipboard: string[] = []
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text: string) => { clipboard.push(text) } } })
|
||||
let eventId = 100
|
||||
const emit = (type: string, data: any) => (sseManager as any).handleEvent(instanceId, {
|
||||
id: `event-${++eventId}`, type, created: eventId, location: { directory: "/fixture" },
|
||||
data: { sessionID: childId, assistantMessageID: "child-message", ...data },
|
||||
})
|
||||
const startText = () => {
|
||||
if (!history[childId][0].content.some((part: any) => part.type === "text")) history[childId][0].content.push({ type: "text", text: "" })
|
||||
emit("session.text.started", {})
|
||||
}
|
||||
const delta = () => {
|
||||
const text = history[childId][0].content.find((part: any) => part.type === "text")
|
||||
text.text += " x"
|
||||
emit("session.text.delta", { ordinal: count, delta: " x" })
|
||||
}
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
||||
<main style={{ display: "flex", height: "700px", width: "1000px" }}>
|
||||
<MessageSection instanceId={instanceId} sessionId={parentId} isActive={true} />
|
||||
</main>
|
||||
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
let added = 0, removed = 0
|
||||
const countSteps = (nodes: NodeList) => Array.from(nodes).reduce((total, node) => total + (node instanceof Element
|
||||
? Number(node.matches('.tool-call[data-part-id^="step-"]')) + node.querySelectorAll('.tool-call[data-part-id^="step-"]').length : 0), 0)
|
||||
const recordMutations = (records: MutationRecord[]) => {
|
||||
for (const record of records) { added += countSteps(record.addedNodes); removed += countSteps(record.removedNodes) }
|
||||
}
|
||||
const observer = instrumented ? new MutationObserver(recordMutations) : undefined
|
||||
observer?.observe(document.querySelector("main")!, { childList: true, subtree: true })
|
||||
;(window as any).fixture = {
|
||||
startText,
|
||||
delta,
|
||||
hold: () => { holdChildReads = true },
|
||||
release: () => { holdChildReads = false; heldReads.splice(0).forEach(resolve => resolve()) },
|
||||
reproject: () => loadMessages(instanceId, childId, { force: true }),
|
||||
replaceChildTools: (indexes: number[], textIndexes: number[] = []) => {
|
||||
// Same native message identity, but a new authoritative part membership.
|
||||
history[childId][0].content = indexes.map(index => textIndexes.includes(index)
|
||||
? { id: `step-${index}`, type: "text", text: output }
|
||||
: tool(index))
|
||||
return loadMessages(instanceId, childId, { force: true })
|
||||
},
|
||||
failRead: () => { failChildRead = true; delta() },
|
||||
clearChild: () => { history[childId] = []; return loadMessages(instanceId, childId, { force: true }) },
|
||||
toolOutput: () => (store.getMessage("child-message")?.parts["step-0"]?.data as any)?.state?.output,
|
||||
mutateTool: () => batch(() => {
|
||||
// Deliberately keep data identity and message revision unchanged; only the
|
||||
// existing normalized part revision invalidates this in-place update.
|
||||
const set = store.setState as any
|
||||
set("messages", "child-message", "parts", "step-0", "data", "state", "output", "Versioned in-place output")
|
||||
set("messages", "child-message", "parts", "step-0", "revision", (revision: number) => revision + 1)
|
||||
}),
|
||||
toolUpdate: (index: number, text: string) => {
|
||||
history[childId][0].content[index].state.content = [{ type: "text", text }]
|
||||
emit("session.tool.success", { id: `step-${index}`, content: [{ type: "text", text }], metadata: {} })
|
||||
},
|
||||
failTool: (index: number) => {
|
||||
const part = history[childId][0].content.find((part: any) => part.id === `step-${index}`)
|
||||
part.state = { ...part.state, status: "error", error: { message: "Deterministic failure" } }
|
||||
emit("session.tool.failed", { id: part.id, error: part.state.error, metadata: {} })
|
||||
},
|
||||
removeTool: (index: number) => store.removeMessagePart("child-message", `step-${index}`, childId),
|
||||
appendTool: () => {
|
||||
history[childId][0].content.push({ ...tool(count), id: "appended-step", state: {
|
||||
status: "completed", input: { filePath: "new.txt" }, metadata: {}, content: [{ type: "text", text: "New step output" }],
|
||||
} })
|
||||
emit("session.tool.input.started", { id: "appended-step", name: "read" })
|
||||
emit("session.tool.called", { id: "appended-step", input: { filePath: "new.txt" } })
|
||||
emit("session.tool.success", { id: "appended-step", content: [{ type: "text", text: "New step output" }], metadata: {} })
|
||||
},
|
||||
reset: () => { observer?.takeRecords(); snapshots = 0; snapshotMs = 0; added = 0; removed = 0 },
|
||||
measure: (updates: number) => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < updates; index++) delta()
|
||||
const ms = performance.now() - start
|
||||
if (observer) recordMutations(observer.takeRecords())
|
||||
return { ms, snapshots, snapshotMs, added, removed }
|
||||
},
|
||||
snapshot: () => ({ snapshots, snapshotMs, added, removed, requests, clipboard, output, held: heldReads.length,
|
||||
partIds: store.getMessage("child-message")?.partIds,
|
||||
childRevision: store.getMessage("child-message")?.revision,
|
||||
loaded: store.getSessionMessageIds(parentId).length }),
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import MessagePart from "../../../src/components/message-part"
|
||||
import MessageTimeline from "../../../src/components/message-timeline"
|
||||
|
|
@ -13,6 +13,14 @@ const code = '<span title="<source>">&</span>'
|
|||
const body = ["**Markdown still works**", html, "`" + code + "`", "```html\n" + code + "\n```"].join("\n\n")
|
||||
const malformed = 'Please explain <code><img/src=x onerror="window.htmlExecuted=true"></code> literally\n\ntext <script>if (x<y) z()</script> after'
|
||||
const [literal, setLiteral] = createSignal(true)
|
||||
const traceEnabled = new URLSearchParams(location.search).has("trace")
|
||||
const trace: Array<Record<string, unknown>> = []
|
||||
const recordCache = (kind: string) => {
|
||||
const cache = document.querySelector("#cache")
|
||||
trace.push({ kind, literal: literal(), connected: cache?.isConnected ?? false,
|
||||
html: cache?.innerHTML, time: performance.now() })
|
||||
}
|
||||
if (traceEnabled) createEffect(() => { literal(); queueMicrotask(() => recordCache("literal-effect")) })
|
||||
const part = { id: "cached-part", type: "text" as const, text: html, version: 1,
|
||||
renderCache: { text: html, html: '<div class="cached-html">old sanitized HTML</div>', theme: "light", mode: "1:escaped:wrap" } }
|
||||
|
||||
|
|
@ -28,4 +36,22 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
|||
{ id: "assistant-preview", messageId: "assistant-preview", type: "assistant", label: "Assistant preview", tooltip: html, totalChars: html.length },
|
||||
]} /></div>
|
||||
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
;(window as any).fixture = { html, code, malformed, literal: setLiteral }
|
||||
if (traceEnabled) {
|
||||
const cache = document.querySelector("#cache")
|
||||
if (cache) new MutationObserver(() => recordCache("mutation")).observe(cache, { childList: true, subtree: true })
|
||||
recordCache("mounted")
|
||||
}
|
||||
const tracedSetLiteral = (value: boolean) => {
|
||||
if (traceEnabled) recordCache("setter-before")
|
||||
setLiteral(value)
|
||||
if (traceEnabled) queueMicrotask(() => recordCache("setter-microtask"))
|
||||
}
|
||||
;(window as any).fixture = { html, code, malformed,
|
||||
literal: traceEnabled ? tracedSetLiteral : setLiteral,
|
||||
snapshot: () => ({ literal: literal(), cache: document.querySelector("#cache")?.innerHTML,
|
||||
cachedHtml: document.querySelectorAll("#cache .cached-html").length,
|
||||
trace, renderCache: part.renderCache,
|
||||
resources: performance.getEntriesByType("resource").map(entry => entry.name)
|
||||
.filter(name => /(?:solid|markdown|user-html|\.js(?:\?|$))/.test(name)),
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,33 @@ import solid from "vite-plugin-solid"
|
|||
import { prepareGitPrototypeAssets } from "./fixtures/git-history-assets.mjs"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
const gitFocusBoundaryInitScript = String.raw`(() => {
|
||||
const trace = []
|
||||
window.gitFocusBoundaryTrace = trace
|
||||
const describeElement = target => target instanceof Element
|
||||
? { tag: target.tagName, id: target.id, className: target.className, partId: target.getAttribute("data-part-id") }
|
||||
: null
|
||||
const pushTrace = entry => {
|
||||
trace.push(entry)
|
||||
if (trace.length > 200) trace.shift()
|
||||
}
|
||||
for (const type of ["focusin", "focusout", "pointerdown", "click"]) {
|
||||
document.addEventListener(type, event => {
|
||||
pushTrace({ kind: type, target: describeElement(event.target), active: describeElement(document.activeElement), time: performance.now() })
|
||||
}, true)
|
||||
}
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
new MutationObserver(records => {
|
||||
for (const record of records) for (const node of record.removedNodes) {
|
||||
if (!(node instanceof Element)) continue
|
||||
if (!node.matches(".git-change-context-widget, .git-change-context-widget-host")
|
||||
&& !node.querySelector(".git-change-context-widget")) continue
|
||||
pushTrace({ kind: "widget-removed", active: describeElement(document.activeElement), time: performance.now() })
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true })
|
||||
}, { once: true })
|
||||
})()`
|
||||
|
||||
test("large Changes inventories keep the reader controls responsive", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 2000, height: 1120 } })
|
||||
try {
|
||||
|
|
@ -592,6 +619,7 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.stack ?? error.message))
|
||||
await page.route("**/api/**", route => route.fulfill({ json: {} }))
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) await page.addInitScript({ content: gitFocusBoundaryInitScript })
|
||||
try {
|
||||
await page.goto(`${url}?session=1`)
|
||||
const composer = page.locator(".session-view textarea.prompt-input")
|
||||
|
|
@ -606,6 +634,8 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
await insert.click()
|
||||
assert.match(await composer.inputValue(), /Conserver ce brouillon/)
|
||||
assert.match(await composer.inputValue(), /Git Diff: Worktree: \/CodeNomad : File: src\/components\/git-panel.tsx : 3-3/)
|
||||
const composerElement = (await composer.elementHandle())!
|
||||
await page.waitForFunction(element => element === document.activeElement, composerElement)
|
||||
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
|
||||
|
||||
// Native editor keyboard selection must keep the full range, not just the hovered line.
|
||||
|
|
@ -659,7 +689,25 @@ test("central diff inserts local lines and revision-qualified history into the r
|
|||
await page.evaluate(() => (window as any).fixture.switchSession("session"))
|
||||
assert.equal(await composer.inputValue(), draft)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
} finally {
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
|
||||
try {
|
||||
console.error("Git focus boundary trace", await page.evaluate(() => ({
|
||||
events: (window as any).gitFocusBoundaryTrace,
|
||||
active: document.activeElement instanceof Element
|
||||
? { tag: document.activeElement.tagName, id: document.activeElement.id, className: document.activeElement.className }
|
||||
: null,
|
||||
widgets: Array.from(document.querySelectorAll<HTMLElement>(".git-change-context-widget")).map(widget => ({
|
||||
connected: widget.isConnected, rect: widget.getBoundingClientRect().toJSON(),
|
||||
})),
|
||||
selections: (window as any).monaco?.editor?.getEditors?.().map((editor: any) => editor.getSelection?.()),
|
||||
})))
|
||||
} catch (error) {
|
||||
console.error("Git focus boundary trace unavailable", error)
|
||||
}
|
||||
}
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("Workspace keyboard focus survives lazy expansion and refresh, including RTL", async () => {
|
||||
|
|
|
|||
261
packages/ui/tests/browser/http-admission-responsiveness.test.ts
Normal file
261
packages/ui/tests/browser/http-admission-responsiveness.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import assert from "node:assert/strict"
|
||||
import type { IncomingMessage, ServerResponse } from "node:http"
|
||||
import type { Socket } from "node:net"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import Fastify from "fastify"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import pino from "pino"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { EventBus } from "../../../server/src/events/bus"
|
||||
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
|
||||
import { createRuntimeFetch } from "../../../server/src/opencode/compatibility/transport"
|
||||
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../../../server/src/server/http-server"
|
||||
import { registerEventRoutes } from "../../../server/src/server/routes/events"
|
||||
import { registerMetaRoutes } from "../../../server/src/server/routes/meta"
|
||||
import { WorktreeDeletionFence } from "../../../server/src/workspaces/worktree-session-evacuation"
|
||||
|
||||
let browser: Browser, assets: ViteDevServer, assetUrl: string
|
||||
before(async () => {
|
||||
assets = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)),
|
||||
logLevel: "error", plugins: [solid()], resolve: { dedupe: ["solid-js"] },
|
||||
server: { host: "127.0.0.1", port: 0, cors: true, hmr: false, watch: null },
|
||||
})
|
||||
await assets.listen()
|
||||
assetUrl = `http://127.0.0.1:${(assets.httpServer!.address() as { port: number }).port}`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await assets?.close() })
|
||||
|
||||
async function until(predicate: () => boolean, description: string) {
|
||||
// Only a deadlock safety bound, never a responsiveness/performance threshold.
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error(`Did not observe ${description}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
// Reuse the production proxy/admission/fetch/fence as in the server disconnect
|
||||
// diagnostic; use a real upstream SDK instead of copying its fake client tree.
|
||||
const held = Promise.withResolvers<void>()
|
||||
let released = false, nativeObservers = 0, nativeHttpA = 0, aborted = 0, heldHandlers = 0
|
||||
let forwardsA = 0, environmentWrites = 0, invalidations = 0, catalogueRequests = 0
|
||||
const nativeHttpCatalogues = new Set<ServerResponse>()
|
||||
const heldMutations = new Set<ServerResponse>()
|
||||
const captured: Array<{ request: IncomingMessage; response: ServerResponse; abortedListeners: number }> = []
|
||||
const clients = new Set<() => void>(), sockets = new Set<Socket>()
|
||||
const backendErrors: string[] = []
|
||||
const versions = new Set<string>(), bus = new EventBus(), fence = new WorktreeDeletionFence()
|
||||
const logger = pino({ level: "silent" }), connections = new ClientConnectionManager(logger)
|
||||
let peakSockets = 0
|
||||
const upstream = Fastify({ forceCloseConnections: true })
|
||||
upstream.get("/api/session/:id", async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id
|
||||
if (id === "A") {
|
||||
nativeHttpA++
|
||||
reply.raw.once("close", () => nativeHttpA--)
|
||||
heldHandlers++
|
||||
try { await held.promise } // intentionally ignore disconnect; exercise late completion
|
||||
finally { heldHandlers-- }
|
||||
}
|
||||
return { data: { id, location: { directory: "/fixture" } } }
|
||||
})
|
||||
upstream.get("/api/agent/:id", async (_request, reply) => {
|
||||
catalogueRequests++
|
||||
nativeHttpCatalogues.add(reply.raw)
|
||||
reply.raw.once("close", () => nativeHttpCatalogues.delete(reply.raw))
|
||||
heldHandlers++
|
||||
try { await held.promise }
|
||||
finally { heldHandlers-- }
|
||||
return []
|
||||
})
|
||||
upstream.get("/api/location", async () => ({ directory: "/fixture" }))
|
||||
upstream.post("/api/session/:id/:action", async () => { forwardsA++; return {} })
|
||||
upstream.put("/api/session/:id/environment", async (_request, reply) => { environmentWrites++; return reply.code(204).send() })
|
||||
await upstream.listen({ host: "127.0.0.1", port: 0 })
|
||||
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
|
||||
const runtimeFetch = createRuntimeFetch(endpoint)
|
||||
const native = OpenCode.make({ baseUrl: endpoint.url, fetch: runtimeFetch })
|
||||
const client = { ...native, session: { ...native.session, get: async (...args: Parameters<typeof native.session.get>) => {
|
||||
if (args[0].sessionID !== "A") return native.session.get(...args)
|
||||
nativeObservers++
|
||||
const signal = args[1]?.signal, onAbort = () => { aborted++ }
|
||||
signal?.addEventListener("abort", onAbort, { once: true })
|
||||
try { return await native.session.get(...args) }
|
||||
finally { nativeObservers--; signal?.removeEventListener("abort", onAbort) }
|
||||
} } }
|
||||
const connection = { endpoint, client, fetch: runtimeFetch, assertCurrent() {},
|
||||
invalidate() { invalidations++ }, profile: async () => "modern" as const }
|
||||
// Only workspace ownership is supplied here. Reads cross real SDK HTTP; no
|
||||
// daemon discovery, database, provider, filesystem or shared profile is used.
|
||||
const manager = {
|
||||
get: () => ({ id: "workspace", path: "/fixture" }),
|
||||
getSharedServiceConnection: async () => connection,
|
||||
getInstanceAuthorizationHeader: () => undefined,
|
||||
getServiceDirectory: () => "/fixture",
|
||||
getServiceDirectoryForPath: async () => "/fixture",
|
||||
getWorktreeIdentityForPath: async () => "/fixture",
|
||||
getSessionEnvironment: async () => ({}),
|
||||
ownsDirectory: async (_id: string, directory: string) => directory === "/fixture",
|
||||
ownsLocation: async (_id: string, location: { directory: string }, _client: unknown, signal?: AbortSignal) =>
|
||||
location.directory === "/fixture" && (await native.location.get({ location }, { signal })).directory === "/fixture",
|
||||
} as unknown as InstanceProxyWorkspaceManager
|
||||
const backend = Fastify({ forceCloseConnections: true })
|
||||
backend.addHook("onError", async (_request, _reply, error) => { backendErrors.push(error.stack ?? error.message) })
|
||||
backend.server.on("connection", socket => {
|
||||
sockets.add(socket); peakSockets = Math.max(peakSockets, sockets.size)
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
})
|
||||
backend.addHook("onRequest", async (request, reply) => {
|
||||
versions.add(request.raw.httpVersion)
|
||||
if (request.method === "POST" && /\/session\/A\/(prompt|compact)$/.test(request.url)) {
|
||||
captured.push({ request: request.raw, response: reply.raw, abortedListeners: request.raw.listenerCount("aborted") })
|
||||
heldMutations.add(reply.raw)
|
||||
reply.raw.once("close", () => { setImmediate(() => heldMutations.delete(reply.raw)) })
|
||||
}
|
||||
})
|
||||
await backend.register(replyFrom)
|
||||
registerInstanceProxyRoutes(backend, { workspaceManager: manager, logger, worktreeDeletionFence: fence })
|
||||
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
|
||||
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
|
||||
})
|
||||
backend.get("/api/auth/status", async () => ({ authenticated: true }))
|
||||
backend.get("/api/witness", async () => ({ witness: true }))
|
||||
// Business HTTP/SSE goes directly to Fastify, not through Vite's Node proxy.
|
||||
// Vite serves modules on a separate origin and cannot consume this HTTP budget.
|
||||
backend.get("/fixture", async (_request, reply) => reply.type("text/html").send(
|
||||
`<html><body><div id="root"></div><script type="module" src="${assetUrl}/tests/browser/fixtures/http-admission-responsiveness.tsx"></script></body></html>`,
|
||||
))
|
||||
const meta = { localUrl: "", eventsUrl: "/api/events", host: "127.0.0.1", listeningMode: "local" as const,
|
||||
localPort: 0, hostLabel: "private fixture", workspaceRoot: "/fixture", addresses: [] }
|
||||
registerMetaRoutes(backend, { serverMeta: meta })
|
||||
await backend.listen({ host: "127.0.0.1", port: 0 })
|
||||
meta.localPort = (backend.server.address() as { port: number }).port
|
||||
const url = meta.localUrl = `http://127.0.0.1:${meta.localPort}`
|
||||
return {
|
||||
url, captured, fence,
|
||||
emit() { bus.publish({ type: "instance.eventStatus", instanceId: "workspace", status: "connected", generation: 1 }) },
|
||||
release() { released = true; held.resolve() },
|
||||
snapshot() { return { released, nativeObservers, nativeHttpA, aborted, heldHandlers, forwardsA, environmentWrites, invalidations,
|
||||
catalogueRequests, nativeHttpCatalogues: nativeHttpCatalogues.size, heldMutations: heldMutations.size,
|
||||
sse: clients.size, sockets: sockets.size, peakSockets, versions: [...versions], backendErrors } },
|
||||
async dispose() {
|
||||
held.resolve(); connections.shutdown(); for (const close of clients) close()
|
||||
await backend.close(); await upstream.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(page: Page, h: Awaited<ReturnType<typeof harness>>) {
|
||||
const errors: string[] = [], protocols = new Map<string, string>()
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Network.enable")
|
||||
cdp.on("Network.responseReceived", ({ response }) => {
|
||||
if (response.url.startsWith(h.url)) protocols.set(new URL(response.url).pathname, response.protocol)
|
||||
})
|
||||
await page.goto(`${h.url}/fixture`)
|
||||
await page.getByRole("button", { name: "Switch to B" }).waitFor()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 1)
|
||||
await until(() => h.snapshot().sse === 1, "one real SSE subscription")
|
||||
const previousEvents = await page.evaluate(() => (window as any).fixture.snapshot().events)
|
||||
h.emit()
|
||||
await page.waitForFunction(previous => (window as any).fixture.snapshot().events > previous, previousEvents)
|
||||
return { errors, protocols }
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ action: "prompt", close: "page", background: false },
|
||||
{ action: "compact", close: "page", background: false },
|
||||
{ action: "prompt", close: "context", background: false },
|
||||
{ action: "compact", close: "context", background: false },
|
||||
{ action: "compact", close: "context", background: true },
|
||||
] as const) {
|
||||
test(`HTTP/1 SSE + held ${scenario.action}: ${scenario.close} reconnect${scenario.background ? " with separate backgroundReads(2) occupancy" : ""}`,
|
||||
{ timeout: 60_000 }, async t => {
|
||||
const h = await harness(), context = await browser.newContext()
|
||||
let reconnectContext = context
|
||||
try {
|
||||
const page = await context.newPage(), diagnostics = await setup(page, h)
|
||||
await page.evaluate(action => (window as any).fixture.start(action), scenario.action)
|
||||
await until(() => h.snapshot().nativeHttpA === 1, "actual SDK session A HTTP preflight")
|
||||
if (scenario.background) {
|
||||
await page.evaluate(() => (window as any).fixture.catalogues())
|
||||
await until(() => h.snapshot().catalogueRequests === 2, "two admitted background catalogue reads")
|
||||
}
|
||||
const started = performance.now()
|
||||
await page.getByRole("textbox", { name: "Draft" }).fill("draft while A is held")
|
||||
await page.getByRole("button", { name: "Switch to B" }).click()
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B" || (window as any).fixture.snapshot().errors.length > 0)
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).loaded, "B", JSON.stringify({ backend: h.snapshot(), browser: await page.evaluate(() => (window as any).fixture.snapshot()) }))
|
||||
const info = await page.evaluate(() => (window as any).fixture.info())
|
||||
const witness = await page.evaluate(() => (window as any).fixture.witness())
|
||||
assert.deepEqual(info.system, { platform: process.platform, arch: process.arch })
|
||||
assert.deepEqual(witness, { witness: true })
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.draft, "draft while A is held")
|
||||
assert.equal(state.selected, "B")
|
||||
assert.equal(state.pending, true)
|
||||
assert.deepEqual(state.errors, [])
|
||||
const during = h.snapshot()
|
||||
assert.equal(during.released, false)
|
||||
assert.equal(during.nativeObservers, 1)
|
||||
assert.equal(during.sse, 1)
|
||||
assert.equal(during.forwardsA, 0)
|
||||
assert.equal(during.heldHandlers, scenario.background ? 3 : 1)
|
||||
assert.deepEqual(during.backendErrors, [])
|
||||
if (scenario.background) {
|
||||
assert.equal(during.catalogueRequests, 2, "three catalogue intents must remain queued, not occupy HTTP sockets")
|
||||
assert.equal(during.nativeHttpCatalogues, 2)
|
||||
assert.equal(state.catalogueSettled, 0)
|
||||
}
|
||||
for (const path of ["/api/events", "/api/witness", "/api/meta", "/workspaces/workspace/instance/api/session/B"]) {
|
||||
assert.equal(diagnostics.protocols.get(path), "http/1.1", `${path} must use real Chromium HTTP/1.1`)
|
||||
}
|
||||
t.diagnostic(`browser=${browser.version()} platform=${process.platform}; held-progress=${Math.round(performance.now() - started)} ms; during=${JSON.stringify(during)}`)
|
||||
assert.deepEqual(diagnostics.errors, [])
|
||||
|
||||
const closing = performance.now()
|
||||
if (scenario.close === "page") await page.close()
|
||||
else { await context.close(); reconnectContext = await browser.newContext() }
|
||||
await until(() => {
|
||||
const state = h.snapshot()
|
||||
return state.sse === 0 && state.heldMutations === 0 && state.nativeHttpA === 0
|
||||
&& state.nativeObservers === 0 && state.nativeHttpCatalogues === 0
|
||||
}, "downstream/SSE/upstream observers retired before releasing the held handler")
|
||||
assert.equal(h.snapshot().aborted, 1)
|
||||
for (const entry of h.captured) {
|
||||
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
|
||||
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
|
||||
}
|
||||
await h.fence.run("/fixture", ["/fixture"], async () => {})
|
||||
assert.equal(h.snapshot().heldHandlers, scenario.background ? 3 : 1, "upstream handlers stay deliberately held after HTTP observers leave")
|
||||
t.diagnostic(`close-cleanup=${Math.round(performance.now() - closing)} ms; before-release=${JSON.stringify(h.snapshot())}; deletion fence drained`)
|
||||
|
||||
const next = await reconnectContext.newPage(), reconnected = await setup(next, h)
|
||||
await next.getByRole("button", { name: "Switch to B" }).click()
|
||||
await next.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B")
|
||||
assert.deepEqual(await next.evaluate(() => (window as any).fixture.witness()), { witness: true })
|
||||
assert.equal(h.snapshot().released, false, "new page must progress before the old upstream handler is released")
|
||||
assert.equal(h.snapshot().sse, 1)
|
||||
const beforeRelease = h.snapshot()
|
||||
h.release()
|
||||
await until(() => h.snapshot().heldHandlers === 0, "all deliberately held upstream continuations finished")
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
assert.equal(h.snapshot().forwardsA, 0, "late preflight completion must not forward/replay the abandoned mutation")
|
||||
assert.equal(h.snapshot().environmentWrites, 0)
|
||||
assert.equal(h.snapshot().invalidations, 0)
|
||||
assert.equal(h.snapshot().catalogueRequests, beforeRelease.catalogueRequests, "destroyed page cannot restart queued catalogue intents")
|
||||
assert.deepEqual(h.snapshot().versions, ["1.1"])
|
||||
assert.deepEqual(h.snapshot().backendErrors, [])
|
||||
assert.deepEqual(reconnected.errors, [])
|
||||
t.diagnostic(`reconnected-before-release=${JSON.stringify(beforeRelease)}; no late mutation/environment/invalidation`)
|
||||
} finally { await reconnectContext.close(); await context.close(); await h.dispose() }
|
||||
})
|
||||
}
|
||||
314
packages/ui/tests/browser/render-cost.test.ts
Normal file
314
packages/ui/tests/browser/render-cost.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { chromium, type Browser, type Page } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
let cacheDirectory: string | undefined
|
||||
before(async () => {
|
||||
// Concurrent browser fixtures must not replace this run's optimized modules.
|
||||
cacheDirectory = await mkdtemp(join(tmpdir(), "codenomad-render-vite-"))
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cacheDirectory,
|
||||
plugins: [solid(), { name: "render-cost-fixture", configureServer(s) {
|
||||
s.middlewares.use("/fixture", async (req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
const name = req.url?.includes("mode=timeline") ? "render-cost-timeline" : "render-cost"
|
||||
res.end(await s.transformIndexHtml("/fixture", `<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/${name}.tsx"></script></body></html>`))
|
||||
})
|
||||
}, async load(id) {
|
||||
// Optional audit-only A/B source override; never changes the checkout.
|
||||
if (process.env.CODENOMAD_RENDER_TASK_BASELINE && id.replaceAll("\\", "/").endsWith("/src/components/tool-call/renderers/task.tsx")) {
|
||||
return readFile(process.env.CODENOMAD_RENDER_TASK_BASELINE, "utf8")
|
||||
}
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
if (cacheDirectory) await rm(cacheDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function open(query: string, run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("fixture-error", error.message) })
|
||||
await page.route("**/*", route => {
|
||||
const request = new URL(route.request().url())
|
||||
if (request.origin !== new URL(url).origin) return route.abort("blockedbyclient")
|
||||
if (request.pathname.startsWith("/api/")) return route.fulfill({ contentType: "application/json", body: "{}" })
|
||||
return route.continue()
|
||||
})
|
||||
try {
|
||||
await page.goto(`${url}?${query}`)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
await run(page)
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
}
|
||||
const step = (page: Page, index = 0) => page.locator(`.tool-call[data-part-id="step-${index}"]`)
|
||||
const frames = (page: Page) => page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
|
||||
test("unrelated child text keeps mounted task steps, disclosure, scroller and copies", async () => open("mode=task&count=12", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
await tool.evaluate(element => { (window as any).originalStep = element })
|
||||
await page.evaluate(() => (window as any).fixture.reproject())
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "authoritative reprojection must not recreate the tool shell")
|
||||
const output = tool.locator(".tool-call-markdown")
|
||||
const box = (await output.boundingBox())!
|
||||
await page.mouse.move(box.x + 30, box.y + 60)
|
||||
await page.mouse.down({ button: "middle" })
|
||||
try {
|
||||
await output.evaluate(element => { (window as any).originalScroller = element; element.scrollTop = 240 })
|
||||
await page.evaluate(() => { (window as any).fixture.hold(); (window as any).fixture.reset(); (window as any).fixture.delta() })
|
||||
await page.waitForFunction(() => (window as any).fixture.snapshot().held > 0)
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "pending display refresh must retain mounted steps")
|
||||
await page.evaluate(() => (window as any).fixture.release())
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
console.log("unrelated-update", { snapshots: state.snapshots, childRevision: state.childRevision, added: state.added, removed: state.removed })
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "unrelated text must not remount a completed tool")
|
||||
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await output.evaluate(element => element === (window as any).originalScroller), true)
|
||||
assert.equal(await output.evaluate(element => element.scrollTop), 240)
|
||||
assert.equal(state.added + state.removed, 0)
|
||||
} finally { await page.mouse.up({ button: "middle" }) }
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.clipboard[0], state.output)
|
||||
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Changed native output"))
|
||||
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Changed native output"))
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Changed native output")
|
||||
await page.evaluate(() => (window as any).fixture.mutateTool())
|
||||
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Versioned in-place output"))
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
await page.evaluate(() => (window as any).fixture.failTool(0))
|
||||
await tool.locator(".tool-call-error-content").waitFor()
|
||||
assert.match((await tool.textContent())!, /Deterministic failure/)
|
||||
await page.evaluate(() => (window as any).fixture.removeTool(0))
|
||||
await tool.waitFor({ state: "detached" })
|
||||
await page.evaluate(() => (window as any).fixture.clearChild())
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 0, "authoritative empty pages must clear stale displayed steps")
|
||||
}))
|
||||
|
||||
test("a failed child refresh keeps the last display snapshot with an explicit retry error", async () => open("mode=task&count=12", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await tool.evaluate(element => { (window as any).originalStep = element })
|
||||
await page.evaluate(() => (window as any).fixture.failRead())
|
||||
await page.getByRole("alert").filter({ hasText: "Deterministic read failure" }).waitFor()
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
await page.getByRole("alert").getByRole("button").click()
|
||||
await page.getByRole("alert").waitFor({ state: "detached" })
|
||||
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
|
||||
}))
|
||||
|
||||
test("native page output must not reuse zero-revision Markdown when a tool is reopened", async () => open("mode=task&count=1", async page => {
|
||||
const tool = step(page), toggle = tool.locator(":scope > .tool-call-header > .tool-call-header-toggle")
|
||||
await tool.waitFor()
|
||||
await toggle.click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Replacement native page output"))
|
||||
await page.waitForFunction(() => (window as any).fixture.toolOutput() === "Replacement native page output")
|
||||
await frames(page)
|
||||
// The old renderer closes on reprojection. Reopen it to distinguish a stale
|
||||
// render-cache hit from the separately asserted disclosure/remount defect.
|
||||
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
assert.equal((await tool.locator("pre code").textContent())?.trim() === "Replacement native page output", true,
|
||||
"the native record changed but the displayed Markdown is stale")
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Replacement native page output")
|
||||
}))
|
||||
|
||||
test("changing an indexed task key does not transfer a removed tool's disclosure", async () => open("mode=task&count=200", async page => {
|
||||
const first = step(page)
|
||||
await first.waitFor()
|
||||
await first.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await first.locator("pre code").waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.appendTool())
|
||||
await first.waitFor({ state: "detached" })
|
||||
assert.equal(await step(page, 1).locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "false")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id="appended-step"]').count(), 1)
|
||||
}))
|
||||
|
||||
for (const retainedIndex of [0, 1]) {
|
||||
test(`authoritative task membership 2->1->2 preserves retained step-${retainedIndex}`, async () => open("mode=task&count=2", async page => {
|
||||
const retained = step(page, retainedIndex)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
await retained.waitFor()
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
const output = retained.locator(".tool-call-markdown")
|
||||
await retained.locator("pre code").waitFor()
|
||||
await frames(page)
|
||||
await retained.evaluate(element => { (window as any).retainedShell = element })
|
||||
await output.evaluate(element => { (window as any).retainedScroller = element; element.scrollTop = 240 })
|
||||
|
||||
await page.evaluate(index => (window as any).fixture.replaceChildTools([index]), retainedIndex)
|
||||
await frames(page)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 1)
|
||||
assert.equal(await counter.textContent(), "1 steps", "removed parts must also leave the membership counter")
|
||||
await page.evaluate(index => (window as any).fixture.replaceChildTools([index, 2]), retainedIndex)
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
assert.equal(await step(page, 2).count(), 1, "new identity at the former cardinality must appear exactly once")
|
||||
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
|
||||
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
assert.equal(await output.evaluate(element => element === (window as any).retainedScroller), true)
|
||||
assert.equal(await output.evaluate(element => element.scrollTop), 240)
|
||||
}))
|
||||
}
|
||||
|
||||
test("authoritative task membership replaces and reorders equal-cardinality identities", async () => open("mode=task&count=2", async page => {
|
||||
const retained = step(page, 0)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
await retained.waitFor()
|
||||
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await retained.locator("pre code").waitFor()
|
||||
await retained.evaluate(element => { (window as any).retainedShell = element })
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools([2, 0]))
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "2 steps")
|
||||
assert.deepEqual(await page.locator('.tool-call[data-part-id^="step-"]').evaluateAll(elements => elements.map(element => element.getAttribute("data-part-id"))), ["step-2", "step-0"])
|
||||
assert.equal(await step(page, 1).count(), 0)
|
||||
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
|
||||
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
|
||||
}))
|
||||
|
||||
test("authoritative task membership drops a tool changed to text under the same part id", async () => open("mode=task&count=2", async page => {
|
||||
await step(page, 1).waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools([0, 1], [1]))
|
||||
await frames(page)
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
|
||||
assert.equal(await counter.textContent(), "1 steps")
|
||||
assert.equal(await step(page, 1).count(), 0)
|
||||
assert.equal(await step(page, 0).count(), 1)
|
||||
}))
|
||||
|
||||
test("authoritative task membership keeps the 200-step bound and clears stale truncation after shrink", async () => open("mode=task&count=201", async page => {
|
||||
await step(page, 200).waitFor()
|
||||
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+\+? steps$/ })
|
||||
assert.equal(await counter.textContent(), "200+ steps")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 200)
|
||||
await page.evaluate(() => (window as any).fixture.replaceChildTools(Array.from({ length: 199 }, (_, index) => index + 2)))
|
||||
await frames(page)
|
||||
assert.equal(await counter.textContent(), "199 steps")
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 199)
|
||||
assert.equal(await page.locator('.tool-call[data-part-id="parent-task"] .tool-call-diagnostic-message').count(), 0)
|
||||
}))
|
||||
|
||||
test("audit sample: deterministic native child deltas (timings are observations, not a CI threshold)", async () => open(`mode=task&count=80&instrument=${process.env.CODENOMAD_RENDER_INSTRUMENT ?? "on"}`, async page => {
|
||||
await step(page, 79).waitFor()
|
||||
await page.evaluate(() => (window as any).fixture.startText())
|
||||
await frames(page)
|
||||
await page.evaluate(() => (window as any).fixture.measure(3))
|
||||
await frames(page)
|
||||
const samples = []
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const metrics = async () => {
|
||||
const { metrics } = await cdp.send("Performance.getMetrics")
|
||||
const ms = (name: string) => metrics.find(metric => metric.name === name)!.value * 1000
|
||||
return { scriptMs: ms("ScriptDuration"), taskMs: ms("TaskDuration"), layoutMs: ms("LayoutDuration"), styleMs: ms("RecalcStyleDuration") }
|
||||
}
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const before = await metrics()
|
||||
const sync = await page.evaluate(() => { const f = (window as any).fixture; f.reset(); return f.measure(1) })
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
const after = await metrics()
|
||||
samples.push({ syncMs: sync.ms, scriptMs: after.scriptMs - before.scriptMs, taskMs: after.taskMs - before.taskMs,
|
||||
layoutMs: after.layoutMs - before.layoutMs, styleMs: after.styleMs - before.styleMs,
|
||||
snapshots: state.snapshots, added: state.added, removed: state.removed })
|
||||
}
|
||||
console.log("render-cost-native-deltas", JSON.stringify({ variant: process.env.CODENOMAD_RENDER_TASK_BASELINE ? "baseline" : "fixed",
|
||||
browser: browser.version(), instrumented: process.env.CODENOMAD_RENDER_INSTRUMENT !== "off", samples }))
|
||||
if (!process.env.CODENOMAD_RENDER_TASK_BASELINE && process.env.CODENOMAD_RENDER_INSTRUMENT !== "off") {
|
||||
assert.ok(samples.every(sample => sample.added === 0 && sample.removed === 0), "structural rescans must not remount unchanged task shells")
|
||||
}
|
||||
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 80)
|
||||
}))
|
||||
|
||||
test("large tool output bounds rendered text but preserves full clipboard source", async () => open("mode=task&count=1&size=2000000", async page => {
|
||||
const tool = step(page)
|
||||
await tool.waitFor()
|
||||
assert.equal(await tool.locator(".tool-call-details").count(), 0, "collapsed steps must not mount their bodies")
|
||||
const start = await page.evaluate(() => performance.now())
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
|
||||
await tool.locator("pre code").waitFor()
|
||||
const content = await tool.locator("pre code").textContent()
|
||||
assert.ok(content && content.length <= 10_000)
|
||||
assert.ok((await tool.textContent())!.includes("Output truncated"))
|
||||
console.log("large-output", { renderedCharacters: content!.length, ...await page.evaluate(start => ({ msToObservedRender: performance.now() - start, nodes: document.querySelectorAll("*").length }), start) })
|
||||
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.equal(state.clipboard[0], state.output)
|
||||
assert.equal(state.output.length, 2_000_000)
|
||||
}))
|
||||
|
||||
test("transcript loads one bounded history page and mounts only virtual rows", async () => open("mode=transcript&count=1000", async page => {
|
||||
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("History 999"))
|
||||
await frames(page)
|
||||
const state = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
const rows = await page.locator("[data-virtual-follow-key]").count()
|
||||
const markers = await page.locator(".timeline-virtual-row").count()
|
||||
console.log("transcript-bounds", { loaded: state.loaded, rows, markers, requests: state.requests })
|
||||
assert.equal(state.loaded, 200)
|
||||
assert.ok(rows < 40)
|
||||
assert.ok(markers < 80)
|
||||
}))
|
||||
|
||||
test("timeline exact extent, bounded DOM and active highlight updates do not mount messages", async () => open("mode=timeline&count=10000", async page => {
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().waitFor()
|
||||
await frames(page)
|
||||
const samples = []
|
||||
for (let index = 0; index < 10; index++) {
|
||||
samples.push(await page.evaluate(() => (window as any).fixture.measure(30)))
|
||||
await frames(page)
|
||||
}
|
||||
console.log("timeline-active-updates", JSON.stringify({ samples, snapshot: await page.evaluate(() => (window as any).fixture.snapshot()) }))
|
||||
const geometry = await page.evaluate(() => {
|
||||
const content = document.querySelector<HTMLElement>(".timeline-virtual-content")!
|
||||
const marker = document.querySelector<HTMLElement>(".timeline-measure button")!
|
||||
const gap = document.querySelector<HTMLElement>(".timeline-measure div")!
|
||||
const markerHeight = parseFloat(getComputedStyle(marker).height), gapHeight = parseFloat(getComputedStyle(gap).height)
|
||||
// Each 3-marker group has a double gap before its tool and following user.
|
||||
let units = 0
|
||||
for (let index = 1; index < 10000; index++) units += index % 3 === 2 ? 1 : 2
|
||||
return { extent: parseFloat(content.style.height), expected: markerHeight * 10000 + gapHeight * units, mounted: document.querySelectorAll(".timeline-virtual-row").length }
|
||||
})
|
||||
console.log("timeline-geometry", geometry)
|
||||
// CSS serialization and native scroll extents round at this magnitude.
|
||||
assert.ok(Math.abs(geometry.extent - geometry.expected) < 1)
|
||||
assert.ok(geometry.mounted < 80)
|
||||
assert.equal(await page.locator(".tool-call, [data-view=message-item]").count(), 0)
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().hover()
|
||||
await page.getByRole("tooltip").locator("strong").waitFor()
|
||||
assert.ok((await page.getByRole("tooltip").textContent())!.length <= 4096)
|
||||
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().click()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).selected, "marker-0")
|
||||
}))
|
||||
|
|
@ -34,7 +34,15 @@ after(async () => { await browser?.close(); await server?.close() })
|
|||
async function open(name: string, run: (page: Page) => Promise<void>) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 700 }, locale: "en-US" })
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
const boundaryDiagnostics: Array<Record<string, unknown>> = []
|
||||
page.on("pageerror", error => {
|
||||
errors.push(error.message)
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) boundaryDiagnostics.push({ kind: "pageerror", message: error.message, stack: error.stack })
|
||||
})
|
||||
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
|
||||
page.on("console", message => boundaryDiagnostics.push({ kind: "console", type: message.type(), text: message.text() }))
|
||||
page.on("requestfailed", request => boundaryDiagnostics.push({ kind: "requestfailed", url: request.url(), error: request.failure()?.errorText }))
|
||||
}
|
||||
await page.addInitScript(`(() => {
|
||||
window.fixtureScrollEvents = [];
|
||||
for (const type of ['wheel', 'scroll', 'pointerdown']) document.addEventListener(type, event => {
|
||||
|
|
@ -55,7 +63,7 @@ async function open(name: string, run: (page: Page) => Promise<void>) {
|
|||
console.error("Browser fixture failure", name, await page.evaluate(() => ({
|
||||
state: (window as any).fixture?.snapshot?.(), events: (window as any).fixtureScrollEvents,
|
||||
streams: Array.from(document.querySelectorAll(".message-stream")).map(el => ({ top: el.scrollTop, height: el.scrollHeight, viewport: el.clientHeight })),
|
||||
})))
|
||||
})), { boundaryDiagnostics })
|
||||
throw error
|
||||
} finally { await page.close() }
|
||||
}
|
||||
|
|
@ -80,7 +88,7 @@ test("user HTML remains literal through live delivery and history while assistan
|
|||
})
|
||||
|
||||
test("user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches", async () => {
|
||||
await open("user-html", async page => {
|
||||
await open(process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE ? "user-html&trace=1" : "user-html", async page => {
|
||||
await page.locator("#root").evaluate(root => { root.style.display = "block"; root.style.height = "auto" })
|
||||
await page.locator("#user strong").waitFor()
|
||||
await page.locator("#assistant .sample-html").waitFor()
|
||||
|
|
|
|||
57
scripts/profile-outline-responsiveness.mts
Normal file
57
scripts/profile-outline-responsiveness.mts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { DatabaseSync } from "node:sqlite"
|
||||
import { performance } from "node:perf_hooks"
|
||||
import { setImmediate as turn } from "node:timers/promises"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { buildSync } from "esbuild"
|
||||
import { readSessionOutline as currentOutline } from "../packages/server/src/opencode/session-pruning/outline-index"
|
||||
|
||||
const baseline = process.argv.includes("--baseline")
|
||||
let readSessionOutline = currentOutline
|
||||
if (baseline) {
|
||||
// Compile the immutable pre-mission source without changing the working tree.
|
||||
const directory = fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/", import.meta.url))
|
||||
const source = execFileSync("git", ["show", "366830f6:packages/server/src/opencode/session-pruning/outline-index.ts"],
|
||||
{ cwd: directory, encoding: "utf8" })
|
||||
const built = buildSync({ stdin: { contents: source, loader: "ts", resolveDir: directory }, bundle: true,
|
||||
platform: "node", format: "esm", write: false })
|
||||
readSessionOutline = (await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString("base64")}`)).readSessionOutline
|
||||
}
|
||||
|
||||
// Synthetic memory-only database. Never discovers or opens an OpenCode database.
|
||||
const count = Number(process.argv[2] ?? 256)
|
||||
const bytes = Number(process.argv[3] ?? 262144)
|
||||
if (!Number.isInteger(count) || count < 1 || count > 1024 || !Number.isInteger(bytes) || bytes < 1 || bytes > 1048576) {
|
||||
throw new Error("Use 1–1024 rows and 1–1048576 payload bytes per row")
|
||||
}
|
||||
const db = new DatabaseSync(":memory:")
|
||||
try {
|
||||
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
|
||||
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER DEFAULT 0);
|
||||
CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message(session_id,seq);
|
||||
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
|
||||
const data = JSON.stringify({ content: [{ type: "tool", name: "read", state: { content: [{ type: "text", text: "x".repeat(bytes) }] } }] })
|
||||
const insert = db.prepare("INSERT INTO session_message(id,session_id,type,seq,data) VALUES (?,'s','assistant',?,?)")
|
||||
for (let i = 0; i < count; i++) insert.run(`m${i}`, i, data)
|
||||
for (let sample = 0; sample < 3; sample++) {
|
||||
await turn()
|
||||
const gaps: number[] = []
|
||||
let previous = performance.now()
|
||||
const start = previous
|
||||
let stopped = false
|
||||
const tick = () => {
|
||||
if (stopped) return
|
||||
const now = performance.now()
|
||||
gaps.push(now - previous)
|
||||
previous = now
|
||||
setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const result = await readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" }, undefined, new AbortController().signal)
|
||||
const elapsed = performance.now() - start
|
||||
await turn()
|
||||
stopped = true
|
||||
console.log(JSON.stringify({ baseline, sample, count, bytes, entries: result.status === "outline" ? result.entries.length : -1,
|
||||
elapsedMs: Math.round(elapsed), maxTurnGapMs: Math.round(Math.max(...gaps)), turns: gaps.length }))
|
||||
}
|
||||
} finally { db.close() }
|
||||
383
scripts/test-compaction-isolation-native.mjs
Normal file
383
scripts/test-compaction-isolation-native.mjs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
// Explicit CLI only: no discovery, shared service, user database or external provider.
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, mkdir, realpath, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { build } from "esbuild"
|
||||
|
||||
const cli = process.argv[2]
|
||||
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute isolated CLI executable")
|
||||
const contextFlag = process.argv.indexOf("--context-mib")
|
||||
const contextMiB = contextFlag < 0 ? 0 : Number(process.argv[contextFlag + 1])
|
||||
if (![0, 2, 8, 32].includes(contextMiB)) throw new Error("--context-mib accepts only 0, 2, 8 or 32")
|
||||
const skipOutline = process.argv.includes("--skip-outline")
|
||||
const temporaryRoot = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(temporaryRoot, { recursive: true })
|
||||
const root = await realpath(await mkdtemp(path.join(temporaryRoot, "compaction-isolation-")))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.test(key)))
|
||||
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
Object.assign(env, {
|
||||
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "test.db"),
|
||||
OPENCODE_SERVER_PASSWORD: "private-compaction-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
})
|
||||
const runtimeVersion = execFileSync(cli, ["--version"], { cwd: root, env, encoding: "utf8" }).trim().replace(/^opencode2? v/, "")
|
||||
assert.match(runtimeVersion, /^2\.0\.(19|21)$/, "This fixture qualifies only the requested runtimes")
|
||||
await Promise.all(["config", "plugin", "pruning"].map(name => mkdir(path.join(root, name), { recursive: true })))
|
||||
const bundle = await build({
|
||||
entryPoints: [fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/plugin.ts", import.meta.url))],
|
||||
bundle: true, platform: "node", format: "esm", target: "node22", write: false,
|
||||
banner: { js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);' },
|
||||
})
|
||||
await writeFile(path.join(root, "pruning", "index.mjs"), bundle.outputFiles[0].contents)
|
||||
await writeFile(path.join(root, "plugin", "index.ts"), `
|
||||
export default { id:'private-compaction-fixture', async setup(ctx) {
|
||||
await ctx.session.hook('http.request', event => {
|
||||
event.request.headers.set('x-fixture-kind', event.kind)
|
||||
event.request.headers.set('x-fixture-session', event.sessionID)
|
||||
})
|
||||
await ctx.tool.transform(editor => editor.add({ name:'isolation_probe', description:'Inert fixture',
|
||||
input:{type:'object',properties:{}}, options:{codemode:false}, execute:async()=>({content:'Synthetic tool result'}) }))
|
||||
} }
|
||||
`)
|
||||
|
||||
const gates = new Map(), requests = [], toolIssued = new Set()
|
||||
const gate = key => {
|
||||
let release
|
||||
const promise = new Promise(resolve => { release = resolve })
|
||||
const value = { promise, release, released: false }
|
||||
gates.set(key, value)
|
||||
return () => { value.released = true; release(); gates.delete(key) }
|
||||
}
|
||||
const summary = ["## Objective", "## Requirements", "## Decisions", "## Work State", "### Completed",
|
||||
"### Active", "### Blocked", "## Next Move", "## Relevant Files", "## Important Context"]
|
||||
.map(heading => `${heading}\n- Synthetic isolation fixture`).join("\n\n")
|
||||
let providerError
|
||||
const provider = createServer(async (request, response) => {
|
||||
try {
|
||||
const started = performance.now()
|
||||
let raw = ""
|
||||
for await (const chunk of request) raw += chunk
|
||||
const bodyReceived = performance.now()
|
||||
const body = JSON.parse(raw), kind = request.headers["x-fixture-kind"], sessionID = request.headers["x-fixture-session"]
|
||||
const record = { kind, sessionID, started, bodyReceived, received: performance.now(), bytes: Buffer.byteLength(raw),
|
||||
messageCount: body.messages?.length, markerCount: (raw.match(/NATIVE_CONTEXT_CANARY_\d+/g) ?? []).length,
|
||||
payloadFillBytes: (raw.match(/Z{1024,}/g) ?? []).reduce((sum, fill) => sum + fill.length, 0) }
|
||||
requests.push(record)
|
||||
const pending = gates.get(`${kind}:${sessionID}`)
|
||||
if (pending) await pending.promise
|
||||
if (response.destroyed) return
|
||||
const text = kind === "compaction" ? summary : "Synthetic conclusion"
|
||||
if (!body.stream) {
|
||||
response.setHeader("Content-Type", "application/json")
|
||||
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: text }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }))
|
||||
return
|
||||
}
|
||||
response.setHeader("Content-Type", "text/event-stream")
|
||||
const chunk = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({
|
||||
id: "fixture", object: "chat.completion.chunk", model: "fixture",
|
||||
choices: [{ index: 0, delta, finish_reason }],
|
||||
...(finish_reason ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } : {}),
|
||||
})}\n\n`)
|
||||
chunk({ role: "assistant" })
|
||||
const tool = kind === "primary" && !toolIssued.has(sessionID)
|
||||
if (tool) {
|
||||
toolIssued.add(sessionID)
|
||||
chunk({ tool_calls: [{ index: 0, id: `inert-${sessionID}`, type: "function", function: { name: "isolation_probe", arguments: "{}" } }] })
|
||||
} else {
|
||||
// Exercise real compaction delta events, without an artificial slow observer.
|
||||
for (let i = 0; i < text.length; i += 16) chunk({ content: text.slice(i, i + 16) })
|
||||
}
|
||||
chunk({}, tool ? "tool_calls" : "stop")
|
||||
response.end("data: [DONE]\n\n")
|
||||
record.responded = performance.now()
|
||||
} catch (error) { providerError = error; response.destroy(error) }
|
||||
})
|
||||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
model: "fixture/fixture",
|
||||
// Fixture-only: preserve the entire imported corpus in a manual summary request.
|
||||
...(contextMiB ? { compaction: { auto: false, keep: { tokens: 0 } } } : {}),
|
||||
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
|
||||
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
|
||||
}, models: { fixture: contextMiB ? { limit: { context: 16777216, output: 8192 } } : {} } } },
|
||||
plugins: [path.join(root, "plugin"), { package: path.join(root, "pruning"), options: { databasePath: env.OPENCODE_DB } }],
|
||||
})
|
||||
let output = "", observerError, spawnError
|
||||
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--log-level", "debug", "--print-logs"], { cwd: root, env, windowsHide: true })
|
||||
const stopped = new Promise(resolve => child.once("close", resolve))
|
||||
child.on("error", error => { spawnError = error })
|
||||
child.stdout.on("data", data => { output += data })
|
||||
child.stderr.on("data", data => { output += data })
|
||||
const streams = new AbortController(), events = [], measurements = []
|
||||
let phase = "setup"
|
||||
const report = { runtimeVersion, root, pid: child.pid, contextMiB, skipOutline, measurements, requests, outline: undefined,
|
||||
pruningBundleSha256: createHash("sha256").update(bundle.outputFiles[0].contents).digest("hex") }
|
||||
let observer, client, releaseA, releaseB
|
||||
const options = () => ({ signal: AbortSignal.timeout(15_000) })
|
||||
async function until(predicate, timeout = 30_000) {
|
||||
const end = performance.now() + timeout
|
||||
while (performance.now() < end) {
|
||||
if (providerError) throw providerError
|
||||
if (observerError) throw observerError
|
||||
if (spawnError) throw spawnError
|
||||
if (child.exitCode !== null) throw new Error("Private server exited early")
|
||||
if (await predicate()) return
|
||||
await delay(20)
|
||||
}
|
||||
throw new Error("Private fixture condition timed out")
|
||||
}
|
||||
async function measured(name, call) {
|
||||
const started = performance.now()
|
||||
const startedPhase = phase
|
||||
try { const value = await call(); const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "ok" }); return value }
|
||||
catch (error) { const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "failed" }); throw error }
|
||||
}
|
||||
const wait = sessionID => client.session.wait({ sessionID }, options())
|
||||
function distribution(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { count: sorted.length, min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], max: sorted.at(-1) }
|
||||
}
|
||||
try {
|
||||
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
||||
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
||||
report.baseUrl = baseUrl
|
||||
client = OpenCode.make({ baseUrl, headers: { Authorization: `Basic ${Buffer.from("opencode:private-compaction-fixture").toString("base64")}` } })
|
||||
const info = await client.server.info(options())
|
||||
assert.equal(info.version, runtimeVersion)
|
||||
assert.equal(info.pid, child.pid, "The endpoint must belong to the child we created")
|
||||
observer = (async () => {
|
||||
for await (const event of client.event.subscribe({ signal: streams.signal })) {
|
||||
events.push({ phase, type: event.type, sessionID: event.data?.sessionID, created: event.created, received: Date.now() })
|
||||
}
|
||||
})().catch(error => { if (!streams.signal.aborted) observerError = error })
|
||||
await until(() => events.some(event => event.type === "server.connected"))
|
||||
const location = { directory: root }
|
||||
let a = await client.session.create({ location, title: "Private compaction A" }, options())
|
||||
const b = await client.session.create({ location, title: "Private witness B" }, options())
|
||||
report.sessions = { a: a.id, b: b.id }
|
||||
await until(async () => {
|
||||
const plugins = (await client.plugin.list({ location }, options())).data
|
||||
const failed = plugins.filter(item => item.source.type !== "builtin" && item.state.status === "failed")
|
||||
assert.equal(failed.length, 0, JSON.stringify(failed))
|
||||
return plugins.some(item => item.id === "codenomad-session-pruning" && item.state.status === "active")
|
||||
})
|
||||
phase = "seed"
|
||||
for (const session of [a, b]) {
|
||||
await client.session.prompt({ sessionID: session.id, text: "Use isolation_probe, then conclude." }, options())
|
||||
await wait(session.id)
|
||||
}
|
||||
const nativeMessages = (await client.message.list({ sessionID: a.id, limit: 20 }, options())).data
|
||||
const template = nativeMessages.find(message => message.type === "assistant" && message.content.some(part => part.type === "tool"))
|
||||
assert(template, "A real native assistant/tool template must exist")
|
||||
const userTemplate = nativeMessages.find(message => message.type === "user")
|
||||
assert(userTemplate)
|
||||
const tool = template.content.find(part => part.type === "tool")
|
||||
assert.equal(tool.state.status, "completed")
|
||||
if (contextMiB) {
|
||||
const exportedA = await client.session.export({ sessionID: a.id }, options())
|
||||
const count = contextMiB * 4, bytes = 262144
|
||||
let fillBytes = 0
|
||||
const imported = Array.from({ length: count }, (_, index) => {
|
||||
const marker = `NATIVE_CONTEXT_CANARY_${String(index).padStart(4, "0")}\n`
|
||||
const text = marker + "Z".repeat(bytes - marker.length)
|
||||
fillBytes += bytes - marker.length
|
||||
return [
|
||||
{ ...structuredClone(userTemplate), id: `msg_context_user_${String(index).padStart(4, "0")}`, text: `Inspect synthetic item ${index}.` },
|
||||
{ ...structuredClone(template), id: `msg_context_assistant_${String(index).padStart(4, "0")}`,
|
||||
content: [{ ...structuredClone(tool), id: `tool_context_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text }] } }] },
|
||||
]
|
||||
}).flat()
|
||||
// Native manual compaction retains the current turn even with keep.tokens=0.
|
||||
// A small trailing turn puts every large tool result in the summarized prefix.
|
||||
imported.push(
|
||||
{ ...structuredClone(userTemplate), id: "msg_context_tail_user", text: "Corpus complete; retain this small trailing turn." },
|
||||
{ ...structuredClone(template), id: "msg_context_tail_assistant", content: [{ type: "text", text: "Synthetic trailing conclusion" }] },
|
||||
)
|
||||
await client.session.remove({ sessionID: a.id }, options())
|
||||
a = await client.session.import({ ...exportedA, messages: imported, location }, { signal: AbortSignal.timeout(60_000) })
|
||||
report.sessions.a = a.id
|
||||
const stored = await client.session.context({ sessionID: a.id }, { signal: AbortSignal.timeout(60_000) })
|
||||
const storedTools = stored.filter(message => message.type === "assistant").flatMap(message => message.content.filter(part => part.type === "tool"))
|
||||
const storedBytes = storedTools.reduce((sum, part) => sum + part.state.content.reduce((total, content) => total + Buffer.byteLength(content.text ?? ""), 0), 0)
|
||||
assert.equal(storedTools.length, count)
|
||||
assert.equal(storedBytes, contextMiB * 1048576)
|
||||
assert.equal(stored.length, imported.length)
|
||||
report.contextCorpus = { turns: count + 1, payloadTurns: count, messages: imported.length, payloadBytes: storedBytes, payloadFillBytes: fillBytes,
|
||||
keepTokens: 0, modelContextLimit: 16777216 }
|
||||
}
|
||||
|
||||
// Seeding is outside the timed compaction/outline intervals and uses native import.
|
||||
let corpus
|
||||
if (!skipOutline) {
|
||||
const empty = await client.session.create({ location, title: "Private outline corpus" }, options())
|
||||
const exported = await client.session.export({ sessionID: empty.id }, options())
|
||||
await client.session.remove({ sessionID: empty.id }, options())
|
||||
const payload = "x".repeat(262144)
|
||||
const messages = Array.from({ length: 128 }, (_, index) => ({ ...structuredClone(template), id: `msg_isolation_${index}`,
|
||||
content: [{ ...structuredClone(tool), id: `tool_isolation_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text: payload }] } }],
|
||||
}))
|
||||
corpus = await client.session.import({ ...exported, messages, location }, { signal: AbortSignal.timeout(60_000) })
|
||||
}
|
||||
|
||||
// Flush seed observations before the measured phase; import is not compaction.
|
||||
await delay(100)
|
||||
phase = "assembly-before-provider"
|
||||
const compactStart = performance.now()
|
||||
releaseA = gate(`compaction:${a.id}`)
|
||||
const admission = measured("A.compact.admission", () => client.session.compact({ sessionID: a.id }, options()))
|
||||
admission.catch(() => {})
|
||||
let probingAssembly = true
|
||||
const assemblyProbes = (async () => {
|
||||
while (probingAssembly && !requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) {
|
||||
await Promise.all([
|
||||
measured("assembly.info", () => client.server.info(options())),
|
||||
measured("assembly.B.get", () => client.session.get({ sessionID: b.id }, options())),
|
||||
])
|
||||
if (probingAssembly) await delay(5)
|
||||
}
|
||||
})()
|
||||
assemblyProbes.catch(() => {})
|
||||
const earlyB = (async () => {
|
||||
await measured("assembly.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude during A summary assembly." }, options()))
|
||||
await measured("assembly.B.wait", () => wait(b.id))
|
||||
assert.equal((await client.session.get({ sessionID: b.id }, options())).outcome, "succeeded")
|
||||
})()
|
||||
earlyB.catch(() => {})
|
||||
const admitted = await admission
|
||||
assert.equal(typeof admitted.time.created, "number")
|
||||
try { await until(() => requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) }
|
||||
finally { probingAssembly = false; await assemblyProbes }
|
||||
const assembled = requests.find(item => item.kind === "compaction" && item.sessionID === a.id)
|
||||
const admittedAt = measurements.find(item => item.name === "A.compact.admission").finished
|
||||
report.compactionAssembly = { submissionToProviderHeadersMs: assembled.started - compactStart,
|
||||
admissionResponseToProviderHeadersMs: assembled.started - admittedAt,
|
||||
submissionToProviderBodyMs: assembled.bodyReceived - compactStart, providerParseMs: assembled.received - assembled.bodyReceived,
|
||||
providerBytes: assembled.bytes, providerMessages: assembled.messageCount, markerCount: assembled.markerCount, payloadFillBytes: assembled.payloadFillBytes }
|
||||
if (contextMiB) {
|
||||
assert.equal(assembled.markerCount, report.contextCorpus.payloadTurns, "Provider must receive every corpus tool marker")
|
||||
assert.equal(assembled.payloadFillBytes, report.contextCorpus.payloadFillBytes, "Stored corpus bytes must really reach the compaction provider")
|
||||
}
|
||||
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.started"))
|
||||
await earlyB
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
report.earlyBCompletedBeforeReleaseA = true
|
||||
report.assemblyWitnesses = measurements.filter(item => item.name.startsWith("assembly.")).map(item => ({ ...item,
|
||||
startedBeforeProviderHeaders: item.started < assembled.started, completedBeforeProviderHeaders: item.finished < assembled.started,
|
||||
completedBeforeProviderBody: item.finished < assembled.bodyReceived }))
|
||||
for (const name of ["assembly.info", "assembly.B.get", "assembly.B.prompt.admission"]) {
|
||||
assert(report.assemblyWitnesses.some(item => item.name === name && item.startedBeforeProviderHeaders), `${name} must be issued before A reaches the provider`)
|
||||
}
|
||||
report.preProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
|
||||
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.startedBeforeProviderHeaders).map(item => item.ms))]))
|
||||
report.whollyPreProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
|
||||
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.completedBeforeProviderHeaders).map(item => item.ms))]))
|
||||
report.bCompletedBeforeProviderHeaders = report.assemblyWitnesses.some(item => item.name === "assembly.B.wait" && item.completedBeforeProviderHeaders)
|
||||
phase = "held-compaction"
|
||||
let aFinished = false
|
||||
const waitingA = wait(a.id).then(() => { aFinished = true })
|
||||
// Attach a rejection handler immediately; still await the actual result later.
|
||||
waitingA.catch(() => {})
|
||||
for (let sample = 0; sample < 4; sample++) {
|
||||
await Promise.all([
|
||||
measured("held.info", () => client.server.info(options())),
|
||||
measured("held.A.get", () => client.session.get({ sessionID: a.id }, options())),
|
||||
measured("held.B.get", () => client.session.get({ sessionID: b.id }, options())),
|
||||
measured("held.active", () => client.session.active(options())),
|
||||
])
|
||||
await delay(50)
|
||||
}
|
||||
phase = "both-held"
|
||||
releaseB = gate(`primary:${b.id}`)
|
||||
const beforeB = requests.length
|
||||
await measured("held.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude while A summary is held." }, options()))
|
||||
await until(() => requests.slice(beforeB).some(item => item.kind === "primary" && item.sessionID === b.id))
|
||||
report.activeWhileBothHeld = await measured("both-held.active", () => client.session.active(options()))
|
||||
assert(report.activeWhileBothHeld[a.id] && report.activeWhileBothHeld[b.id], "Both native executions must be active")
|
||||
await measured("both-held.B.get", () => client.session.get({ sessionID: b.id }, options()))
|
||||
releaseB(); releaseB = undefined
|
||||
await measured("A-held.B.wait", () => wait(b.id))
|
||||
const completedB = await client.session.get({ sessionID: b.id }, options())
|
||||
assert.equal(completedB.outcome, "succeeded", "B must succeed, not simply stop after an error")
|
||||
const messagesB = (await client.message.list({ sessionID: b.id, limit: 8 }, options())).data
|
||||
assert(messagesB.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text === "Synthetic conclusion")))
|
||||
assert.equal(aFinished, false, "B completed independently before releasing A")
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
report.bCompletedBeforeReleaseA = true
|
||||
|
||||
if (!skipOutline) {
|
||||
phase = "outline-under-held-compaction"
|
||||
let outlining = true
|
||||
const outlineStart = performance.now(), heartbeats = []
|
||||
const heartbeat = (async () => {
|
||||
while (outlining) {
|
||||
const start = performance.now()
|
||||
await client.server.info(options())
|
||||
heartbeats.push({ startedMs: start - outlineStart, ms: performance.now() - start })
|
||||
if (outlining) await delay(5)
|
||||
}
|
||||
})()
|
||||
heartbeat.catch(() => {})
|
||||
try {
|
||||
const outline = (await measured("A-held.outline", () => client.rpc.call({ rpcID: "codenomad.session-pruning", method: "outline", location,
|
||||
input: { sessionID: corpus.id } }, options()))).output
|
||||
assert.equal(outline.status, "outline", JSON.stringify(outline))
|
||||
assert.equal(outline.entries.length, 128)
|
||||
assert(outline.entries.every(entry => entry.tools === 1 && entry.toolName === tool.name))
|
||||
const elapsedMs = performance.now() - outlineStart
|
||||
report.outline = { count: 128, payloadBytes: 262144, elapsedMs, heartbeats,
|
||||
completedHeartbeatsDuringOutline: heartbeats.filter(item => item.startedMs + item.ms <= elapsedMs).length }
|
||||
} finally { outlining = false; await heartbeat }
|
||||
}
|
||||
assert.equal(aFinished, false)
|
||||
phase = "held-compaction"
|
||||
// A genuine pending provider wait, not merely a race through a fast reply.
|
||||
await delay(1_000)
|
||||
await measured("held-final.B.get", () => client.session.get({ sessionID: b.id }, options()))
|
||||
await measured("held-final.info", () => client.server.info(options()))
|
||||
assert.equal(aFinished, false)
|
||||
report.aHeldMs = performance.now() - compactStart
|
||||
const releasedAt = performance.now()
|
||||
phase = "summary-release"
|
||||
releaseA(); releaseA = undefined
|
||||
await waitingA
|
||||
report.aCompletionAfterReleaseMs = performance.now() - releasedAt
|
||||
const context = await client.session.context({ sessionID: a.id }, options())
|
||||
assert(context.some(message => message.type === "compaction" && message.status === "completed"), "Real native compaction must finish successfully")
|
||||
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
|
||||
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.failed"))
|
||||
report.eventCounts = Object.fromEntries([...new Set(events.map(event => event.type))].map(type => [type, events.filter(event => event.type === type).length]))
|
||||
const business = events.filter(event => event.type.startsWith("session.") && typeof event.created === "number")
|
||||
report.eventDeliveryLagByPhaseMs = Object.fromEntries([...new Set(business.map(event => event.phase))]
|
||||
.map(name => [name, distribution(business.filter(event => event.phase === name).map(event => event.received - event.created))]))
|
||||
report.events = events
|
||||
report.latencyByOperationMs = Object.fromEntries([...new Set(measurements.map(item => item.name))]
|
||||
.map(name => [name, distribution(measurements.filter(item => item.name === name).map(item => item.ms))]))
|
||||
report.status = "passed"
|
||||
console.log(JSON.stringify({ ...report, events: undefined, measurements: undefined, requests: undefined, assemblyWitnesses: undefined }, null, 2))
|
||||
console.log(`PASS: B completed before releasing A's provider; corpus=${contextMiB} MiB, outline=${!skipOutline}`)
|
||||
} catch (error) {
|
||||
report.status = "failed"
|
||||
report.error = String(error.stack ?? error)
|
||||
throw error
|
||||
} finally {
|
||||
releaseA?.(); releaseB?.()
|
||||
for (const value of gates.values()) value.release()
|
||||
streams.abort()
|
||||
await observer
|
||||
child.kill()
|
||||
await stopped
|
||||
provider.closeAllConnections()
|
||||
await new Promise(resolve => provider.close(resolve))
|
||||
await writeFile(path.join(root, "server.log"), output)
|
||||
await writeFile(path.join(root, "result.json"), JSON.stringify(report, null, 2))
|
||||
console.log(`Private fixture retained at ${root}; only its child was stopped`)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import assert from "node:assert/strict"
|
|||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { tsImport } from "tsx/esm/api"
|
||||
|
||||
export async function testNativeEventRelay({ client, location }) {
|
||||
export async function testNativeEventRelay({ client, location, service }) {
|
||||
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
|
||||
const { InstanceEventBridge } = await tsImport("../packages/server/src/workspaces/instance-events.ts", import.meta.url)
|
||||
const controller = new AbortController()
|
||||
|
|
@ -18,8 +18,8 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
list: () => records,
|
||||
ownsDirectory: owns,
|
||||
ownsLocation: (id, ref) => owns(id, ref.directory),
|
||||
getSharedServiceClient: async () => client,
|
||||
subscribeToSharedService: signal => client.event.subscribe({ signal }),
|
||||
getSharedServiceClient: () => service ? service.client() : Promise.resolve(client),
|
||||
subscribeToSharedService: signal => service ? service.subscribe({ signal }) : client.event.subscribe({ signal }),
|
||||
invalidateWorktrees() {},
|
||||
}
|
||||
bus.on("instance.eventStatus", event => { if (event.status === "connected") connected = true })
|
||||
|
|
@ -43,6 +43,26 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
try {
|
||||
bus.publish({ type: "workspace.started", workspace: records[0] })
|
||||
await until(() => connected && nativeConnected)
|
||||
if (service) {
|
||||
for (const mode of ["abort", "return", "throw"]) {
|
||||
const local = new AbortController()
|
||||
const wrapped = (await service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
|
||||
try {
|
||||
assert.equal((await wrapped.next()).value.type, "server.connected")
|
||||
if (mode === "abort") {
|
||||
const next = wrapped.next()
|
||||
local.abort()
|
||||
assert.equal((await next).done, true)
|
||||
} else if (mode === "throw") {
|
||||
const failure = new Error("isolated subscriber failure")
|
||||
await assert.rejects(wrapped.throw(failure), error => error === failure)
|
||||
} else await wrapped.return()
|
||||
await client.server.info()
|
||||
assert.equal((await service.acquire()).client, client, `${mode} must retain the shared native client`)
|
||||
} finally { local.abort(); await wrapped.return() }
|
||||
}
|
||||
console.log("PASS: native service subscriber abort/return/throw retain the healthy shared connection")
|
||||
}
|
||||
const session = await client.session.create({ location: { directory: location.directory } })
|
||||
sessions.push(session.id)
|
||||
const titles = Array.from({ length: 8 }, (_, index) => `relay-fixture-${index}`)
|
||||
|
|
@ -56,6 +76,13 @@ export async function testNativeEventRelay({ client, location }) {
|
|||
await until(() => renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)).length === titles.length)
|
||||
assert.deepEqual(renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)), titles)
|
||||
console.log("PASS: native relay isolates slow recipients, preserves session ordering and does not block another shared SDK subscriber")
|
||||
if (service) {
|
||||
bridge.shutdown()
|
||||
await client.session.update({ sessionID: session.id, title: "after-relay-shutdown" })
|
||||
await until(() => renames(native).includes("after-relay-shutdown"))
|
||||
assert.equal((await service.acquire()).client, client)
|
||||
console.log("PASS: relay shutdown leaves another native subscriber and shared client requests usable")
|
||||
}
|
||||
} finally {
|
||||
release(false)
|
||||
bridge.shutdown()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { testNativeWorktreeFamily } from "./test-worktree-families-native.mjs"
|
|||
import { testNativeWorktreeManagement } from "./test-native-worktree-management.mjs"
|
||||
import { testNativeEventRelay } from "./test-native-event-relay.mjs"
|
||||
|
||||
export async function testNativeLocationIdentity({ client, connection, root }) {
|
||||
export async function testNativeLocationIdentity({ client, connection, root, service }) {
|
||||
const { contractProfile, runtimeIdentity } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
||||
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url)
|
||||
const profile = connection.profile ? await connection.profile() : contractProfile(runtimeIdentity(connection.endpoint))
|
||||
|
|
@ -36,7 +36,7 @@ export async function testNativeLocationIdentity({ client, connection, root }) {
|
|||
const rootLocation = await client.location.get({ location: { directory } })
|
||||
const worktreeLocation = await client.location.get({ location: { directory: worktree } })
|
||||
assert.equal(rootLocation.project.id, worktreeLocation.project.id)
|
||||
await testNativeEventRelay({ client, location: rootLocation })
|
||||
await testNativeEventRelay({ client, location: rootLocation, service })
|
||||
await testNativeWorktreeFamily({ client, profile, rootLocation, worktreeLocation })
|
||||
await testNativeWorktreeManagement({ client, root })
|
||||
if (profile !== "legacy") {
|
||||
|
|
|
|||
57
scripts/test-render-cost-audit.mjs
Normal file
57
scripts/test-render-cost-audit.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
// Read-only source A/B in isolated loopback Chromium fixtures. No checkout,
|
||||
// desktop, daemon, database or user-profile mutation is needed.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const ui = join(root, "packages/ui")
|
||||
const reference = "47f9e43e3fa1a3be59c0f94932a7af0a4be20660"
|
||||
const source = "packages/ui/src/components/tool-call/renderers/task.tsx"
|
||||
const repetitions = Number(process.argv[2] ?? 3)
|
||||
if (!Number.isInteger(repetitions) || repetitions < 1 || repetitions > 10) throw new Error("Use 1–10 repetitions")
|
||||
const directory = mkdtempSync(join(tmpdir(), "codenomad-render-cost-"))
|
||||
const baselinePath = join(directory, "task-baseline.tsx")
|
||||
writeFileSync(baselinePath, execFileSync("git", ["show", `${reference}:${source}`], { cwd: root }))
|
||||
const runs = []
|
||||
for (let repetition = 0; repetition < repetitions; repetition++) {
|
||||
for (const variant of ["baseline", "fixed"]) {
|
||||
const env = { ...process.env, CODENOMAD_RENDER_INSTRUMENT: "off" }
|
||||
if (variant === "baseline") env.CODENOMAD_RENDER_TASK_BASELINE = baselinePath
|
||||
else delete env.CODENOMAD_RENDER_TASK_BASELINE
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", "--test", "--test-concurrency=1",
|
||||
"--test-name-pattern=^audit sample:", "tests/browser/render-cost.test.ts"], {
|
||||
cwd: ui, env, encoding: "utf8", windowsHide: true, timeout: 120_000,
|
||||
})
|
||||
writeFileSync(join(directory, `${repetition + 1}-${variant}.log`), `${result.stdout ?? ""}\n${result.stderr ?? ""}`)
|
||||
if (result.status !== 0) throw new Error(`Fixture failed (${variant}): ${result.error ?? result.stderr}; logs: ${directory}`)
|
||||
const match = result.stdout.match(/render-cost-native-deltas (\{[^\r\n]*\})/)
|
||||
if (!match) throw new Error(`No metrics in ${variant}; logs: ${directory}`)
|
||||
const metrics = JSON.parse(match[1])
|
||||
if (metrics.variant !== variant || metrics.instrumented || metrics.samples.length !== 10) throw new Error("Invalid A/B fixture metadata")
|
||||
runs.push({ repetition: repetition + 1, ...metrics })
|
||||
console.log(`${repetition + 1}/${repetitions} ${variant}: 10 samples recorded`)
|
||||
}
|
||||
}
|
||||
function stats(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mean = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return { n: values.length, median: (sorted[(sorted.length - 1) >> 1] + sorted[sorted.length >> 1]) / 2,
|
||||
min: sorted[0], max: sorted.at(-1), mean,
|
||||
sampleStandardDeviation: Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1)) }
|
||||
}
|
||||
const summary = Object.fromEntries(["baseline", "fixed"].map(variant => {
|
||||
const samples = runs.filter(run => run.variant === variant).flatMap(run => run.samples)
|
||||
return [variant, Object.fromEntries(["syncMs", "scriptMs", "taskMs", "layoutMs", "styleMs"].map(key => [key, stats(samples.map(sample => sample[key]))]))]
|
||||
}))
|
||||
const artifact = { reference, fixedSourceSha256: createHash("sha256").update(readFileSync(join(root, source))).digest("hex"),
|
||||
browserPath: process.env.CODENOMAD_BROWSER_PATH ?? "Playwright default", node: process.version,
|
||||
repetitions, scenario: "80 completed read steps × 8,000 characters; 3 warmup deltas, 10 single native text deltas per run",
|
||||
notes: "CDP cumulative CPU deltas through two animation frames; not frame latency. Instrumentation off. Other host activity is not controlled.",
|
||||
summary, runs }
|
||||
writeFileSync(join(directory, "metrics.json"), JSON.stringify(artifact, null, 2))
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
console.log(`Raw metrics and logs: ${join(directory, "metrics.json")}`)
|
||||
|
|
@ -165,7 +165,7 @@ try {
|
|||
assert.equal((await client.server.info()).version, runtimeVersion)
|
||||
console.log(`Testing official runtime ${runtimeVersion}`)
|
||||
const { testNativeLocationIdentity } = await import("./test-opencode-location-native.mjs")
|
||||
await testNativeLocationIdentity({ client, connection, root })
|
||||
await testNativeLocationIdentity({ client, connection, root, service: sharedService })
|
||||
const { testNativeProxy } = await import("./test-opencode-proxy-native.mjs")
|
||||
await testNativeProxy({ client, baseUrl, root, runtimeFetch, connection, authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
|
||||
exercise: async (proxy, sessionID) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue