From 9998e15d344de63d64e09c13cd827fabbdb76efb Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 17:41:52 +0000 Subject: [PATCH 1/6] fix(bible): preserve exact authored freeform fact text [skip ci] --- .../bible-fact-text-custody.md | 31 +++++++++++++++ docs/plans/agent-story-workflows/plan.md | 10 +++++ .../bible/bibleGraphFieldDrafts.svelte.ts | 2 +- ...leGraphNodeDetailProjection.client.test.ts | 38 ++++++++++++++++--- 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 docs/plans/agent-story-workflows/bible-fact-text-custody.md diff --git a/docs/plans/agent-story-workflows/bible-fact-text-custody.md b/docs/plans/agent-story-workflows/bible-fact-text-custody.md new file mode 100644 index 00000000..84f0b0cc --- /dev/null +++ b/docs/plans/agent-story-workflows/bible-fact-text-custody.md @@ -0,0 +1,31 @@ +# Exact authored Bible freeform text + +Separate bounded successor of accepted Notes commit +`c703ebb7a0016554b702124a794a4654968b42cd`, tree +`ab5d64837792dc9379370a927a32486a6cc92f39`. Notes/PR28 and PR27 stay unchanged. + +The authoring roadmap and manual-edit Bible reconciliation require preservation +of authored material and canonical before/after fact provenance. The existing +Bible Save/preview/accept workflow and graph dependency memory already work. +This slice corrects an input loss rather than introducing another fact writer. + +Before implementation, the actual client-compiled `createBibleGraphFieldDrafts` +received ` Mara reveals the witness — 雨.\n\n ` but called its existing Save callback +with `Mara reveals the witness — 雨.`. A regression against the unchanged owner +failed with those exact actual/expected values; eleven existing client tests passed. +Its request construction called `submitted.trim()` even though +`BibleGraphPartFields` forwards exact text and the canonical field writer retains +`FieldValue::Text` verbatim. The UI retired the draft after acknowledgement of the +trimmed value, silently discarding author input. Baseline evidence is retained +outside Git in `bible-fact-text-custody-evidence/exact-fact-baseline.log`. + +Keep the submitted freeform text exactly as entered. Existing empty input still +maps to `null` and clears the field. Whitespace-only authored input is a text value, +not an empty clear. Existing typed fields, canonical writer/provenance, validation, +conflict handling, pending-save custody and explicit screenplay acceptance are +unchanged. No schema, dependency, provider, recovery or new command is added. + +Client qualification includes leading/trailing spaces, newline/Unicode text, +whitespace-only text and explicit empty clear. Qualification/independent review +and exact source are recorded in the separate external handoff. No hosted or +real-model quality claim is implied. diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index f9772a86..8c238803 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1187,3 +1187,13 @@ SQLite and rendered Chromium checks cover interruptions, two clips, lock-after- lost-ack recovery, save/discard/reload and canonical conflict. See the audit for exact proof boundaries and external source/evidence handoff. No new hosted job, schema, embedding or project-switch recovery scope. + +### Exact authored Bible freeform text (separate successor) + +After independently accepted Notes c703ebb, the actual Bible field draft owner +reproduction proves that `submitted.trim()` silently removes authored whitespace +before canonical Save. See [bible-fact-text-custody.md](bible-fact-text-custody.md). +Reuse the current field writer and owned before/after graph memory, preserving +submitted freeform text exactly while retaining explicit empty clear. Existing +Bible proposals and screenplay review/acceptance are not duplicated. No new +schema, embedding, project-switch or hosted spending scope. diff --git a/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts b/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts index 96db6275..3cb186be 100644 --- a/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts +++ b/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts @@ -96,7 +96,7 @@ export function createBibleGraphFieldDrafts(options: { const owner = options.owner(), submitted = value(currentField), draft = state.drafts[field.id], - request = { submitted: submitted.trim() }; + request = { submitted }; requests[field.id] = request; const owned = () => options.owner() === owner && state.owner === owner && requests[field.id] === request; diff --git a/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts b/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts index 064f1f0b..baf4716e 100644 --- a/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts +++ b/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts @@ -103,7 +103,7 @@ for (const ordering of ['refresh-before-ack','ack-before-refresh']) { const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current,save:()=>new Promise(resolve=>finish=resolve)}); const exact=' Blue draft — 雨.\\n\\n '; editor.update(current[0],exact); const base=editor.state.drafts.fact.base; - const saving=editor.save(current[0]); const ack={...current[0],value:{type:'text',value:exact.trim()}}; + const saving=editor.save(current[0]); const ack={...current[0],value:{type:'text',value:exact}}; if(ordering==='refresh-before-ack') { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } finish(ack); await saving; if(ordering==='ack-before-refresh') { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } @@ -119,9 +119,9 @@ for(const conflict of [false,true]) { const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current,save:()=>new Promise(resolve=>finish=resolve)}); editor.update(current[0],' Blue — 雨. '); const saving=editor.save(current[0]); if(conflict) { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } - current=[{...current[0],value:{type:'text',value:'Blue — 雨.'}}]; editor.observe(); finish(current[0]); await saving; + current=[{...current[0],value:{type:'text',value:' Blue — 雨. '}}]; editor.observe(); finish(current[0]); await saving; if(conflict) { assert.equal(editor.value(current[0]),' Blue — 雨. '); assert.equal(editor.changed(current[0]),true); } - else { assert.equal(editor.state.drafts.fact,undefined); assert.equal(editor.value(current[0]),'Blue — 雨.'); } + else { assert.equal(editor.state.drafts.fact,undefined); assert.equal(editor.value(current[0]),' Blue — 雨. '); } } `)); @@ -175,10 +175,10 @@ globalThis.read=async()=>projection('Mara','Red',1);const release=store.retainBi let acknowledge,refresh;const editor=createBibleGraphFieldDrafts({owner:()=> 'Mara',fields:()=>fields('Mara'),verified:()=>store.isBibleGraphNodeProjectionVerified(key('Mara')),save:()=>new Promise(resolve=>acknowledge=resolve)}); editor.update(fields('Mara')[0],' Blue draft — 雨. ');const saving=editor.save(fields('Mara')[0]); globalThis.read=()=>new Promise(resolve=>refresh=resolve);const reading=store.refreshOwnedBibleGraphNodeProjections(); -const ack=projection('Mara','Blue draft — 雨.',2);store.cacheNodeProjection(store.cacheKey(key('Mara')),ack);acknowledge(ack.payload.parts[0].fields[0]);await saving; -assert.equal(editor.state.drafts['Mara.tagline'],undefined);assert.equal(editor.value(fields('Mara')[0]),'Blue draft — 雨.');assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false); +const ack=projection('Mara',' Blue draft — 雨. ',2);store.cacheNodeProjection(store.cacheKey(key('Mara')),ack);acknowledge(ack.payload.parts[0].fields[0]);await saving; +assert.equal(editor.state.drafts['Mara.tagline'],undefined);assert.equal(editor.value(fields('Mara')[0]),' Blue draft — 雨. ');assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false); await editor.save(fields('Mara')[0]);assert.match(editor.state.errors['Mara.tagline'],/Verify saved facts/); -refresh(projection('Mara','Red',1));await assert.rejects(reading,/older revision/);assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false);assert.equal(fields('Mara')[0].value.value,'Blue draft — 雨.'); +refresh(projection('Mara','Red',1));await assert.rejects(reading,/older revision/);assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false);assert.equal(fields('Mara')[0].value.value,' Blue draft — 雨. '); globalThis.read=async()=>projection('Mara','Green',3);await store.refreshOwnedBibleGraphNodeProjections();editor.observe();assert.equal(editor.changed(fields('Mara')[0]),false);assert.equal(editor.value(fields('Mara')[0]),'Green');release(); `)); @@ -190,3 +190,29 @@ editor.update(current[0],'Old blue');const old=editor.save(current[0]);owner='El completions[0]({...current[0],value:{type:'text',value:'Old blue'}});await old;assert.equal(editor.value(current[0]),'New exact draft — 雨.');assert.equal(editor.state.saving.fact,true); current=[{...current[0],value:{type:'text',value:'New exact draft — 雨.'}}];completions[1](current[0]);await fresh;assert.equal(editor.state.drafts.fact,undefined);assert.equal(editor.state.saving.fact,false); `)); + +it('saves exactly entered freeform Bible text including whitespace and distinguishes empty clear', () => + run(` +for (const entered of [' Mara reveals the witness — 雨.\\n\\n ', ' \\n ', '']) { + let submitted; + let current=[{id:'exact.field',part_id:'profile',field_key:'tagline',value:{type:'text',value:'Original saved fact'}}]; + const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current, + save:async(field,text)=>{submitted=text;current=[{...field,value:text?{type:'text',value:text}:null}];return current[0];}}); + editor.update(current[0],entered); + await editor.save(current[0]); + assert.equal(submitted,entered,'Save must preserve the exact authored text'); + assert.deepEqual(current[0].value,entered?{type:'text',value:entered}:null); + assert.equal(editor.value(current[0]),entered); + assert.equal(editor.state.drafts['exact.field'],undefined,'acknowledged exact draft retires'); +} +`)); + +it('retains exact author text when a transport acknowledges a trimmed different fact', () => + run(` +let current=[{id:'exact.field',part_id:'profile',field_key:'tagline',value:{type:'text',value:'Red'}}]; +const entered=' Blue — 雨. '; +const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current, + save:async(field,text)=>{assert.equal(text,entered);current=[{...field,value:{type:'text',value:text.trim()}}];return current[0];}}); +editor.update(current[0],entered);await editor.save(current[0]); +assert.equal(editor.value(current[0]),entered);assert.equal(editor.state.drafts['exact.field'].baseText,'Red');assert.equal(editor.changed(current[0]),true); +`)); From b8227132f8c086f78b7c846eca7e24c4d983a92c Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 18:13:31 +0000 Subject: [PATCH 2/6] fix(screenplay): retain and explicitly recover missing-block drafts [skip ci] --- .../missing-screenplay-draft-recovery.md | 62 +++++ docs/plans/agent-story-workflows/plan.md | 13 ++ ui/src/lib/components/editor/README.md | 13 ++ .../editor/ScriptMissingDraft.svelte | 87 +++++++ .../lib/components/editor/ScriptPanel.svelte | 9 +- .../editor/scriptBlockCreationDraft.svelte.ts | 17 +- .../editor/scriptBlockEditDraft.svelte.ts | 8 + .../editor/scriptMissingDraft.svelte.test.ts | 212 ++++++++++++++++++ .../components/editor/scriptMissingDraft.ts | 26 +++ ui/src/lib/stores/README.md | 5 + .../stores/scriptBlockEditSession.svelte.ts | 10 + 11 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md create mode 100644 ui/src/lib/components/editor/ScriptMissingDraft.svelte create mode 100644 ui/src/lib/components/editor/scriptMissingDraft.svelte.test.ts create mode 100644 ui/src/lib/components/editor/scriptMissingDraft.ts diff --git a/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md b/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md new file mode 100644 index 00000000..7c0f17e6 --- /dev/null +++ b/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md @@ -0,0 +1,62 @@ +# Missing saved-block draft recovery + +Base: independently accepted Notes c703ebb and Bible exact-text 9998e15. + +Before implementation, the actual ScriptPanel rendering regression retains exact +Unicode/newline manual text and its original revision in the session edit owner, +then reads a canonical document without that block. The panel renders no draft. +Only orphaned removal requests were displayed; canonical disappearance made an +active edit and its unknown-acknowledgment retry inaccessible. The preserved +baseline fails its rendered-text assertion. This is a concrete authoring loss of +access, separate from project switching and the accepted block-type operation. + +Reuse the same per-block session edit owner. Surface unavailable drafts without +claiming a deletion from a pending/failed read; preserve original block/revision +identity. Read/check and immutable original retry remain under existing guards. +Explicit discard acts only on this draft. Explicit copy enters the existing +single composer for the visibly selected canonical clip, preserving original +text and kind and leaving the original draft intact. A busy/active composer or +uncertain original save refuses copy; no automatic recreate, retarget or commit. +Cancel of the copy preserves the original. Ordinary Save uses existing canonical +CreateScriptBlock, UserEdited provenance, history and downstream review. A copy +is a new authored block, not restoration of old identity or consumed lineage. + +Qualification covers missing-block panel/remount, independent drafts, same-ID +return, reads failing or returning old versions, pending/lost-ack exact retry, +repeated copy/save/cancel and actual public create/remove storage. Preserve +saved unrelated material and require explicit downstream proposal acceptance. +No backend API/schema, dependency, alias mapping, new canonical store or native/ +real-model qualification is implied. No hosted jobs or main merge. + +## Local qualification + +608 UI tests in 105 files pass; independent preliminary review reran 22 focused +tests. Svelte check reports zero errors/warnings. Final rendered Chromium uses +actual AppShell, Bible inspector, screenplay and timeline with a temporary IPC +adapter to real public SQLite services. It removes a saved block externally via +its ordinary canonical removal command while its exact manual draft is open. +The draft remains visible through panel navigation, explicit copy/cancel and +copy Save with a dropped post-commit acknowledgment. Exact retry creates one +UserEdited block, retaining the original draft and unrelated saved blocks. +The public agent-context preview contains exact copied text. Existing targeted +preview preserves all saved blocks; explicit acceptance changes only its bound +generated target text/revision. Only explicit discard clears the original draft. + +The final walkthrough has three labelled synthetic HTTP replies: initial +generation, recap and targeted preview. Nine replies total are retained across +two earlier rehearsals plus the final isolated project. The final responder +distinguishes generation/preview by actual prompt, rather than a global count; +the final assertions require distinct target text/revision and complete equality +of every other block. Earlier layout rehearsals are retained separately. + +These are instrumented browser/public-service checks, not native/release or +real-model narrative qualification. Native renderer status is unavailable in +the test adapter and visibly reports that limitation. Runtime exception checks +pass; they do not hide missing-adapter-route transcript errors. No reopen after +downstream acceptance is claimed. Stable served module bytes are retained after +the run, not capture-time network attestation. Temporary UI route, Vite config +and Rust adapter example are removed/restored before the product commit. + +Evidence is outside Git under +`/workspace/scratch/missing-screenplay-draft-recovery-evidence`. Parent owns PRs, +merge gates, hosted native qualification and Library delivery. diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index 8c238803..f3f872cd 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1197,3 +1197,16 @@ Reuse the current field writer and owned before/after graph memory, preserving submitted freeform text exactly while retaining explicit empty clear. Existing Bible proposals and screenplay review/acceptance are not duplicated. No new schema, embedding, project-switch or hosted spending scope. + +### Missing saved-block draft recovery (separate successor) + +Accepted Notes c703ebb and Bible exact-text 9998e15 remain preserved. A rendered +ScriptPanel reproduction proves an active draft becomes inaccessible when its +canonical block disappears: only orphaned removal requests were displayed. +Reuse the existing per-block edit owner and single creation composer for visible +retention, immutable original retry, explicit discard and explicit copy into a +new selected-clip draft. Ordinary Save retains canonical UserEdited provenance, +history and downstream review; copying neither writes nor retires the original. +See [missing-screenplay-draft-recovery.md](missing-screenplay-draft-recovery.md). +No project-switch, block-type duplication, new store/schema, Pumas identity +mapping, hosted job or main merge scope. diff --git a/ui/src/lib/components/editor/README.md b/ui/src/lib/components/editor/README.md index 0df5140a..280b0d83 100644 --- a/ui/src/lib/components/editor/README.md +++ b/ui/src/lib/components/editor/README.md @@ -28,6 +28,8 @@ This directory contains the main beat and script viewing workflow, including AI | `scriptBlockCreationDraft.svelte.test.ts` | Refused/ambiguous save, exact text and captured-context frontend fixtures. | | `scriptBlockCreationRetry.svelte.test.ts` | Lost acknowledgement, exact retry, guarded discard/retarget and definite native placement refusal through actual frontend command/store functions with an invoke fixture. | | `ScriptBlockComposer.retry.ssr.test.ts` | Uncertain-save read-only controls, exact retry action and definite-refusal recovery rendering fixtures. | +| `ScriptMissingDraft.svelte`, `scriptMissingDraft.ts` | Visible missing-block drafts, exact original retry and explicit copy into the existing selected-clip composer. | +| `scriptMissingDraft.svelte.test.ts` | Removal rendering, independent drafts, read failure/return, guarded copy, cancel and immutable retry. | | `ScriptBlockEditor.svelte` | Session-owned per-block edit/save/cancel surface with exact uncertain retry and explicit reload. | | `scriptBlockEditDraft.svelte.ts` | Captured draft, immutable pending edit, read comparison and explicit continuation from that version. | | `scriptBlockEditComparison.svelte.test.ts` | Exact draft/current-text comparison, explicit version continuation, stale/ABA/read failures, navigation, immutable retry and retired-session fixtures. | @@ -263,3 +265,14 @@ owner preserves drafts through navigation and retries immutable uncertain writes `ScriptTimelineTitleEvidence` shows original/current changed consumed names or owned removal. Unchanged supplied titles remain bound to the proposal. Existing Preview update and Accept update remain separate replacement actions. + +Missing saved blocks do not hide their session-owned manual drafts or uncertain +original saves. ScriptMissingDraft labels absence from the current view without +asserting deletion after failed reads. It preserves original block/revision +identity and offers existing read/retry guards. Explicit copy captures exact text +and original kind into the idle existing composer for the visibly selected clip; +it neither restores the old block nor discards the source draft. A busy/uncertain +source or active composer refuses copy. Cancel of the copy leaves the source +intact. Only ordinary Save creates new canonical UserEdited material, using the +existing downstream review and explicit acceptance workflow. Same-ID return +reattaches the same edit owner and original revision. diff --git a/ui/src/lib/components/editor/ScriptMissingDraft.svelte b/ui/src/lib/components/editor/ScriptMissingDraft.svelte new file mode 100644 index 00000000..362dd892 --- /dev/null +++ b/ui/src/lib/components/editor/ScriptMissingDraft.svelte @@ -0,0 +1,87 @@ + + +
+ Your draft is retained +

+ This saved block is unavailable in the current screenplay view. Check saved screenplay before + deciding what to do. +

+
+ Original source +

Block: {editor.blockId}

+

Saved revision: {draft.baseRevision}

+
+ + + {#if draft.error}

{draft.error} Your draft is still here.

{/if} + {#if draft.uncertain}

+ The original save may have completed. Retry that exact save before copying or discarding. +

{/if} +
+ {#if draft.uncertain}{/if} + + + +
+ {#if source}

+ Copy starts a new block for {source.name}. Review it in Write screenplay and + explicitly Save. This original draft stays here until discarded. +

{:else}

Select a timeline clip to copy this text into a new screenplay draft.

{/if} +
+ + diff --git a/ui/src/lib/components/editor/ScriptPanel.svelte b/ui/src/lib/components/editor/ScriptPanel.svelte index 9de5d403..8e38962b 100644 --- a/ui/src/lib/components/editor/ScriptPanel.svelte +++ b/ui/src/lib/components/editor/ScriptPanel.svelte @@ -1,7 +1,11 @@ + +{#if previous || current} +
+ Story arc assignment used for this update + {#if !previous}

Original arc assignment consumption is unknown.

{/if} + {#each [{ label: 'Originally consumed arc assignment', input: previous, fields: previousFields }, { label: 'Current preview arc assignment', input: current, fields: currentFields }] as group (group.label)} + {#if group.input} + {group.label} +
    + {#each group.input.arc_ids as id (id)}
  • + {group.fields?.find((field) => field.arc_id === id && field.field === 'name') + ?.value ?? 'Arc'} · {id} +
  • {/each} +
+ {#if group.input.arc_ids.length === 0}

(no arcs)

{/if} + Clip {group.input.node_id} · Revision {group.input.revision_event_id ?? + 'known baseline; no assignment event'} + {/if} + {/each} +

+ Saving arc assignment and previewing preserve saved screenplay. Accept update replaces only + the selected block. +

+
+{/if} + + diff --git a/ui/src/lib/components/editor/ScriptImpactReview.svelte b/ui/src/lib/components/editor/ScriptImpactReview.svelte index 9a0509a9..efdc485a 100644 --- a/ui/src/lib/components/editor/ScriptImpactReview.svelte +++ b/ui/src/lib/components/editor/ScriptImpactReview.svelte @@ -1,4 +1,5 @@ + +
+ Story arcs + {#each arcs as arc (arc.id)} + + {/each} + {#each unavailable as id (id)} + + {/each} + {#if arcs.length === 0 && unavailable.length === 0}

+ Create a story arc in the Arcs panel to assign it here. +

{/if} +
+
+ + +
+{#if dirty}

+ Unsaved arc assignment. Kept while you switch clips or panels in this project session. +

{/if} +{#if draft.state.saved}

+ Arc assignment saved. Affected generated screenplay can be reviewed before replacement. +

{/if} +{#if changed}

+ Saved arc assignment changed since this draft was read. Your draft is kept. +

+
+ Current saved arc assignment +

+ {draft.state.current?.arc_ids + .map((id) => arcs.find((arc) => arc.id === id)?.name ?? id) + .join(', ') || '(no arcs)'} +

+
{/if} +{#if draft.state.uncertain}

+ Save completion is uncertain. Retry the original arc save before discarding this draft. +

{/if} +{#if draft.state.error}

{draft.state.error}

{/if} + + diff --git a/ui/src/lib/components/editor/scriptImpactNotice.ts b/ui/src/lib/components/editor/scriptImpactNotice.ts index 46201502..9f486ee9 100644 --- a/ui/src/lib/components/editor/scriptImpactNotice.ts +++ b/ui/src/lib/components/editor/scriptImpactNotice.ts @@ -1,6 +1,8 @@ import type { ScriptImpactCause } from '$lib/scriptTypes.js'; export function scriptImpactCauseLabel(cause: ScriptImpactCause): string { + if (cause.input.kind === 'timeline_node' && cause.dependency_id.endsWith('.arc_membership')) + return 'Story arc assignment changed.'; if ( cause.input.kind === 'timeline_node' && cause.dependency_id.endsWith(`.timeline_title.${cause.input.node_id}`) diff --git a/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts b/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts new file mode 100644 index 00000000..299b11a7 --- /dev/null +++ b/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts @@ -0,0 +1,140 @@ +import { expect, it, vi } from 'vitest'; +import { createTimelineArcsDraft } from './timelineArcsDraft.svelte.js'; +import type { + SetTimelineNodeArcsCommand, + TimelineArcMembershipInput, +} from '$lib/timelineCommandTypes.js'; +const initial: TimelineArcMembershipInput = { + node_id: 'clip', + arc_ids: [], + revision_event_id: null, +}; +function fixture() { + const apply = vi.fn().mockImplementation(async (payload: SetTimelineNodeArcsCommand) => ({ + ...initial, + arc_ids: [...payload.arc_ids], + revision_event_id: 'ack', + })); + const read = vi + .fn() + .mockResolvedValue({ ...initial, arc_ids: ['current'], revision_event_id: 'new' }); + const commandId = vi.fn().mockReturnValueOnce('save-1').mockReturnValueOnce('save-2'); + const draft = createTimelineArcsDraft({ nodeId: 'clip', apply, read, commandId }); + draft.observe(initial); + return { draft, apply, read, commandId }; +} +it('assigns and clears only on explicit saves; reordered sets produce no write', async () => { + const { draft, apply } = fixture(); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + draft.state.arcIds = ['b', 'a']; + draft.observe(initial); + expect(apply).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply).toHaveBeenCalledWith( + { node_id: 'clip', arc_ids: ['a', 'b'], expected: initial }, + 'save-1', + ); + draft.state.arcIds = ['a', 'b']; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); + draft.state.arcIds = []; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(2); + expect(apply.mock.calls[1]![0].expected.arc_ids).toEqual(['a', 'b']); +}); +it('retains dirty intent for changed saved membership and same-set ABA', async () => { + for (const current of [ + { ...initial, arc_ids: ['other'], revision_event_id: 'other' }, + { ...initial, revision_event_id: 'restored' }, + ]) { + const { draft, apply } = fixture(); + draft.state.arcIds = ['mine']; + draft.observe(current); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + expect(draft.state.arcIds).toEqual(['mine']); + expect(draft.state.base).toEqual(initial); + } +}); +it('discard requires successful explicit same-clip read', async () => { + const { draft, read, apply } = fixture(); + draft.state.arcIds = ['mine']; + read.mockRejectedValueOnce(new Error('Unavailable')); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['mine']); + read.mockResolvedValueOnce({ ...initial, node_id: 'other' }); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['mine']); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['current']); + expect(apply).not.toHaveBeenCalled(); +}); +it('retains immutable arrays and original command across uncertain transport and state mutation', async () => { + const { draft, apply, read, commandId } = fixture(); + draft.state.arcIds = ['mine']; + draft.state.base!.arc_ids.push('forged'); + apply.mockImplementationOnce(async (p: SetTimelineNodeArcsCommand) => { + p.arc_ids.push('mutated'); + p.expected.arc_ids.push('mutated'); + throw new Error('Lost ack'); + }); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + draft.state.arcIds = ['later']; + await draft.reload(); + expect(read).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual([ + { node_id: 'clip', arc_ids: ['mine'], expected: initial }, + 'save-1', + ]); + expect(commandId).toHaveBeenCalledTimes(1); + expect(draft.state.arcIds).toEqual(['later']); + expect(draft.state.base?.arc_ids).toEqual(['mine']); +}); +it('acknowledges original receipt without adopting newer canonical membership', async () => { + const { draft, apply } = fixture(); + let resolve!: (r: TimelineArcMembershipInput) => void; + apply.mockReturnValueOnce(new Promise((done) => (resolve = done))); + draft.state.arcIds = ['submitted']; + const pending = draft.apply(); + draft.observe({ ...initial, arc_ids: ['concurrent'], revision_event_id: 'newer' }); + draft.state.arcIds = ['later']; + resolve({ ...initial, arc_ids: ['submitted'], revision_event_id: 'ack' }); + await pending; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); + expect(draft.state.arcIds).toEqual(['later']); + expect(draft.state.current?.arc_ids).toEqual(['concurrent']); +}); +it('mismatched receipts remain uncertain and exact retries retain identity', async () => { + for (const receipt of [ + null, + { ...initial, node_id: 'other', arc_ids: ['mine'], revision_event_id: 'bad' }, + { ...initial, arc_ids: ['wrong'], revision_event_id: 'bad' }, + { ...initial, arc_ids: ['mine'] }, + ]) { + const { draft, apply } = fixture(); + draft.state.arcIds = ['mine']; + apply.mockResolvedValueOnce(receipt); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual(apply.mock.calls[0]); + } +}); +it('only typed precommit refusals release retry custody', async () => { + for (const proven of [true, false]) { + const { draft, apply, read } = fixture(); + draft.state.arcIds = ['mine']; + const message = 'Arc assignment refused: stale'; + apply.mockRejectedValueOnce( + new Error(message, proven ? { cause: { kind: 'conflict', message } } : undefined), + ); + await draft.apply(); + expect(draft.state.uncertain).toBe(!proven); + await draft.reload(); + expect(read).toHaveBeenCalledTimes(proven ? 1 : 0); + } +}); diff --git a/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts b/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts new file mode 100644 index 00000000..cc58fbb4 --- /dev/null +++ b/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts @@ -0,0 +1,135 @@ +import type { + TimelineArcMembershipInput, + SetTimelineNodeArcsCommand, +} from '$lib/timelineCommandTypes.js'; + +const copy = (input: TimelineArcMembershipInput): TimelineArcMembershipInput => ({ + ...input, + arc_ids: [...input.arc_ids], +}); +export const sameArcIds = (a: string[], b: string[]) => + a.length === b.length && [...a].sort().every((id, i) => id === [...b].sort()[i]); +const same = (a: TimelineArcMembershipInput | null, b: TimelineArcMembershipInput | null) => + !!a && + !!b && + a.node_id === b.node_id && + sameArcIds(a.arc_ids, b.arc_ids) && + a.revision_event_id === b.revision_event_id; + +/** Per-clip explicit intent; transport receives copies of privately retained retry custody. */ +export function createTimelineArcsDraft(options: { + nodeId: string; + read: () => Promise; + apply: ( + payload: SetTimelineNodeArcsCommand, + id: string, + ) => Promise; + commandId: () => string; +}) { + const state = $state({ + base: null as TimelineArcMembershipInput | null, + current: null as TimelineArcMembershipInput | null, + arcIds: [] as string[], + busy: false, + uncertain: false, + saved: false, + error: null as string | null, + }); + let base: TimelineArcMembershipInput | null = null; + let submission: { payload: SetTimelineNodeArcsCommand; id: string } | null = null; + function useRead(read: TimelineArcMembershipInput) { + base = copy(read); + state.base = copy(read); + state.current = copy(read); + state.arcIds = [...read.arc_ids]; + state.saved = false; + state.error = null; + } + function observe(read: TimelineArcMembershipInput | null | undefined) { + if (!read || read.node_id !== options.nodeId) return; + if (same(base, read)) state.current = copy(read); + else if (!submission && !state.busy && (!base || sameArcIds(state.arcIds, base.arc_ids))) + useRead(read); + else state.current = copy(read); + } + async function reload() { + if (state.busy || submission) return; + const ids = [...state.arcIds]; + state.busy = true; + state.error = null; + try { + const read = await options.read(); + if (!read || read.node_id !== options.nodeId) + throw new Error('Current arc assignment is unavailable; save the project and read again.'); + if (!sameArcIds(state.arcIds, ids)) + throw new Error('The draft changed during the read; your assignment was kept.'); + useRead(read); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.busy = false; + } + } + async function apply() { + if (state.busy || !base) return; + if (!submission) { + if (!same(base, state.current)) { + state.error = + 'Saved arc assignment changed. Your draft is kept; discard it and read current arcs before saving.'; + return; + } + if (sameArcIds(state.arcIds, base.arc_ids)) return; + submission = { + id: options.commandId(), + payload: { + node_id: options.nodeId, + arc_ids: [...state.arcIds].sort(), + expected: copy(base), + }, + }; + } + const accepted = submission; + state.busy = true; + state.error = null; + state.saved = false; + try { + const receipt = await options.apply( + { + ...accepted.payload, + arc_ids: [...accepted.payload.arc_ids], + expected: copy(accepted.payload.expected), + }, + accepted.id, + ); + if ( + !receipt || + receipt.node_id !== options.nodeId || + !sameArcIds(receipt.arc_ids, accepted.payload.arc_ids) || + !receipt.revision_event_id + ) + throw new Error( + 'The save receipt is unavailable. Retry this exact save to recover its result.', + ); + if (same(state.current, accepted.payload.expected)) state.current = copy(receipt); + base = copy(receipt); + state.base = copy(receipt); + submission = null; + state.uncertain = false; + state.saved = true; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + const native = error instanceof Error ? error.cause : null; + const refused = + typeof native === 'object' && + native !== null && + 'kind' in native && + (native.kind === 'conflict' || native.kind === 'bad_request') && + state.error.startsWith('Arc assignment refused: '); + state.uncertain = !refused; + if (refused) submission = null; + } finally { + state.busy = false; + } + } + return { state, observe, reload, apply }; +} diff --git a/ui/src/lib/propagationProposalTypes.ts b/ui/src/lib/propagationProposalTypes.ts index f321b505..bbac191f 100644 --- a/ui/src/lib/propagationProposalTypes.ts +++ b/ui/src/lib/propagationProposalTypes.ts @@ -1,3 +1,4 @@ +import type { TimelineArcMembershipInput } from './timelineCommandTypes.js'; import type { StoryArcFieldInput } from './storyArcTypes.js'; import type { BibleGraphFieldKey, @@ -72,6 +73,8 @@ export interface PropagationProposal { ancestor_notes_previous?: TimelineNotesInput[] | null; ancestor_notes_current?: TimelineNotesInput[] | null; ancestor_notes_absence_revisions?: [string, string][] | null; + arc_membership_previous?: TimelineArcMembershipInput | null; + arc_membership_current?: TimelineArcMembershipInput | null; timeline_notes_previous?: TimelineNotesInput | null; timeline_notes_current?: TimelineNotesInput | null; arc_inputs?: StoryArcFieldInput[] | null; diff --git a/ui/src/lib/selectedNodeEditorTypes.ts b/ui/src/lib/selectedNodeEditorTypes.ts index c1eb185e..cea68740 100644 --- a/ui/src/lib/selectedNodeEditorTypes.ts +++ b/ui/src/lib/selectedNodeEditorTypes.ts @@ -1,5 +1,9 @@ import type { TimelineNotesInput } from './propagationProposalTypes.js'; -import type { TimelineNodeNameRead, TimelineNodeRangeRead } from './timelineCommandTypes.js'; +import type { + TimelineArcMembershipInput, + TimelineNodeNameRead, + TimelineNodeRangeRead, +} from './timelineCommandTypes.js'; import type { BeatType, ContentStatus, NodeId, StoryLevel } from './timelineTypes.js'; export interface SelectedNodeEditorProjection { @@ -27,6 +31,7 @@ export interface SelectedNodeEditorNode { locked: boolean; range_read?: TimelineNodeRangeRead | null; name_read?: TimelineNodeNameRead | null; + arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; } diff --git a/ui/src/lib/stores/README.md b/ui/src/lib/stores/README.md index 47624f85..26f84a48 100644 --- a/ui/src/lib/stores/README.md +++ b/ui/src/lib/stores/README.md @@ -89,6 +89,7 @@ continues. | `propagationProposalProjection.svelte.ts` | Projection cache and command bridge | Caches backend proposal list and replaces cache from command responses with stale-response guards. | Keep. | | `timelineTitleSession.svelte.ts` | Transient UI state | Exact title intent and immutable uncertain retry within the editor session. | Canonical names/history/dependencies remain backend-owned. | | `timelineNotesSession.svelte.ts` | Transient UI state | Per-clip exact Notes drafts, original owned author reads and immutable save retries within the existing editor session. Explicit Save uses the existing canonical Notes command; drafts never become agent inputs before acceptance. | +| `timelineArcsSession.svelte.ts` | Session-scoped per-clip arc assignment drafts, immutable retries and owned save receipts; canonical membership remains in SQLite/projections. | | `timelinePlacementSession.svelte.ts` | Transient UI state | Captured per-node placement intent and immutable uncertain retry within the editor session. | Canonical placement and review remain backend/projection-owned. | | `scriptBlockEditSession.svelte.ts` | Transient UI state | Retains independent block text drafts, captured revisions and immutable pending edits across Script consumer replacement. | Keep session scoped; canonical text and propagation remain backend/projection-owned. | | `scriptBlockCreationSession.svelte.ts` | Transient UI state | Retains captured creation context, text and immutable pending payload/ID across Script consumer lifetimes; project activation replaces its owner. | Keep session scoped; canonical screenplay remains backend-owned and projection-backed. | diff --git a/ui/src/lib/stores/timelineArcsSession.svelte.test.ts b/ui/src/lib/stores/timelineArcsSession.svelte.test.ts new file mode 100644 index 00000000..af1c5499 --- /dev/null +++ b/ui/src/lib/stores/timelineArcsSession.svelte.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi, beforeEach } from 'vitest'; +import { getSessionTimelineArcsDraft } from './timelineArcsSession.svelte.js'; +import { editorState, resetEditorState } from './editor.svelte.js'; +import { applyTimelineNodeArcsCommand } from './timelineRenderProjection.svelte.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +vi.mock('./timelineRenderProjection.svelte.js', () => ({ applyTimelineNodeArcsCommand: vi.fn() })); +vi.mock('$lib/projectionApi.js', () => ({ getSelectedNodeEditorProjection: vi.fn() })); +vi.mock('./selectedNodeEditorProjection.svelte.js', () => ({ + refreshSelectedNodeEditorProjection: vi.fn(), +})); +vi.mock('./scriptDocumentProjection.svelte.js', () => ({ invalidateScriptContext: vi.fn() })); +const node: SelectedNodeEditorNode = { + node_id: 'A', + name: 'A', + level: 'Scene', + sort_order: 0, + start_ms: 1000, + end_ms: 2000, + notes: 'Saved A', + content_status: 'HasContent', + locked: false, + arc_read: { node_id: 'A', arc_ids: [], revision_event_id: null }, +}; +beforeEach(() => { + resetEditorState(); + vi.clearAllMocks(); + vi.mocked(applyTimelineNodeArcsCommand).mockResolvedValue({ + outcome: 'recorded', + projection: {} as never, + arc_read: { ...node.arc_read!, arc_ids: ['Accepted'], revision_event_id: 'ack' }, + }); + vi.mocked(refreshSelectedNodeEditorProjection).mockResolvedValue({} as never); +}); +it('retains two exact clip drafts through disposal/re-entry without saving; reopening clears custody', async () => { + const first = getSessionTimelineArcsDraft(node); + first.observe(node.arc_read); + first.state.arcIds = ['Draft A']; + const b = { ...node, node_id: 'B', arc_read: { ...node.arc_read!, node_id: 'B' } }; + const second = getSessionTimelineArcsDraft(b); + second.observe(b.arc_read); + second.state.arcIds = ['Draft B']; + expect(getSessionTimelineArcsDraft({ ...node })).toBe(first); + expect(first.state.arcIds).toEqual(['Draft A']); + expect(second.state.arcIds).toEqual(['Draft B']); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); + resetEditorState(); + const reopened = getSessionTimelineArcsDraft(node); + reopened.observe(node.arc_read); + expect(reopened).not.toBe(first); + expect(reopened.state.arcIds).toEqual([]); + await first.apply(); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); + expect(first.state.error).toContain('project changed'); +}); +it('keeps acknowledged save distinct from failed refresh and guards selection/session', async () => { + editorState.selectedNodeId = 'A'; + vi.mocked(refreshSelectedNodeEditorProjection).mockRejectedValueOnce( + new Error('Refresh unavailable'), + ); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Accepted']; + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(invalidateScriptContext).toHaveBeenCalledTimes(1); + const guard = vi.mocked(refreshSelectedNodeEditorProjection).mock.calls[0]![1]!; + expect(guard()).toBe(true); + editorState.selectedNodeId = 'B'; + expect(guard()).toBe(false); + resetEditorState(); + editorState.selectedNodeId = 'A'; + expect(guard()).toBe(false); +}); +it('reads a background owner without retargeting selection or writing', async () => { + editorState.selectedNodeId = 'B'; + vi.mocked(getSelectedNodeEditorProjection).mockResolvedValueOnce({ + payload: { node: { ...node, arc_read: { ...node.arc_read!, arc_ids: ['Current'] } } }, + } as never); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Unsaved']; + await draft.reload(); + expect(draft.state.arcIds).toEqual(['Current']); + expect(editorState.selectedNodeId).toBe('B'); + expect(refreshSelectedNodeEditorProjection).not.toHaveBeenCalled(); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); +}); +it('rejects old-project read completion without replacing retained text', async () => { + let resolve!: (value: never) => void; + vi.mocked(getSelectedNodeEditorProjection).mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }), + ); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Unsaved']; + const pending = draft.reload(); + resetEditorState(); + resolve({ payload: { node } } as never); + await pending; + expect(draft.state.arcIds).toEqual(['Unsaved']); + expect(draft.state.error).toContain('project changed'); +}); diff --git a/ui/src/lib/stores/timelineArcsSession.svelte.ts b/ui/src/lib/stores/timelineArcsSession.svelte.ts new file mode 100644 index 00000000..26a58e9b --- /dev/null +++ b/ui/src/lib/stores/timelineArcsSession.svelte.ts @@ -0,0 +1,62 @@ +import { untrack } from 'svelte'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +import { createCommandId } from '$lib/commandTransport.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { createTimelineArcsDraft } from '$lib/components/editor/timelineArcsDraft.svelte.js'; +import { editorState, getEditorSessionGeneration } from './editor.svelte.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { applyTimelineNodeArcsCommand } from './timelineRenderProjection.svelte.js'; + +let session = -1; +type ArcsDrafts = Record | undefined>; +let drafts = Object.create(null) as ArcsDrafts; + +/** Navigation retains each clip's intent; closing/reopening a project ends custody. */ +export function getSessionTimelineArcsDraft(node: SelectedNodeEditorNode) { + return untrack(() => { + const admitted = getEditorSessionGeneration(); + if (session !== admitted) { + session = admitted; + drafts = Object.create(null) as ArcsDrafts; + } + let draft = drafts[node.node_id]; + if (!draft) { + const assertCurrent = () => { + if (admitted !== getEditorSessionGeneration()) + throw new Error('The project changed before this arc assignment.'); + }; + draft = createTimelineArcsDraft({ + nodeId: node.node_id, + commandId: createCommandId, + async read() { + assertCurrent(); + // Independent read never retargets the selected projection cache. + const projection = await getSelectedNodeEditorProjection({ node_id: node.node_id }); + assertCurrent(); + const read = projection.payload.node; + return read?.node_id === node.node_id ? (read.arc_read ?? null) : null; + }, + async apply(payload, commandId) { + assertCurrent(); + const response = await applyTimelineNodeArcsCommand(payload, commandId); + assertCurrent(); + invalidateScriptContext(); + if (editorState.selectedNodeId === node.node_id) { + // Projection refresh failure cannot turn an owned save receipt into + // an uncertain acknowledgement. This cache reports its own errors. + void refreshSelectedNodeEditorProjection( + node.node_id, + () => + admitted === getEditorSessionGeneration() && + editorState.selectedNodeId === node.node_id, + ).catch(() => {}); + } + return response.arc_read ?? null; + }, + }); + drafts[node.node_id] = draft; + } + return draft; + }); +} diff --git a/ui/src/lib/stores/timelineRenderProjection.svelte.ts b/ui/src/lib/stores/timelineRenderProjection.svelte.ts index 7d985fd0..4992aacc 100644 --- a/ui/src/lib/stores/timelineRenderProjection.svelte.ts +++ b/ui/src/lib/stores/timelineRenderProjection.svelte.ts @@ -6,6 +6,7 @@ import { deleteTimelineNode, deleteTimelineRelationship, setTimelineNodeLock, + setTimelineNodeArcs, setTimelineNodeNotes, setTimelineNodeName, setTimelineNodeRange, @@ -24,6 +25,7 @@ import type { DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, + SetTimelineNodeArcsCommand, SetTimelineNodeNotesCommand, SetTimelineNodeNameCommand, SetTimelineNodeRangeCommand, @@ -197,6 +199,17 @@ export async function applyTimelineNodeLockCommand( ); } +export async function applyTimelineNodeArcsCommand( + payload: SetTimelineNodeArcsCommand, + commandId?: CommandId, +): Promise { + return runTimelineProjectionRequest( + () => setTimelineNodeArcs(payload, commandId), + (result) => result.projection, + 'Failed to apply timeline arc assignment command', + ); +} + export async function applyTimelineNodeNotesCommand( payload: SetTimelineNodeNotesCommand, commandId?: CommandId, diff --git a/ui/src/lib/timelineCommandApi.ts b/ui/src/lib/timelineCommandApi.ts index 5ab6bf1d..f8a1cccd 100644 --- a/ui/src/lib/timelineCommandApi.ts +++ b/ui/src/lib/timelineCommandApi.ts @@ -8,6 +8,7 @@ import type { DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNotesCommand, + SetTimelineNodeArcsCommand, SetTimelineNodeNameCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, @@ -23,6 +24,17 @@ export function setTimelinePlayhead(positionMs: number): Promise { + const command: CommandEnvelope = { + id: commandId, + payload, + }; + + return invokeDesktop('command_timeline_node_arcs', { command }); +} export function setTimelineNodeName( payload: SetTimelineNodeNameCommand, commandId = createCommandId(), diff --git a/ui/src/lib/timelineCommandTypes.ts b/ui/src/lib/timelineCommandTypes.ts index 15dc2553..f8443893 100644 --- a/ui/src/lib/timelineCommandTypes.ts +++ b/ui/src/lib/timelineCommandTypes.ts @@ -1,3 +1,14 @@ +export interface TimelineArcMembershipInput { + node_id: string; + arc_ids: string[]; + revision_event_id: string | null; +} +export interface SetTimelineNodeArcsCommand { + node_id: string; + arc_ids: string[]; + expected: TimelineArcMembershipInput; +} + import type { TimelineNotesInput } from './propagationProposalTypes.js'; import type { BeatType, RelationshipId, RelationshipType, StoryLevel } from './timelineTypes.js'; import type { CommandOutcome, ProjectionEnvelope } from './projectionTypes.js'; @@ -92,6 +103,7 @@ export interface DeleteTimelineRelationshipCommand { } export interface TimelineCommandResponse { + arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; outcome: CommandOutcome; projection: ProjectionEnvelope; From 001356c69daca37ad89851d8640722d711c0267d Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 19:35:21 +0000 Subject: [PATCH 4/6] feat(timeline): reorder adjacent siblings with continuity review [skip ci] --- crates/core/src/contracts/mod.rs | 9 +- .../src/contracts/selected_node_editor.rs | 3 + crates/core/src/contracts/timeline_command.rs | 30 ++ crates/core/src/timeline/mod.rs | 1 + crates/core/src/timeline/sibling_reorder.rs | 150 ++++++++ .../src/timeline/sibling_reorder_tests.rs | 103 ++++++ crates/server/src/command_service.rs | 6 +- crates/server/src/command_service_timeline.rs | 80 ++++- crates/server/src/lib.rs | 2 + crates/server/src/projection_service.rs | 3 + crates/server/src/timeline_command.rs | 1 + crates/server/src/timeline_sibling_order.rs | 131 +++++++ .../src/timeline_sibling_order_tests.rs | 320 ++++++++++++++++++ .../src/timeline_sibling_reorder_history.rs | 102 ++++++ .../agent-story-workflows/execution-ledger.md | 8 + .../timeline-sibling-reorder-qualification.md | 29 ++ src-tauri/src/commands/timeline.rs | 15 +- src-tauri/src/lib.rs | 1 + ui/src/lib/commandApi.ts | 1 + .../lib/components/editor/BeatEditor.svelte | 2 + .../TimelineSiblingReorderEditor.svelte | 117 +++++++ .../timelineReorderDraft.svelte.test.ts | 180 ++++++++++ .../editor/timelineReorderDraft.svelte.ts | 192 +++++++++++ ui/src/lib/selectedNodeEditorTypes.ts | 2 + ui/src/lib/stores/README.md | 1 + .../stores/timelineRenderProjection.svelte.ts | 13 + .../timelineReorderSession.svelte.test.ts | 109 ++++++ .../stores/timelineReorderSession.svelte.ts | 62 ++++ ui/src/lib/timelineCommandApi.ts | 9 + ui/src/lib/timelineCommandTypes.ts | 21 ++ 30 files changed, 1691 insertions(+), 12 deletions(-) create mode 100644 crates/core/src/timeline/sibling_reorder.rs create mode 100644 crates/core/src/timeline/sibling_reorder_tests.rs create mode 100644 crates/server/src/timeline_sibling_order.rs create mode 100644 crates/server/src/timeline_sibling_order_tests.rs create mode 100644 crates/server/src/timeline_sibling_reorder_history.rs create mode 100644 docs/reports/timeline-sibling-reorder-qualification.md create mode 100644 ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte create mode 100644 ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts create mode 100644 ui/src/lib/components/editor/timelineReorderDraft.svelte.ts create mode 100644 ui/src/lib/stores/timelineReorderSession.svelte.test.ts create mode 100644 ui/src/lib/stores/timelineReorderSession.svelte.ts diff --git a/crates/core/src/contracts/mod.rs b/crates/core/src/contracts/mod.rs index ffec74c8..434d9704 100644 --- a/crates/core/src/contracts/mod.rs +++ b/crates/core/src/contracts/mod.rs @@ -132,10 +132,11 @@ pub use story_arc::{ pub use timeline_command::{ ApplyTimelineChildCommand, ApplyTimelineChildrenCommand, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, - DeleteTimelineRelationshipCommand, SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, - SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, - SplitTimelineNodeCommand, TimelineArcMembershipInput, TimelineNodeNameRead, - TimelineNodeRangeRead, + DeleteTimelineRelationshipCommand, ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, + SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, + SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, TimelineArcMembershipInput, + TimelineNodeNameRead, TimelineNodeRangeRead, TimelineSiblingOrderRead, + TimelineSiblingPlacementRead, }; pub use timeline_render::{ TimelineRenderAffectSample, TimelineRenderClip, TimelineRenderGap, TimelineRenderProjection, diff --git a/crates/core/src/contracts/selected_node_editor.rs b/crates/core/src/contracts/selected_node_editor.rs index 16c80de0..261c9c5b 100644 --- a/crates/core/src/contracts/selected_node_editor.rs +++ b/crates/core/src/contracts/selected_node_editor.rs @@ -47,6 +47,8 @@ pub struct SelectedNodeEditorNode { pub notes_read: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub arc_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_read: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -149,6 +151,7 @@ impl From<&StoryNode> for SelectedNodeEditorNode { name_read: None, notes_read: None, arc_read: None, + order_read: None, } } } diff --git a/crates/core/src/contracts/timeline_command.rs b/crates/core/src/contracts/timeline_command.rs index 227e5e4a..fe222c4d 100644 --- a/crates/core/src/contracts/timeline_command.rs +++ b/crates/core/src/contracts/timeline_command.rs @@ -23,6 +23,36 @@ pub struct SetTimelineNodeArcsCommand { use crate::timeline::node::{BeatType, NodeId, StoryLevel}; use crate::timeline::relationship::{RelationshipId, RelationshipType}; +/// Canonical sibling order/geometry read, including structural membership ABA. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineSiblingOrderRead { + pub node_id: NodeId, + pub parent_id: Option, + pub level: StoryLevel, + pub membership_revision_event_id: Option, + pub siblings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineSiblingPlacementRead { + pub node_id: NodeId, + pub name: String, + pub start_ms: u64, + pub end_ms: u64, + pub sort_order: u32, + pub revision_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReorderTimelineSiblingCommand { + pub node_id: NodeId, + pub neighbor_id: NodeId, + pub expected: TimelineSiblingOrderRead, +} + /// Exact canonical title and its owned field clock; None is a known baseline. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/core/src/timeline/mod.rs b/crates/core/src/timeline/mod.rs index 8fa9d1e5..76b96898 100644 --- a/crates/core/src/timeline/mod.rs +++ b/crates/core/src/timeline/mod.rs @@ -1,5 +1,6 @@ pub mod node; pub mod relationship; +pub mod sibling_reorder; pub mod structure; pub mod timing; pub mod track; diff --git a/crates/core/src/timeline/sibling_reorder.rs b/crates/core/src/timeline/sibling_reorder.rs new file mode 100644 index 00000000..0fb9b80d --- /dev/null +++ b/crates/core/src/timeline/sibling_reorder.rs @@ -0,0 +1,150 @@ +//! Atomic adjacent sibling order and placement, preserving authored content. +use super::{Timeline, node::NodeId, timing::TimeRange}; +use crate::{Error, Result}; + +/// Swap an earlier/later pair without changing durations, gap or outer bounds. +pub fn swapped_adjacent_ranges( + first: TimeRange, + second: TimeRange, +) -> Result<(TimeRange, TimeRange)> { + first.validate()?; + second.validate()?; + if first.end_ms > second.start_ms { + return Err(Error::InvalidOperation( + "Reorder requires non-overlapping adjacent clips".into(), + )); + } + let second_end = first + .start_ms + .checked_add(second.duration_ms()) + .ok_or_else(|| Error::InvalidOperation("Reorder range overflow".into()))?; + let first_start = second_end + .checked_add(second.start_ms - first.end_ms) + .ok_or_else(|| Error::InvalidOperation("Reorder range overflow".into()))?; + Ok(( + TimeRange::new(first_start, second.end_ms)?, + TimeRange::new(first.start_ms, second_end)?, + )) +} + +impl Timeline { + /// Reorder one clip with its adjacent sibling. All validation precedes mutation. + /// Content locks retain their AI meaning and do not prohibit structural moves. + pub fn reorder_adjacent_nodes(&mut self, node_id: NodeId, neighbor_id: NodeId) -> Result<()> { + let node = self.node(node_id)?.clone(); + let neighbor = self.node(neighbor_id)?.clone(); + if node_id == neighbor_id + || node.parent_id.is_none() + || node.parent_id != neighbor.parent_id + || node.level != neighbor.level + { + return Err(Error::InvalidOperation( + "Reorder requires two distinct clips on the same parent and story-level track" + .into(), + )); + } + let mut siblings = self + .nodes + .iter() + .filter(|candidate| { + candidate.parent_id == node.parent_id && candidate.level == node.level + }) + .collect::>(); + siblings.sort_by_key(|candidate| (candidate.time_range.start_ms, candidate.id.0)); + let index = siblings + .iter() + .position(|candidate| candidate.id == node_id) + .expect("node is sibling"); + let neighbor_index = siblings + .iter() + .position(|candidate| candidate.id == neighbor_id) + .expect("neighbor is sibling"); + if index.abs_diff(neighbor_index) != 1 { + return Err(Error::InvalidOperation( + "Selected clips are no longer adjacent".into(), + )); + } + let (first, second) = if index < neighbor_index { + (&node, &neighbor) + } else { + (&neighbor, &node) + }; + let outer = TimeRange::new(first.time_range.start_ms, second.time_range.end_ms)?; + if siblings.iter().any(|candidate| { + candidate.id != node_id + && candidate.id != neighbor_id + && candidate.time_range.overlaps(&outer) + }) { + return Err(Error::InvalidOperation( + "Reorder cannot cross overlapping sibling clips".into(), + )); + } + siblings.sort_by_key(|candidate| { + ( + candidate.sort_order, + candidate.time_range.start_ms, + candidate.id.0, + ) + }); + let first_index = siblings + .iter() + .position(|candidate| candidate.id == first.id) + .expect("first sibling"); + if first.sort_order > second.sort_order + || siblings + .get(first_index + 1) + .is_none_or(|candidate| candidate.id != second.id) + { + return Err(Error::InvalidOperation("Screen placement and sibling order disagree; review their placement before reordering".into())); + } + let parent = self.node(node.parent_id.expect("parent checked"))?; + if outer.start_ms < parent.time_range.start_ms + || outer.end_ms > parent.time_range.end_ms + || outer.end_ms > self.total_duration_ms + { + return Err(Error::InvalidOperation( + "Reorder must remain inside the parent and timeline".into(), + )); + } + let (first_range, second_range) = + swapped_adjacent_ranges(first.time_range, second.time_range)?; + let mut plan = Vec::new(); + for (root, range) in [(first, first_range), (second, second_range)] { + let offset = i128::from(range.start_ms) - i128::from(root.time_range.start_ms); + plan.push((root.id, range)); + for child in self.descendants_of(root.id) { + child.time_range.validate()?; + let owner = self.node(child.parent_id.expect("descendant parent"))?; + if child.time_range.start_ms < owner.time_range.start_ms + || child.time_range.end_ms > owner.time_range.end_ms + { + return Err(Error::InvalidOperation( + "Reorder requires every child to remain contained by its parent".into(), + )); + } + let shifted = |value: u64| { + u64::try_from(i128::from(value) + offset).map_err(|_| { + Error::InvalidOperation("Reorder descendant range overflow".into()) + }) + }; + plan.push(( + child.id, + TimeRange::new( + shifted(child.time_range.start_ms)?, + shifted(child.time_range.end_ms)?, + )?, + )); + } + } + for (id, range) in plan { + self.node_mut(id)?.time_range = range; + } + self.node_mut(first.id)?.sort_order = second.sort_order; + self.node_mut(second.id)?.sort_order = first.sort_order; + Ok(()) + } +} + +#[cfg(test)] +#[path = "sibling_reorder_tests.rs"] +mod tests; diff --git a/crates/core/src/timeline/sibling_reorder_tests.rs b/crates/core/src/timeline/sibling_reorder_tests.rs new file mode 100644 index 00000000..19376fd7 --- /dev/null +++ b/crates/core/src/timeline/sibling_reorder_tests.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::timeline::{ + node::{StoryLevel, StoryNode}, + structure::EpisodeStructure, +}; +fn fixture() -> (Timeline, NodeId, NodeId, NodeId) { + let mut t = Timeline::new(1000, EpisodeStructure::standard_30_min()); + let p = StoryNode::new( + "Parent", + StoryLevel::Premise, + TimeRange::new(0, 1000).unwrap(), + ); + let parent = p.id; + t.add_node(p).unwrap(); + let mut a = StoryNode::new_child( + "A", + StoryLevel::Act, + TimeRange::new(100, 300).unwrap(), + parent, + ); + a.sort_order = 1; + a.locked = true; + a.content.notes = " exact 雨\n".into(); + let first = a.id; + t.add_node(a).unwrap(); + let mut b = StoryNode::new_child( + "B", + StoryLevel::Act, + TimeRange::new(400, 700).unwrap(), + parent, + ); + b.sort_order = 2; + let second = b.id; + t.add_node(b).unwrap(); + let c = StoryNode::new_child( + "Child", + StoryLevel::Sequence, + TimeRange::new(150, 250).unwrap(), + first, + ); + let child = c.id; + t.add_node(c).unwrap(); + (t, first, second, child) +} +#[test] +fn swap_translates_children_preserves_content_and_is_reversible() { + let (mut t, a, b, c) = fixture(); + let before = serde_json::to_value(&t).unwrap(); + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!( + t.node(a).unwrap().time_range, + TimeRange::new(500, 700).unwrap() + ); + assert_eq!( + t.node(b).unwrap().time_range, + TimeRange::new(100, 400).unwrap() + ); + assert_eq!( + t.node(c).unwrap().time_range, + TimeRange::new(550, 650).unwrap() + ); + assert!(t.node(a).unwrap().locked); + assert_eq!(t.node(a).unwrap().content.notes, " exact 雨\n"); + assert_eq!( + t.children_of(t.node(a).unwrap().parent_id.unwrap())[0].id, + b + ); + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!(serde_json::to_value(&t).unwrap(), before); +} +#[test] +fn equal_sort_order_uses_chronological_tie_break() { + let (mut t, a, b, _) = fixture(); + t.node_mut(b).unwrap().sort_order = 1; + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!(t.siblings_of(a)[0].id, b); +} +#[test] +fn malformed_overlap_child_and_order_refuse_without_mutation() { + for case in 0..4 { + let (mut t, a, b, c) = fixture(); + match case { + 0 => t.node_mut(b).unwrap().time_range.start_ms = 200, + 1 => t.node_mut(c).unwrap().time_range.end_ms = 350, + 2 => t.node_mut(b).unwrap().sort_order = 0, + _ => t.node_mut(a).unwrap().time_range.end_ms = 50, + }; + let before = serde_json::to_value(&t).unwrap(); + assert!(t.reorder_adjacent_nodes(a, b).is_err()); + assert_eq!(serde_json::to_value(&t).unwrap(), before); + } +} +#[test] +fn extreme_ranges_are_exact_without_float_rounding() { + let (a, b) = swapped_adjacent_ranges( + TimeRange::new(1, u64::MAX / 2).unwrap(), + TimeRange::new(u64::MAX / 2 + 7, u64::MAX).unwrap(), + ) + .unwrap(); + assert_eq!(a.duration_ms(), u64::MAX / 2 - 1); + assert_eq!(b.duration_ms(), u64::MAX - (u64::MAX / 2 + 7)); + assert_eq!(a.start_ms - b.end_ms, 7); +} diff --git a/crates/server/src/command_service.rs b/crates/server/src/command_service.rs index f0d111c9..bd6ab670 100644 --- a/crates/server/src/command_service.rs +++ b/crates/server/src/command_service.rs @@ -42,9 +42,9 @@ pub use crate::command_service_timeline::{ create_timeline_child_from_parent, create_timeline_child_from_parent_core_command, create_timeline_node, create_timeline_node_from_core_command, create_timeline_relationship, create_timeline_relationship_from_core_command, delete_timeline_node, - delete_timeline_relationship, set_timeline_node_arcs, set_timeline_node_lock, - set_timeline_node_name, set_timeline_node_notes, set_timeline_node_range, split_timeline_node, - split_timeline_node_from_core_command, + delete_timeline_relationship, reorder_timeline_sibling, set_timeline_node_arcs, + set_timeline_node_lock, set_timeline_node_name, set_timeline_node_notes, + set_timeline_node_range, split_timeline_node, split_timeline_node_from_core_command, }; #[derive(Debug, Serialize)] diff --git a/crates/server/src/command_service_timeline.rs b/crates/server/src/command_service_timeline.rs index a68e0a40..e0740cab 100644 --- a/crates/server/src/command_service_timeline.rs +++ b/crates/server/src/command_service_timeline.rs @@ -1,9 +1,10 @@ use eidetic_core::contracts::{ ApplyTimelineChildCommand, CommandEnvelope, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, - DeleteTimelineRelationshipCommand, ObjectKind, ProjectionEnvelope, SetTimelineNodeArcsCommand, - SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, - SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, TimelineRenderProjection, + DeleteTimelineRelationshipCommand, ObjectKind, ProjectionEnvelope, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, + SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + SplitTimelineNodeCommand, TimelineRenderProjection, }; use eidetic_core::timeline::Timeline; use rusqlite::Connection; @@ -30,6 +31,8 @@ pub struct TimelineCommandResponse { notes_read: Option, #[serde(skip_serializing_if = "Option::is_none")] arc_read: Option, + #[serde(skip_serializing_if = "Option::is_none")] + order_read: Option, projection: ProjectionEnvelope, } @@ -93,6 +96,7 @@ async fn create_timeline_node_at_admission( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -158,6 +162,7 @@ pub async fn set_timeline_node_name( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -213,6 +218,7 @@ pub async fn set_timeline_node_range( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -255,6 +261,7 @@ pub async fn set_timeline_node_lock( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -313,6 +320,7 @@ pub async fn set_timeline_node_arcs( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: Some(arc_read), notes_read: None, projection, @@ -371,6 +379,7 @@ pub async fn set_timeline_node_notes( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: Some(notes_read), projection, @@ -429,6 +438,7 @@ pub async fn delete_timeline_node( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -481,6 +491,7 @@ pub async fn delete_timeline_relationship( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -531,6 +542,7 @@ pub async fn create_timeline_relationship_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -576,6 +588,7 @@ pub async fn apply_timeline_children( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -677,6 +690,7 @@ pub async fn split_timeline_node_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, arc_read: None, notes_read: None, projection, @@ -785,3 +799,63 @@ fn map_timeline_command_error(error: TimelineCommandError) -> BackendError { #[cfg(test)] #[path = "timeline_command_admission_tests.rs"] mod admission_tests; + +pub async fn reorder_timeline_sibling( + state: &AppState, + command: CommandEnvelope, +) -> Result { + let (_session, path, project) = timeline_command_project(state).await?; + let worker = state.clone(); + crate::state::complete_project_session_work(state, _session, async move { + let state = &worker; + let response = tokio::task::spawn_blocking(move || { + let mut conn = crate::sqlite::open_write_connection(&path) + .map_err(|e| BackendError::internal(e.to_string()))?; + history_store::create_schema(&conn).map_err(map_history_error)?; + let outcome = timeline_command::record_reorder_timeline_sibling_history( + &mut conn, &project, &command, 0, + ) + .map_err(|error| { + let error = map_timeline_command_error(error); + // Only pre-commit domain/receipt refusals carry this marker. + // Projection/publication failures after commit remain uncertain. + match error { + BackendError::BadRequest(message) => { + BackendError::bad_request(format!("Reorder refused: {message}")) + } + BackendError::Conflict(message) => { + BackendError::conflict(format!("Reorder refused: {message}")) + } + other => other, + } + })?; + let order_read = crate::timeline_sibling_order::command_receipt(&conn, &command) + .map_err(map_history_error)?; + let projection = + timeline_render_projection_from_current_state(&conn, &project.timeline) + .map_err(map_timeline_command_error)?; + Ok::<_, BackendError>(TimelineCommandResponse { + outcome, + order_read: Some(order_read), + arc_read: None, + notes_read: None, + projection, + }) + }) + .await + .map_err(|error| { + BackendError::internal(format!( + "timeline sibling reorder command task failed: {error}" + )) + })??; + + if response.outcome == RecordChangeOutcome::Recorded { + let _ = state.events_tx.send(ServerEvent::TimelineChanged); + let _ = state.events_tx.send(ServerEvent::HierarchyChanged); + let _ = state.events_tx.send(ServerEvent::ScriptChanged); + state.trigger_save(); + } + Ok(response) + }) + .await +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index a1aa94d1..f0333881 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -119,6 +119,8 @@ pub(crate) mod timeline_node_split_history; pub(crate) mod timeline_node_store; mod timeline_notes_lineage; pub(crate) mod timeline_relationship_store; +mod timeline_sibling_order; +mod timeline_sibling_reorder_history; pub(crate) mod timeline_title_lineage; pub mod validation; pub(crate) mod vector_store; diff --git a/crates/server/src/projection_service.rs b/crates/server/src/projection_service.rs index a1304e8b..d6a500ad 100644 --- a/crates/server/src/projection_service.rs +++ b/crates/server/src/projection_service.rs @@ -383,6 +383,9 @@ fn load_selected_node_editor_at_path( .as_mut() .filter(|node| canonical_node_ids.contains(&node.node_id)) { + node.order_read = Some( + crate::timeline_sibling_order::capture(&tx, node.node_id).map_err(map_history_error)?, + ); node.arc_read = Some( crate::timeline_arc_membership::capture(&tx, node.node_id) .map_err(map_history_error)?, diff --git a/crates/server/src/timeline_command.rs b/crates/server/src/timeline_command.rs index 7f4b2127..ac2e1b29 100644 --- a/crates/server/src/timeline_command.rs +++ b/crates/server/src/timeline_command.rs @@ -27,6 +27,7 @@ pub(crate) use crate::timeline_command_history::{ }; pub(crate) use crate::timeline_node_delete_history::record_delete_timeline_node_history; pub(crate) use crate::timeline_node_split_history::record_split_timeline_node_history; +pub(crate) use crate::timeline_sibling_reorder_history::record_reorder_timeline_sibling_history; #[cfg(test)] pub(crate) fn apply_set_timeline_node_range( diff --git a/crates/server/src/timeline_sibling_order.rs b/crates/server/src/timeline_sibling_order.rs new file mode 100644 index 00000000..69892497 --- /dev/null +++ b/crates/server/src/timeline_sibling_order.rs @@ -0,0 +1,131 @@ +//! Owned sibling snapshots and immutable command receipts; no parallel order state. +use crate::{history_store::HistoryStoreError, timeline_node_store}; +use eidetic_core::{ + contracts::*, + timeline::{node::NodeId, timing::TimeRange}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +fn invalid(message: &str) -> HistoryStoreError { + HistoryStoreError::InvalidValue(message.into()) +} +pub(crate) fn capture( + conn: &Connection, + node: NodeId, +) -> Result { + capture_excluding(conn, node, None) +} +pub(crate) fn capture_excluding( + conn: &Connection, + node: NodeId, + exclude: Option, +) -> Result { + let nodes = timeline_node_store::load_nodes(conn)?; + let selected = nodes + .iter() + .find(|n| n.id == node) + .ok_or_else(|| invalid("Clip no longer exists"))?; + let mut siblings = nodes + .iter() + .filter(|n| n.parent_id == selected.parent_id && n.level == selected.level) + .map(|n| { + Ok(TimelineSiblingPlacementRead { + node_id: n.id, + name: n.name.clone(), + start_ms: n.time_range.start_ms, + end_ms: n.time_range.end_ms, + sort_order: n.sort_order, + revision_event_id: timeline_node_store::latest_node_event(conn, n.id, exclude)?, + }) + }) + .collect::, HistoryStoreError>>()?; + siblings.sort_by_key(|n| (n.start_ms, n.node_id.0)); + let membership:Option=conn.query_row("SELECT r.change_event_id FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id JOIN change_events e ON e.id=r.change_event_id WHERE r.object_kind='timeline_node' AND f.field_key='parent_id' AND (f.old_text=?1 OR f.new_text=?1) AND (?2 IS NULL OR e.id != ?2) ORDER BY e.rowid DESC,r.rowid DESC LIMIT 1",params![selected.parent_id.map(|id|id.0.to_string()),exclude.map(|id|id.0.to_string())],|r|r.get(0)).optional()?; + Ok(TimelineSiblingOrderRead { + node_id: node, + parent_id: selected.parent_id, + level: selected.level, + membership_revision_event_id: membership + .map(|id| { + uuid::Uuid::parse_str(&id) + .map(ChangeEventId) + .map_err(|e| HistoryStoreError::InvalidId(e.to_string())) + }) + .transpose()?, + siblings, + }) +} +pub(crate) fn command_receipt( + conn: &Connection, + command: &CommandEnvelope, +) -> Result { + let (event,kind,json):(String,String,String) = conn.query_row("SELECT e.id,c.payload_type,c.payload_json FROM change_events e JOIN commands c ON c.id=e.command_id WHERE e.command_id=?1",[command.id.0.to_string()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + if kind != "timeline.sibling_reorder" + || serde_json::from_str::(&json)? != command.payload + { + return Err(invalid( + "Reorder receipt command differs from owned history", + )); + } + let event = ChangeEventId( + uuid::Uuid::parse_str(&event).map_err(|e| HistoryStoreError::InvalidId(e.to_string()))?, + ); + let mut receipt = command.payload.expected.clone(); + let a = receipt + .siblings + .iter() + .position(|n| n.node_id == command.payload.node_id) + .ok_or_else(|| invalid("Missing original selected clip"))?; + let b = receipt + .siblings + .iter() + .position(|n| n.node_id == command.payload.neighbor_id) + .ok_or_else(|| invalid("Missing original neighbor"))?; + let (first, second) = if a < b { (a, b) } else { (b, a) }; + let (a_range, b_range) = eidetic_core::timeline::sibling_reorder::swapped_adjacent_ranges( + TimeRange::new( + receipt.siblings[first].start_ms, + receipt.siblings[first].end_ms, + ) + .map_err(|e| invalid(&e.to_string()))?, + TimeRange::new( + receipt.siblings[second].start_ms, + receipt.siblings[second].end_ms, + ) + .map_err(|e| invalid(&e.to_string()))?, + ) + .map_err(|e| invalid(&e.to_string()))?; + let first_order = receipt.siblings[first].sort_order; + receipt.siblings[first].sort_order = receipt.siblings[second].sort_order; + receipt.siblings[second].sort_order = first_order; + for (index, range) in [(first, a_range), (second, b_range)] { + let n = &mut receipt.siblings[index]; + n.start_ms = range.start_ms; + n.end_ms = range.end_ms; + n.revision_event_id = Some(event); + let stored_order:Option=conn.query_row("SELECT f.new_integer FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id WHERE r.object_kind='timeline_node' AND r.object_id=?1 AND r.change_event_id=?2 AND f.field_key='sort_order'",params![n.node_id.0.to_string(),event.0.to_string()],|r|r.get(0)).optional()?; + let previous_order = command + .payload + .expected + .siblings + .iter() + .find(|old| old.node_id == n.node_id) + .expect("original receipt node") + .sort_order; + if stored_order != (previous_order != n.sort_order).then_some(i64::from(n.sort_order)) { + return Err(invalid( + "Reorder order receipt differs from owned sparse history", + )); + } + for (field, value) in [("start_ms", range.start_ms), ("end_ms", range.end_ms)] { + let stored:i64=conn.query_row("SELECT f.new_integer FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id WHERE r.object_kind='timeline_node' AND r.object_id=?1 AND r.change_event_id=?2 AND f.field_key=?3",params![n.node_id.0.to_string(),event.0.to_string(),field],|r|r.get(0))?; + if u64::try_from(stored).ok() != Some(value) { + return Err(invalid("Reorder receipt differs from owned history")); + } + } + } + receipt.siblings.sort_by_key(|n| (n.start_ms, n.node_id.0)); + Ok(receipt) +} +#[cfg(test)] +#[path = "timeline_sibling_order_tests.rs"] +mod tests; diff --git a/crates/server/src/timeline_sibling_order_tests.rs b/crates/server/src/timeline_sibling_order_tests.rs new file mode 100644 index 00000000..2069f0d3 --- /dev/null +++ b/crates/server/src/timeline_sibling_order_tests.rs @@ -0,0 +1,320 @@ +use super::*; +use crate::{ + history_store::RecordChangeOutcome, timeline_node_store, + timeline_sibling_reorder_history::record_reorder_timeline_sibling_history as save, +}; +use eidetic_core::Project; +fn refresh(conn: &Connection, p: &mut Project) { + p.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); +} +fn command( + conn: &Connection, + a: NodeId, + b: NodeId, +) -> CommandEnvelope { + CommandEnvelope::new(ReorderTimelineSiblingCommand { + node_id: a, + neighbor_id: b, + expected: capture(conn, a).unwrap(), + }) +} +fn snapshot(conn: &Connection) -> (String, Vec) { + let materials = + serde_json::to_string(&crate::timeline_notes_lineage::tests::logical_rows(conn)).unwrap(); + let counts = [ + "commands", + "change_events", + "object_revisions", + "object_revision_fields", + ] + .map(|table| { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() + }); + (materials, counts.to_vec()) +} +#[test] +fn both_placements_preserve_authored_material_and_original_receipt_after_reorder_aba() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + refresh(&conn, &mut p); + let before = authored(&conn); + let cmd = command(&conn, a, b); + assert_eq!( + save(&mut conn, &p, &cmd, 50).unwrap(), + RecordChangeOutcome::Recorded + ); + let receipt = command_receipt(&conn, &cmd).unwrap(); + assert_eq!(authored(&conn), before); + assert_eq!(receipt.siblings[0].node_id, b); + assert_eq!(receipt.siblings[0].start_ms, 1000); + assert_eq!(receipt.siblings[1].start_ms, 4000); + refresh(&conn, &mut p); + let reverse = command(&conn, a, b); + save(&mut conn, &p, &reverse, 60).unwrap(); + refresh(&conn, &mut p); + let snap = snapshot(&conn); + assert_eq!( + save(&mut conn, &p, &cmd, 70).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &cmd).unwrap(), receipt); + assert_eq!(snapshot(&conn), snap); + let mut forged = cmd.clone(); + forged.payload.neighbor_id = a; + assert!(save(&mut conn, &p, &forged, 80).is_err()); + assert_eq!(snapshot(&conn), snap); + let stale = CommandEnvelope::new(cmd.payload.clone()); + assert!(save(&mut conn, &p, &stale, 90).is_err()); + assert_eq!(snapshot(&conn), snap); +} +#[test] +fn structural_moves_allow_content_locks_and_stale_neighbor_notes_refuse() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + refresh(&conn, &mut p); + let stale = command(&conn, a, b); + crate::timeline_command_history::record_set_timeline_node_notes_history( + &mut conn, + &p, + &CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: b, + notes: "new notes 雨".into(), + expected: None, + }), + 51, + ) + .unwrap(); + refresh(&conn, &mut p); + let before = snapshot(&conn); + assert!(save(&mut conn, &p, &stale, 52).is_err()); + assert_eq!(snapshot(&conn), before); + crate::timeline_command_history::record_set_timeline_node_lock_history( + &mut conn, + &p, + &CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: a, + locked: true, + }), + 53, + ) + .unwrap(); + refresh(&conn, &mut p); + let fresh = command(&conn, a, b); + save(&mut conn, &p, &fresh, 54).unwrap(); + refresh(&conn, &mut p); + assert!(p.timeline.node(a).unwrap().locked); +} +#[test] +fn reordered_scene_context_requires_targeted_review_and_explicit_acceptance() { + let (mut conn, mut p, blocks) = crate::script_context_scope::tests::fixture(); + refresh(&conn, &mut p); + let b = crate::script_context_scope::tests::node(&blocks[1]); + let f = crate::script_context_scope::tests::node(&blocks[2]); + let before = authored(&conn); + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, 51).unwrap(); + refresh(&conn, &mut p); + assert_eq!(authored(&conn), before); + let review = crate::script_context_scope::tests::target(&conn, &blocks[1]) + .impact + .unwrap(); + assert!(review.needs_review); + assert!( + review + .causes + .iter() + .any(|c| c.reason == ScriptImpactReason::ContextChanged) + ); + let req = crate::script_context_scope::tests::request(&conn, &blocks[1]); + let binding = crate::script_impact_review::capture(&conn, &req.payload).unwrap(); + crate::script_impact_review::record_proposal( + &mut conn, + &req, + binding, + "Labelled synthetic reordered-scene preview".into(), + 52, + ) + .unwrap(); + assert_eq!(authored(&conn), before); + crate::script_impact_review::tests::accept(&mut conn, &req).unwrap(); + assert!( + !crate::script_context_scope::tests::target(&conn, &blocks[1]) + .impact + .unwrap() + .needs_review + ); +} + +fn authored(conn: &Connection) -> String { + let doc = crate::script_store::load_document_projection( + conn, + &ScriptDocumentId::new("script.document.main").unwrap(), + ) + .unwrap() + .unwrap(); + let mut blocks = doc + .segments + .into_iter() + .flat_map(|s| s.blocks) + .collect::>(); + blocks.sort_by_key(|b| b.block.id.clone()); + serde_json::to_string(&blocks).unwrap() +} +#[test] +fn inserted_then_deleted_sibling_invalidates_original_read_without_writing() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + refresh(&conn, &mut p); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + let stale = command(&conn, a, b); + let created = NodeId::new(); + crate::timeline_command_history::record_create_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: created, + parent_id: p.timeline.node(a).unwrap().parent_id, + level: eidetic_core::timeline::node::StoryLevel::Scene, + name: "Temporary neighbor".into(), + start_ms: 2500, + end_ms: 3000, + beat_type: None, + }), + 55, + ) + .unwrap(); + refresh(&conn, &mut p); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: created }), + 56, + ) + .unwrap(); + refresh(&conn, &mut p); + let now = capture(&conn, a).unwrap(); + assert_eq!(now.siblings, stale.payload.expected.siblings); + assert_ne!( + now.membership_revision_event_id, + stale.payload.expected.membership_revision_event_id + ); + let before = snapshot(&conn); + assert!(save(&mut conn, &p, &stale, 57).is_err()); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn nested_source_bound_segments_translate_and_pending_acceptance_refuses_reorder_aba() { + let (mut conn, mut p, blocks) = crate::script_context_scope::tests::fixture(); + refresh(&conn, &mut p); + let b = crate::script_context_scope::tests::node(&blocks[1]); + let f = crate::script_context_scope::tests::node(&blocks[2]); + let child = NodeId::new(); + crate::timeline_command_history::record_create_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: child, + parent_id: Some(b), + level: eidetic_core::timeline::node::StoryLevel::Beat, + name: "Nested authored beat".into(), + start_ms: 4100, + end_ms: 4500, + beat_type: None, + }), + 50, + ) + .unwrap(); + refresh(&conn, &mut p); + let mut block = blocks[1].clone(); + block.source_node_id = Some(child.0.to_string()); + block.segment_id = ScriptSegmentId::new("segment.child").unwrap(); + block.block_id = ScriptBlockId::new("block.child").unwrap(); + block.segment_start_ms = 4100; + block.segment_end_ms = 4500; + block.text = " Nested author text 雨\n\n".into(); + crate::script_document_command::apply_set_script_block( + &mut conn, + &CommandEnvelope::new(block), + 51, + ) + .unwrap(); + let before = authored(&conn); + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, 52).unwrap(); + refresh(&conn, &mut p); + assert_eq!(authored(&conn), before); + assert_eq!(p.timeline.node(child).unwrap().time_range.start_ms, 2100); + let (start, end): (i64, i64) = conn + .query_row( + "SELECT start_ms,end_ms FROM script_segments WHERE id='segment.child'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!((start, end), (2100, 2500)); + let req = crate::script_context_scope::tests::request(&conn, &blocks[1]); + let binding = crate::script_impact_review::capture(&conn, &req.payload).unwrap(); + crate::script_impact_review::record_proposal( + &mut conn, + &req, + binding, + "Labelled synthetic pending reorder preview".into(), + 53, + ) + .unwrap(); + for time in [54, 55] { + let reverse = command(&conn, b, f); + save(&mut conn, &p, &reverse, time).unwrap(); + refresh(&conn, &mut p); + } + let before = snapshot(&conn); + assert!(crate::script_impact_review::tests::accept(&mut conn, &req).is_err()); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn pending_generation_refuses_reordered_then_restored_source_without_writes() { + let (mut conn, mut p, generation) = crate::script_generation_target::tests::fixture(); + refresh(&conn, &mut p); + let b = generation.payload.target_binding.as_ref().unwrap().node_id; + let f = NodeId(uuid::Uuid::from_u128(6)); + for time in [65, 66] { + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, time).unwrap(); + refresh(&conn, &mut p); + } + let before = snapshot(&conn); + assert!( + crate::script_document_command::apply_generated_script_block(&mut conn, &generation, 67) + .is_err() + ); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn original_receipt_replays_after_selected_clip_deletion() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + refresh(&conn, &mut p); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + let cmd = command(&conn, a, b); + save(&mut conn, &p, &cmd, 65).unwrap(); + let receipt = command_receipt(&conn, &cmd).unwrap(); + refresh(&conn, &mut p); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: a }), + 66, + ) + .unwrap(); + refresh(&conn, &mut p); + let before = snapshot(&conn); + assert_eq!( + save(&mut conn, &p, &cmd, 67).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &cmd).unwrap(), receipt); + assert_eq!(snapshot(&conn), before); +} diff --git a/crates/server/src/timeline_sibling_reorder_history.rs b/crates/server/src/timeline_sibling_reorder_history.rs new file mode 100644 index 00000000..d752ad9b --- /dev/null +++ b/crates/server/src/timeline_sibling_reorder_history.rs @@ -0,0 +1,102 @@ +//! Atomic two-subtree authoring and source-bound screenplay placement. +use crate::{ + history_store::{self, HistoryStoreError, RecordChangeOutcome}, + timeline_command::TimelineCommandError, + timeline_node_store, +}; +use eidetic_core::{Project, contracts::*}; +use rusqlite::Connection; +pub(crate) fn record_reorder_timeline_sibling_history( + conn: &mut Connection, + project: &Project, + command: &CommandEnvelope, + created_at_ms: u64, +) -> Result { + if let Some(outcome) = + history_store::check_recorded_command(conn, command, "timeline.sibling_reorder")? + { + return Ok(outcome); + } + if command.payload.expected.node_id != command.payload.node_id { + return Err(HistoryStoreError::InvalidValue( + "Sibling snapshot belongs to another clip".into(), + ) + .into()); + } + let mut next = project.timeline.clone(); + next.reorder_adjacent_nodes(command.payload.node_id, command.payload.neighbor_id)?; + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + format!( + "reorder {} with {}", + project.timeline.node(command.payload.node_id)?.name, + project.timeline.node(command.payload.neighbor_id)?.name + ), + ) + .with_created_at_ms(created_at_ms); + let mut revisions = Vec::new(); + let mut ranges = Vec::new(); + for old in &project.timeline.nodes { + let new = next.node(old.id)?; + if old.time_range != new.time_range || old.sort_order != new.sort_order { + let mut revision = ObjectRevision::new( + ObjectKind::TimelineNode, + old.id.0.to_string(), + event.id, + RevisionOperation::Update, + ); + if old.time_range != new.time_range { + for (field, old_value, new_value) in [ + ("start_ms", old.time_range.start_ms, new.time_range.start_ms), + ("end_ms", old.time_range.end_ms, new.time_range.end_ms), + ] { + revision = revision.with_field(FieldDelta::new( + field, + Some(FieldValue::Integer(crate::script_store_codec::to_i64( + old_value, field, + )?)), + Some(FieldValue::Integer(crate::script_store_codec::to_i64( + new_value, field, + )?)), + )); + } + ranges.push((new.id, new.time_range)); + } + if old.sort_order != new.sort_order { + revision = revision.with_field(FieldDelta::new( + "sort_order", + Some(FieldValue::Integer(i64::from(old.sort_order))), + Some(FieldValue::Integer(i64::from(new.sort_order))), + )); + } + revisions.push(revision); + } + } + crate::script_store::create_schema(conn)?; + Ok(history_store::record_change_with( + conn, + command, + "timeline.sibling_reorder", + &event, + &revisions, + |tx| { + crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?; + if crate::timeline_sibling_order::capture_excluding( + tx, + command.payload.node_id, + Some(event.id), + )? != command.payload.expected + { + return Err(HistoryStoreError::InvalidValue("Sibling placement changed; discard the reorder draft and read current siblings before applying".into())); + } + timeline_node_store::upsert_nodes_in_transaction(tx, &next.nodes)?; + crate::timeline_script_placement::sync_in_transaction( + tx, + &ranges, + event.id, + revisions.len(), + ) + }, + )?) +} diff --git a/docs/plans/agent-story-workflows/execution-ledger.md b/docs/plans/agent-story-workflows/execution-ledger.md index 4d891702..bfc6eeee 100644 --- a/docs/plans/agent-story-workflows/execution-ledger.md +++ b/docs/plans/agent-story-workflows/execution-ledger.md @@ -2082,3 +2082,11 @@ ZIP-byte verification are not claimed. Parent retains independent source/visual review and PR/merge/Library delivery. No new PR, external reviewer, credential or paid service. See arc-description-qualification.md for complete source/hash/test receipts. This successor changes documentation only; PR19–23/main stay unmerged. + +### Timeline sibling reorder milestone + +- Base: accepted arc assignment `1f45e152fe68b9dd34d8058a8c1064ba6de3437f`, preserving Notes, exact Bible text and missing-screenplay draft recovery checkpoints. +- Evidence before implementation: single-node drag/exact placement existed, but no atomic neighboring move authored both subtrees and hierarchy order. Child/hierarchy ordering uses `sort_order` before placement; screenplay context is chronological. Existing scene-order memory was already working and reused. +- Adds explicit adjacent **Move earlier/later → named pair preview → Apply reorder**, canonical two-subtree checked translation/order revisions, atomic source-bound screenplay placement, backend sibling/membership ABA reads and original immutable replay receipts. Existing continuity review/targeted acceptance handles downstream material; manual text and draft custody remain intact. Structural moves preserve content locks under the documented M4 policy. +- Local rendered qualification: real AppShell/Bible/timeline/screenplay plus public SQLite services, six final screenshots, labelled synthetic provider. Does not establish native runtime or real-model quality. Source/module receipts are post-run; earlier probes are explicitly retained separately. Independent final review and exact source/test/evidence seals are in the parent handoff. +- See `docs/reports/timeline-sibling-reorder-qualification.md`; external evidence `/workspace/scratch/timeline-sibling-reorder-evidence`. No paid CI, PR/merge, ONNX403 bypass or Pumas guard weakening. Split-containment policy remains open; this milestone does not complete M4 generally. diff --git a/docs/reports/timeline-sibling-reorder-qualification.md b/docs/reports/timeline-sibling-reorder-qualification.md new file mode 100644 index 00000000..a5736096 --- /dev/null +++ b/docs/reports/timeline-sibling-reorder-qualification.md @@ -0,0 +1,29 @@ +# Atomic timeline sibling reorder + +## Gap and scope + +Based on accepted arc-assignment source `1f45e152fe68b9dd34d8058a8c1064ba6de3437f` (tree `ac1cb02fad05d636d8b4dd4c99a89b8d0cb176a6`). + +The M4 authoring plan calls for shared validated manual/agent timeline mutations. Existing drag/exact placement moves one clip and already supports consumed scene-order memory. There was no atomic adjacent-sibling reorder command/editor. `Timeline::children_of` orders by `(sort_order, start_ms)` while screenplay context follows chronological segment placement. Moving one clip alone does not author both placements or their hierarchy order. This milestone adds that concrete operation; it does not claim scene-order lineage was previously absent or complete the deferred split-containment work. + +## Authored change and review + +The selected-clip editor provides **Move earlier/later**, a named pair preview and **Apply reorder**. Choosing a direction does not write. For the rendered fixture, B: Setup initially occupies 150000–300000 ms and A: Complication 300000–480000 ms. Moving A earlier produces A 150000–330000 and B 330000–480000. Each duration and the original gap/outer span are preserved. + +The core plans both subtrees before mutation, translates descendants by checked exact integer offsets, swaps root `sort_order`, and rejects different parents/levels, nonadjacent clips, overlap, inconsistent hierarchy/chronological adjacency and invalid child containment. Equal order values use the existing chronological tie-break. Content locks retain their AI-regeneration meaning: manual structural reorders preserve locks and remain allowed. + +The server uses existing command/object-revision history, canonical nodes and `timeline_script_placement`. Both roots and all changed descendants receive sparse placement/order revisions; source-bound screenplay segment geometry updates in the same SQLite transaction. Block text, spans, locks, block revisions and unrelated material remain untouched. Existing continuity dependencies identify affected generated scene material and supply the normal targeted preview/explicit acceptance workflow. No parallel reorder state, embedding dependency or automatic screenplay replacement was added. + +The backend-owned sibling read includes all sibling identities, names, placement/order and owned node clocks, plus the parent's structural membership clock. The writer checks that receipt and the full canonical timeline inside its transaction. Insert/delete ABA invalidates the old read. Recorded-command replay precedes current existence/lock checks, and the returned pair receipt is validated against the original stored command and sparse fields rather than reconstructed from current placement. Pending generation, preview and acceptance retain their existing stale/ABA guards. + +The UI keeps per-clip intent in the existing project session, with a privately copied immutable original pair/command for uncertain retry. Changed saved siblings retain draft intent and block stale fresh applies. Successful explicit reads discard the draft; they cannot discard an uncertain command. A confirmed acknowledgement survives asynchronous projection refresh or a later lock clock. Saved screenplay and an exact whitespace/Unicode manual draft survive navigation and downstream review. + +## Validation and qualification boundary + +Final maintained checks pass **127 core / 613 server** tests (zero ignored), **634 UI tests / 110 files**, strict core/server all-target Clippy, Rust/Tauri formatting, ESLint, Svelte diagnostics (0 errors / 0 warnings) and the static production build. New focused cases comprise 4 core, 7 server and 13 UI tests. + +Meaningful unit cases cover unequal durations/gap, nested translation, malformed/overlapping clips, equal hierarchy order, near-maximum safe UI timestamps, content locks, whole-sibling clocks, membership ABA, original retry after later reorder/deletion, immutable transport copies, project-session ownership, pending generation refusal and pending acceptance ABA. The rendered qualification runs actual AppShell/Bible/timeline/screenplay components through a compiled local adapter to public command/projection services and real SQLite. Its six final captures show unwritten pair intent, uncertain-but-committed state, retained conflicting intent, pending targeted review, stale acceptance, and explicit target-only acceptance. + +All provider responses are labelled synthetic fixtures. This is local Chromium/public-service qualification, not native Tauri/Bevy qualification or real-model execution/quality. The test adapter manually refreshes normal projections because it has no native event transport. Raw local source/current Vite module bytes were measured after the final run; they are not capture-time source attestation. Earlier local probe transcripts/captures are retained separately, including the corrected acknowledgement display and harness readiness/projection-refresh issues. No ONNX403 bypass, paid CI or Pumas identity-policy change occurred. + +Exact final source/tree, test totals, independent review, screenshot hashes and supporting evidence are bound in the external handoff/manifest under `/workspace/scratch/timeline-sibling-reorder-evidence`. Parent owns native qualification, PR/review/merge and Library delivery. Deferred split containment, project-switch recovery and optional embeddings remain outside this operation. diff --git a/src-tauri/src/commands/timeline.rs b/src-tauri/src/commands/timeline.rs index ece5cdd7..5b8ae0bb 100644 --- a/src-tauri/src/commands/timeline.rs +++ b/src-tauri/src/commands/timeline.rs @@ -1,7 +1,7 @@ use eidetic_core::contracts::{ CommandEnvelope, DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, - SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, - SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, + SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, }; use eidetic_server::command_service; use eidetic_server::projection_service; @@ -185,3 +185,14 @@ mod tests { assert_eq!(super::clamp_timeline_playhead(240_000, 120_000), 120_000); } } + +#[tauri::command] +pub async fn command_timeline_sibling_reorder( + app: tauri::AppHandle, + command: CommandEnvelope, +) -> Result { + let state = app.state::().inner().clone(); + command_service::reorder_timeline_sibling(&state, command) + .await + .map_err(CommandError::from) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b48c4eb1..efe57127 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,6 +156,7 @@ pub fn run() { commands::timeline::command_timeline_create_node, commands::timeline::command_timeline_create_child_from_parent, commands::timeline::command_timeline_node_arcs, + commands::timeline::command_timeline_sibling_reorder, commands::timeline::command_timeline_node_name, commands::timeline::command_timeline_node_range, commands::timeline::command_timeline_node_lock, diff --git a/ui/src/lib/commandApi.ts b/ui/src/lib/commandApi.ts index 34459dec..2a1323b4 100644 --- a/ui/src/lib/commandApi.ts +++ b/ui/src/lib/commandApi.ts @@ -75,6 +75,7 @@ export { setTimelinePlayhead, setTimelineNodeLock, setTimelineNodeNotes, + reorderTimelineSibling, setTimelineNodeArcs, setTimelineNodeName, setTimelineNodeRange, diff --git a/ui/src/lib/components/editor/BeatEditor.svelte b/ui/src/lib/components/editor/BeatEditor.svelte index 604382dc..fc199a47 100644 --- a/ui/src/lib/components/editor/BeatEditor.svelte +++ b/ui/src/lib/components/editor/BeatEditor.svelte @@ -28,6 +28,7 @@ import BeatEditorHeader from './BeatEditorHeader.svelte'; import TimelinePlacementEditor from './TimelinePlacementEditor.svelte'; import TimelineTitleEditor from './TimelineTitleEditor.svelte'; + import TimelineSiblingReorderEditor from './TimelineSiblingReorderEditor.svelte'; import TimelineArcAssignmentEditor from './TimelineArcAssignmentEditor.svelte'; import BeatNotesPanel from './BeatNotesPanel.svelte'; import BeatPlanningActions from './BeatPlanningActions.svelte'; @@ -335,6 +336,7 @@ + {/if} {#if childCreateError}

{childCreateError}

{/if} diff --git a/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte b/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte new file mode 100644 index 00000000..5d7b0939 --- /dev/null +++ b/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte @@ -0,0 +1,117 @@ + + +
+ Sibling order + + + {#if !earlier && !later}

No adjacent sibling clips on this track.

{/if} + {#if draft.state.neighborId} +

Unsaved reorder draft. Kept while you switch clips or panels in this project session.

+ {#if preview}

+ Swap with {draft.state.base?.siblings.find((n) => n.node_id === draft.state.neighborId) + ?.name}. Durations and the gap stay the same; children move with their clip. +

+ {#each preview.siblings.filter((n) => n.node_id === node.node_id || n.node_id === draft.state.neighborId) as clip (clip.node_id)}

+ {clip.name}: {clip.start_ms}–{clip.end_ms} ms +

{/each} + {:else}

+ These siblings overlap or their placement and order disagree. Read and adjust their + placement before reordering. +

{/if} + {/if} +
+
+ + +
+{#if changed}

+ Saved siblings changed since this draft was read. Your reorder draft is kept. +

{/if} +{#if draft.state.saved}

+ Sibling reorder saved. Affected generated screenplay can be reviewed before replacement. +

{/if} +{#if draft.state.uncertain}

+ Reorder completion is uncertain. Retry the original pair before discarding this draft. +

{/if} +{#if draft.state.error}

{draft.state.error}

{/if} + + diff --git a/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts b/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts new file mode 100644 index 00000000..7d0df482 --- /dev/null +++ b/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts @@ -0,0 +1,180 @@ +import { expect, it, vi } from 'vitest'; +import { createTimelineReorderDraft, reordered } from './timelineReorderDraft.svelte.js'; +import type { + ReorderTimelineSiblingCommand, + TimelineSiblingOrderRead, +} from '$lib/timelineCommandTypes.js'; +const initial: TimelineSiblingOrderRead = { + node_id: 'a', + parent_id: 'parent', + level: 'Scene', + membership_revision_event_id: null, + siblings: [ + { node_id: 'a', name: 'A', start_ms: 100, end_ms: 200, sort_order: 1, revision_event_id: null }, + { node_id: 'b', name: 'B', start_ms: 300, end_ms: 500, sort_order: 2, revision_event_id: null }, + ], +}; +function receipt(p: ReorderTimelineSiblingCommand) { + const r = reordered(p.expected, p.neighbor_id)!; + for (const n of r.siblings) n.revision_event_id = 'ack'; + return r; +} +function fixture() { + const apply = vi.fn().mockImplementation(async (p: ReorderTimelineSiblingCommand) => receipt(p)); + const read = vi.fn().mockResolvedValue(initial); + const commandId = vi.fn().mockReturnValue('swap-1'); + const draft = createTimelineReorderDraft({ nodeId: 'a', apply, read, commandId }); + draft.observe(initial); + return { draft, apply, read, commandId }; +} +it('choice is unwritten until explicit atomic save preserves duration/gap', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + expect(apply).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply).toHaveBeenCalledWith( + { node_id: 'a', neighbor_id: 'b', expected: initial }, + 'swap-1', + ); + expect(draft.state.base?.siblings.map((n) => [n.node_id, n.start_ms, n.end_ms])).toEqual([ + ['b', 100, 300], + ['a', 400, 500], + ]); + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); +}); +it('membership and owned-clock ABA keep draft and block stale write', async () => { + for (const current of [ + { ...initial, membership_revision_event_id: 'aba' }, + { + ...initial, + siblings: initial.siblings.map((n) => ({ ...n, revision_event_id: 'restored' })), + }, + ]) { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + draft.observe(current); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + expect(draft.state.neighborId).toBe('b'); + expect(draft.state.base).toEqual(initial); + } +}); +it('exact private pair survives transport/state mutation and newer observations', async () => { + const { draft, apply, read, commandId } = fixture(); + draft.state.neighborId = 'b'; + draft.state.base!.siblings[0]!.name = 'forged'; + apply.mockImplementationOnce(async (p: ReorderTimelineSiblingCommand) => { + p.expected.siblings[0]!.name = 'transport-mutated'; + p.neighbor_id = 'mutated'; + throw new Error('Lost ACK'); + }); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + draft.observe({ ...initial, membership_revision_event_id: 'newer' }); + await draft.reload(); + expect(read).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual([ + { node_id: 'a', neighbor_id: 'b', expected: initial }, + 'swap-1', + ]); + expect(commandId).toHaveBeenCalledTimes(1); + expect(draft.state.current?.membership_revision_event_id).toBe('newer'); + expect(draft.state.saved).toBe(true); +}); +it('only typed precommit refusal releases retry custody', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + apply.mockRejectedValueOnce(new Error('Reorder refused: stale', { cause: { kind: 'conflict' } })); + await draft.apply(); + expect(draft.state.uncertain).toBe(false); + expect(draft.state.neighborId).toBe('b'); + apply.mockRejectedValueOnce(new Error('Publication failed', { cause: { kind: 'conflict' } })); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); +}); +it('discard requires successful same-node explicit read', async () => { + const { draft, read, apply } = fixture(); + draft.state.neighborId = 'b'; + read.mockRejectedValueOnce(new Error('offline')); + await draft.reload(); + expect(draft.state.neighborId).toBe('b'); + read.mockResolvedValueOnce({ ...initial, node_id: 'other' }); + await draft.reload(); + expect(draft.state.neighborId).toBe('b'); + await draft.reload(); + expect(draft.state.neighborId).toBe(null); + expect(apply).not.toHaveBeenCalled(); +}); +it('malformed ACK retains exact retry', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + apply.mockResolvedValueOnce(initial); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + expect(draft.state.saved).toBe(false); + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(apply.mock.calls[0]![1]).toEqual(apply.mock.calls[1]![1]); +}); +it('overlap and absent pair cannot be submitted', () => { + expect( + reordered( + { ...initial, siblings: initial.siblings.map((n) => ({ ...n, start_ms: 100 })) }, + 'b', + ), + ).toBeNull(); + expect(reordered(initial, 'missing')).toBeNull(); +}); +it('mirrors hierarchy adjacency rather than offering chronologically adjacent mismatched order', () => { + const r = { + ...initial, + siblings: [ + { ...initial.siblings[0]! }, + { ...initial.siblings[1]!, sort_order: 3 }, + { + node_id: 'c', + name: 'C', + start_ms: 600, + end_ms: 700, + sort_order: 2, + revision_event_id: null, + }, + ], + }; + expect(reordered(r, 'b')).toBeNull(); +}); +it('near-max safe ranges preserve exact millisecond arithmetic without unsafe intermediate sums', () => { + const r = { + ...initial, + siblings: [ + { ...initial.siblings[0]!, start_ms: 9007199254740881, end_ms: 9007199254740901 }, + { ...initial.siblings[1]!, start_ms: 9007199254740931, end_ms: 9007199254740950 }, + ], + }; + expect(reordered(r, 'b')?.siblings.map((n) => [n.start_ms, n.end_ms])).toEqual([ + [9007199254740881, 9007199254740900], + [9007199254740930, 9007199254740950], + ]); +}); + +it('confirmed reorder remains acknowledged when a post-save projection adopts a newer lock clock', async () => { + const { draft } = fixture(); + draft.state.neighborId = 'b'; + await draft.apply(); + draft.observe({ + ...draft.state.base!, + siblings: draft.state.base!.siblings.map((n) => ({ + ...n, + revision_event_id: n.node_id === 'a' ? 'later-lock' : n.revision_event_id, + })), + }); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(draft.state.base?.siblings.find((n) => n.node_id === 'a')?.revision_event_id).toBe( + 'later-lock', + ); + await draft.reload(); + expect(draft.state.saved).toBe(false); +}); diff --git a/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts b/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts new file mode 100644 index 00000000..4b77104c --- /dev/null +++ b/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts @@ -0,0 +1,192 @@ +import type { + TimelineSiblingOrderRead, + ReorderTimelineSiblingCommand, +} from '$lib/timelineCommandTypes.js'; +const copy = (r: TimelineSiblingOrderRead): TimelineSiblingOrderRead => ({ + ...r, + siblings: r.siblings.map((n) => ({ ...n })), +}); +export const sameOrder = (a: TimelineSiblingOrderRead | null, b: TimelineSiblingOrderRead | null) => + !!a && + !!b && + a.node_id === b.node_id && + a.parent_id === b.parent_id && + a.level === b.level && + a.membership_revision_event_id === b.membership_revision_event_id && + a.siblings.length === b.siblings.length && + a.siblings.every((n, i) => { + const other = b.siblings[i]!; + return ( + n.node_id === other.node_id && + n.name === other.name && + n.start_ms === other.start_ms && + n.end_ms === other.end_ms && + n.sort_order === other.sort_order && + n.revision_event_id === other.revision_event_id + ); + }); +export function reordered( + read: TimelineSiblingOrderRead, + neighborId: string, +): TimelineSiblingOrderRead | null { + const result = copy(read); + const a = result.siblings.findIndex((n) => n.node_id === read.node_id); + const b = result.siblings.findIndex((n) => n.node_id === neighborId); + if (a < 0 || b < 0 || Math.abs(a - b) !== 1 || !read.parent_id) return null; + const first = result.siblings[Math.min(a, b)]!; + const second = result.siblings[Math.max(a, b)]!; + if (first.end_ms > second.start_ms || first.sort_order > second.sort_order) return null; + const hierarchy = [...result.siblings].sort( + (a, b) => + a.sort_order - b.sort_order || a.start_ms - b.start_ms || a.node_id.localeCompare(b.node_id), + ); + if (hierarchy[hierarchy.indexOf(first) + 1] !== second) return null; + if ( + !result.siblings.every( + (n) => [n.start_ms, n.end_ms].every(Number.isSafeInteger) && n.start_ms < n.end_ms, + ) + ) + return null; + const end = first.start_ms + (second.end_ms - second.start_ms); + const start = end + (second.start_ms - first.end_ms); + if (![start, end, first.start_ms, second.end_ms].every(Number.isSafeInteger)) return null; + const outerStart = first.start_ms, + outerEnd = second.end_ms; + if ( + result.siblings.some( + (n) => n !== first && n !== second && n.start_ms < outerEnd && n.end_ms > outerStart, + ) + ) + return null; + [first.sort_order, second.sort_order] = [second.sort_order, first.sort_order]; + first.end_ms = outerEnd; + first.start_ms = start; + second.start_ms = outerStart; + second.end_ms = end; + result.siblings.sort((a, b) => a.start_ms - b.start_ms || a.node_id.localeCompare(b.node_id)); + return result; +} +export function createTimelineReorderDraft(options: { + nodeId: string; + read: () => Promise; + apply: (p: ReorderTimelineSiblingCommand, id: string) => Promise; + commandId: () => string; +}) { + const state = $state({ + base: null as TimelineSiblingOrderRead | null, + current: null as TimelineSiblingOrderRead | null, + neighborId: null as string | null, + busy: false, + uncertain: false, + saved: false, + error: null as string | null, + }); + let base: TimelineSiblingOrderRead | null = null; + let submission: { payload: ReorderTimelineSiblingCommand; id: string } | null = null; + function useRead(read: TimelineSiblingOrderRead) { + base = copy(read); + state.base = copy(read); + state.current = copy(read); + state.neighborId = null; + state.saved = false; + state.error = null; + } + function observe(read: TimelineSiblingOrderRead | null | undefined) { + if (!read || read.node_id !== options.nodeId) return; + if (sameOrder(base, read)) state.current = copy(read); + else if (!submission && !state.busy && (!base || !state.neighborId)) { + const acknowledged = state.saved; + useRead(read); + state.saved = acknowledged; + } else state.current = copy(read); + } + async function reload() { + if (state.busy || submission) return; + const intent = state.neighborId; + state.busy = true; + state.error = null; + try { + const read = await options.read(); + if (!read || read.node_id !== options.nodeId) + throw new Error('Current siblings are unavailable; save the project and read again.'); + if (state.neighborId !== intent) + throw new Error('Reorder draft changed during the read; it was kept.'); + useRead(read); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.busy = false; + } + } + async function apply() { + if (state.busy || !base) return; + if (!submission) { + if (!sameOrder(base, state.current)) { + state.error = + 'Saved siblings changed. Your reorder draft is kept; discard it and read current siblings before applying.'; + return; + } + if (!state.neighborId || !reordered(base, state.neighborId)) return; + submission = { + id: options.commandId(), + payload: { node_id: options.nodeId, neighbor_id: state.neighborId, expected: copy(base) }, + }; + } + const accepted = submission; + state.busy = true; + state.error = null; + state.saved = false; + try { + const receipt = await options.apply( + { ...accepted.payload, expected: copy(accepted.payload.expected) }, + accepted.id, + ); + const expected = reordered(accepted.payload.expected, accepted.payload.neighbor_id); + if (!receipt || !expected) + throw new Error( + 'Reorder receipt unavailable. Retry this exact reorder to recover its result.', + ); + const pair = [accepted.payload.node_id, accepted.payload.neighbor_id]; + const clocks = receipt.siblings + .filter((n) => pair.includes(n.node_id)) + .map((n) => n.revision_event_id); + const normalized = copy(receipt); + for (const n of normalized.siblings) { + if (pair.includes(n.node_id)) + n.revision_event_id = expected.siblings.find( + (v) => v.node_id === n.node_id, + )!.revision_event_id; + } + if ( + clocks.length !== 2 || + !clocks[0] || + clocks[0] !== clocks[1] || + !sameOrder(normalized, expected) + ) + throw new Error( + 'Reorder receipt differs from the submitted pair. Retry the exact reorder.', + ); + if (sameOrder(state.current, accepted.payload.expected)) state.current = copy(receipt); + base = copy(receipt); + state.base = copy(receipt); + if (state.neighborId === accepted.payload.neighbor_id) state.neighborId = null; + submission = null; + state.uncertain = false; + state.saved = true; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + const native = error instanceof Error ? error.cause : null; + const refused = + typeof native === 'object' && + native !== null && + 'kind' in native && + (native.kind === 'conflict' || native.kind === 'bad_request') && + state.error.startsWith('Reorder refused: '); + state.uncertain = !refused; + if (refused) submission = null; + } finally { + state.busy = false; + } + } + return { state, observe, reload, apply }; +} diff --git a/ui/src/lib/selectedNodeEditorTypes.ts b/ui/src/lib/selectedNodeEditorTypes.ts index cea68740..2408aa9b 100644 --- a/ui/src/lib/selectedNodeEditorTypes.ts +++ b/ui/src/lib/selectedNodeEditorTypes.ts @@ -1,5 +1,6 @@ import type { TimelineNotesInput } from './propagationProposalTypes.js'; import type { + TimelineSiblingOrderRead, TimelineArcMembershipInput, TimelineNodeNameRead, TimelineNodeRangeRead, @@ -31,6 +32,7 @@ export interface SelectedNodeEditorNode { locked: boolean; range_read?: TimelineNodeRangeRead | null; name_read?: TimelineNodeNameRead | null; + order_read?: TimelineSiblingOrderRead | null; arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; } diff --git a/ui/src/lib/stores/README.md b/ui/src/lib/stores/README.md index 26f84a48..332036fb 100644 --- a/ui/src/lib/stores/README.md +++ b/ui/src/lib/stores/README.md @@ -89,6 +89,7 @@ continues. | `propagationProposalProjection.svelte.ts` | Projection cache and command bridge | Caches backend proposal list and replaces cache from command responses with stale-response guards. | Keep. | | `timelineTitleSession.svelte.ts` | Transient UI state | Exact title intent and immutable uncertain retry within the editor session. | Canonical names/history/dependencies remain backend-owned. | | `timelineNotesSession.svelte.ts` | Transient UI state | Per-clip exact Notes drafts, original owned author reads and immutable save retries within the existing editor session. Explicit Save uses the existing canonical Notes command; drafts never become agent inputs before acceptance. | +| `timelineReorderSession.svelte.ts` | Session-scoped per-clip sibling reorder intent, immutable retries and original owned receipts; canonical order and placement remain in SQLite/projections. | | `timelineArcsSession.svelte.ts` | Session-scoped per-clip arc assignment drafts, immutable retries and owned save receipts; canonical membership remains in SQLite/projections. | | `timelinePlacementSession.svelte.ts` | Transient UI state | Captured per-node placement intent and immutable uncertain retry within the editor session. | Canonical placement and review remain backend/projection-owned. | | `scriptBlockEditSession.svelte.ts` | Transient UI state | Retains independent block text drafts, captured revisions and immutable pending edits across Script consumer replacement. | Keep session scoped; canonical text and propagation remain backend/projection-owned. | diff --git a/ui/src/lib/stores/timelineRenderProjection.svelte.ts b/ui/src/lib/stores/timelineRenderProjection.svelte.ts index 4992aacc..ee12792c 100644 --- a/ui/src/lib/stores/timelineRenderProjection.svelte.ts +++ b/ui/src/lib/stores/timelineRenderProjection.svelte.ts @@ -6,6 +6,7 @@ import { deleteTimelineNode, deleteTimelineRelationship, setTimelineNodeLock, + reorderTimelineSibling, setTimelineNodeArcs, setTimelineNodeNotes, setTimelineNodeName, @@ -25,6 +26,7 @@ import type { DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeNotesCommand, SetTimelineNodeNameCommand, @@ -250,3 +252,14 @@ export function clearTimelineRenderProjection(): void { timelineRenderProjectionState.pending = false; timelineRenderProjectionState.error = undefined; } + +export async function applyTimelineSiblingReorderCommand( + payload: ReorderTimelineSiblingCommand, + commandId?: CommandId, +): Promise { + return runTimelineProjectionRequest( + () => reorderTimelineSibling(payload, commandId), + (result) => result.projection, + 'Failed to apply timeline sibling reorder command', + ); +} diff --git a/ui/src/lib/stores/timelineReorderSession.svelte.test.ts b/ui/src/lib/stores/timelineReorderSession.svelte.test.ts new file mode 100644 index 00000000..cc896377 --- /dev/null +++ b/ui/src/lib/stores/timelineReorderSession.svelte.test.ts @@ -0,0 +1,109 @@ +import { expect, it, vi, beforeEach } from 'vitest'; +import { getSessionTimelineReorderDraft } from './timelineReorderSession.svelte.js'; +import { editorState, resetEditorState } from './editor.svelte.js'; +import { applyTimelineSiblingReorderCommand } from './timelineRenderProjection.svelte.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { reordered } from '$lib/components/editor/timelineReorderDraft.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +vi.mock('./timelineRenderProjection.svelte.js', () => ({ + applyTimelineSiblingReorderCommand: vi.fn(), +})); +vi.mock('$lib/projectionApi.js', () => ({ getSelectedNodeEditorProjection: vi.fn() })); +vi.mock('./selectedNodeEditorProjection.svelte.js', () => ({ + refreshSelectedNodeEditorProjection: vi.fn(), +})); +vi.mock('./scriptDocumentProjection.svelte.js', () => ({ invalidateScriptContext: vi.fn() })); +const node: SelectedNodeEditorNode = { + node_id: 'a', + name: 'A', + level: 'Scene', + sort_order: 1, + start_ms: 100, + end_ms: 200, + notes: 'A', + content_status: 'HasContent', + locked: true, + order_read: { + node_id: 'a', + parent_id: 'parent', + level: 'Scene', + membership_revision_event_id: null, + siblings: [ + { + node_id: 'a', + name: 'A', + start_ms: 100, + end_ms: 200, + sort_order: 1, + revision_event_id: null, + }, + { + node_id: 'b', + name: 'B', + start_ms: 300, + end_ms: 500, + sort_order: 2, + revision_event_id: null, + }, + ], + }, +}; +beforeEach(() => { + resetEditorState(); + vi.clearAllMocks(); + vi.mocked(applyTimelineSiblingReorderCommand).mockImplementation(async (p) => { + const r = reordered(p.expected, p.neighbor_id)!; + for (const n of r.siblings) n.revision_event_id = 'ack'; + return { outcome: 'recorded', projection: {} as never, order_read: r }; + }); + vi.mocked(refreshSelectedNodeEditorProjection).mockResolvedValue({} as never); +}); +it('retains per-clip reorder across remount and ends old-session command custody', async () => { + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + const b = { ...node, node_id: 'b', order_read: { ...node.order_read!, node_id: 'b' } }; + const second = getSessionTimelineReorderDraft(b); + second.observe(b.order_read); + second.state.neighborId = 'a'; + expect(getSessionTimelineReorderDraft({ ...node })).toBe(draft); + expect(draft.state.neighborId).toBe('b'); + expect(second.state.neighborId).toBe('a'); + resetEditorState(); + expect(getSessionTimelineReorderDraft(node)).not.toBe(draft); + await draft.apply(); + expect(applyTimelineSiblingReorderCommand).not.toHaveBeenCalled(); + expect(draft.state.error).toContain('project changed'); +}); +it('locked manual structural reorder ACK survives refresh failure and late selection', async () => { + editorState.selectedNodeId = 'a'; + vi.mocked(refreshSelectedNodeEditorProjection).mockRejectedValueOnce(new Error('Refresh failed')); + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(invalidateScriptContext).toHaveBeenCalledTimes(1); + const guard = vi.mocked(refreshSelectedNodeEditorProjection).mock.calls[0]![1]!; + expect(guard()).toBe(true); + editorState.selectedNodeId = 'b'; + expect(guard()).toBe(false); + resetEditorState(); + editorState.selectedNodeId = 'a'; + expect(guard()).toBe(false); +}); +it('background explicit read discards only its draft without retargeting current clip', async () => { + editorState.selectedNodeId = 'b'; + vi.mocked(getSelectedNodeEditorProjection).mockResolvedValueOnce({ payload: { node } } as never); + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + await draft.reload(); + expect(draft.state.neighborId).toBeNull(); + expect(editorState.selectedNodeId).toBe('b'); + expect(refreshSelectedNodeEditorProjection).not.toHaveBeenCalled(); + expect(applyTimelineSiblingReorderCommand).not.toHaveBeenCalled(); +}); diff --git a/ui/src/lib/stores/timelineReorderSession.svelte.ts b/ui/src/lib/stores/timelineReorderSession.svelte.ts new file mode 100644 index 00000000..2b4d1576 --- /dev/null +++ b/ui/src/lib/stores/timelineReorderSession.svelte.ts @@ -0,0 +1,62 @@ +import { untrack } from 'svelte'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +import { createCommandId } from '$lib/commandTransport.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { createTimelineReorderDraft } from '$lib/components/editor/timelineReorderDraft.svelte.js'; +import { editorState, getEditorSessionGeneration } from './editor.svelte.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { applyTimelineSiblingReorderCommand } from './timelineRenderProjection.svelte.js'; + +let session = -1; +type ReorderDrafts = Record | undefined>; +let drafts = Object.create(null) as ReorderDrafts; + +/** Navigation retains each clip's intent; closing/reopening a project ends custody. */ +export function getSessionTimelineReorderDraft(node: SelectedNodeEditorNode) { + return untrack(() => { + const admitted = getEditorSessionGeneration(); + if (session !== admitted) { + session = admitted; + drafts = Object.create(null) as ReorderDrafts; + } + let draft = drafts[node.node_id]; + if (!draft) { + const assertCurrent = () => { + if (admitted !== getEditorSessionGeneration()) + throw new Error('The project changed before this sibling reorder.'); + }; + draft = createTimelineReorderDraft({ + nodeId: node.node_id, + commandId: createCommandId, + async read() { + assertCurrent(); + // Independent read never retargets the selected projection cache. + const projection = await getSelectedNodeEditorProjection({ node_id: node.node_id }); + assertCurrent(); + const read = projection.payload.node; + return read?.node_id === node.node_id ? (read.order_read ?? null) : null; + }, + async apply(payload, commandId) { + assertCurrent(); + const response = await applyTimelineSiblingReorderCommand(payload, commandId); + assertCurrent(); + invalidateScriptContext(); + if (editorState.selectedNodeId === node.node_id) { + // Projection refresh failure cannot turn an owned save receipt into + // an uncertain acknowledgement. This cache reports its own errors. + void refreshSelectedNodeEditorProjection( + node.node_id, + () => + admitted === getEditorSessionGeneration() && + editorState.selectedNodeId === node.node_id, + ).catch(() => {}); + } + return response.order_read ?? null; + }, + }); + drafts[node.node_id] = draft; + } + return draft; + }); +} diff --git a/ui/src/lib/timelineCommandApi.ts b/ui/src/lib/timelineCommandApi.ts index f8a1cccd..66366866 100644 --- a/ui/src/lib/timelineCommandApi.ts +++ b/ui/src/lib/timelineCommandApi.ts @@ -8,6 +8,7 @@ import type { DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNotesCommand, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeNameCommand, SetTimelineNodeRangeCommand, @@ -171,3 +172,11 @@ export function deleteTimelineNode( return invokeDesktop('command_timeline_delete_node', { command }); } + +export function reorderTimelineSibling( + payload: ReorderTimelineSiblingCommand, + commandId = createCommandId(), +): Promise { + const command: CommandEnvelope = { id: commandId, payload }; + return invokeDesktop('command_timeline_sibling_reorder', { command }); +} diff --git a/ui/src/lib/timelineCommandTypes.ts b/ui/src/lib/timelineCommandTypes.ts index f8443893..03fbeb2c 100644 --- a/ui/src/lib/timelineCommandTypes.ts +++ b/ui/src/lib/timelineCommandTypes.ts @@ -1,3 +1,23 @@ +export interface TimelineSiblingPlacementRead { + node_id: string; + name: string; + start_ms: number; + end_ms: number; + sort_order: number; + revision_event_id: string | null; +} +export interface TimelineSiblingOrderRead { + node_id: string; + parent_id: string | null; + level: StoryLevel; + membership_revision_event_id: string | null; + siblings: TimelineSiblingPlacementRead[]; +} +export interface ReorderTimelineSiblingCommand { + node_id: string; + neighbor_id: string; + expected: TimelineSiblingOrderRead; +} export interface TimelineArcMembershipInput { node_id: string; arc_ids: string[]; @@ -103,6 +123,7 @@ export interface DeleteTimelineRelationshipCommand { } export interface TimelineCommandResponse { + order_read?: TimelineSiblingOrderRead | null; arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; outcome: CommandOutcome; From a0a2982a5e9fa79c4cca87af1d861dbbfea11692 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 14 Oct 2026 22:43:54 +0000 Subject: [PATCH 5/6] fix(authoring): document integrated contracts and retain security gates --- .github/workflows/ci.yml | 8 + crates/core/src/contracts/README.md | 9 + crates/core/src/timeline/README.md | 9 + crates/server/src/README.md | 12 + docs/dependency-governance.md | 24 +- .../authoring-integration-readiness.md | 72 ++ docs/reports/pr29-dependency-audit.md | 188 +++ src-tauri/src/README.md | 8 + src-tauri/src/commands/README.md | 9 + ui/package-lock.json | 1078 +++++------------ ui/package.json | 8 +- ui/src/lib/README.md | 8 + ui/src/lib/components/sidebar/bible/README.md | 9 + 13 files changed, 648 insertions(+), 794 deletions(-) create mode 100644 docs/reports/authoring-integration-readiness.md create mode 100644 docs/reports/pr29-dependency-audit.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 347e7e64..af874165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,10 @@ jobs: working-directory: ui run: npm ci + - name: Frontend dependency audit + working-directory: ui + run: npm audit --audit-level=high + - name: Frontend lint working-directory: ui run: npm run lint @@ -108,6 +112,10 @@ jobs: working-directory: ui run: npm run test + - name: Frontend static build + working-directory: ui + run: npm run build + traceability: name: Decision Traceability runs-on: ubuntu-latest diff --git a/crates/core/src/contracts/README.md b/crates/core/src/contracts/README.md index 067b08ea..24022479 100644 --- a/crates/core/src/contracts/README.md +++ b/crates/core/src/contracts/README.md @@ -220,3 +220,12 @@ Timeline title authoring uses `SetTimelineNodeNameCommand` with a required exact Optional `TimelineTitleInput` receipts record exact consumed names and owned clocks in generation/proposal JSON. Legacy absence remains unknown; baseline clock None is a known canonical read. Recap source IDs avoid ambiguous name-based resolution. + +## Clip arc and sibling authoring contracts + +`SetTimelineNodeArcsCommand` carries the originally read assigned IDs and owned +field clock. `ReorderTimelineSiblingCommand` binds the selected node, adjacent +neighbor and original complete sibling/parent membership read. The selected +editor projects these reads through optional `arc_read` and `order_read` fields. +Unknown legacy evidence stays distinct from known empty membership; optional +consumption on screenplay receipts never backfills unobserved history. diff --git a/crates/core/src/timeline/README.md b/crates/core/src/timeline/README.md index 25c2988e..e4bcd168 100644 --- a/crates/core/src/timeline/README.md +++ b/crates/core/src/timeline/README.md @@ -67,3 +67,12 @@ use eidetic_core::timeline::Timeline; ## Structured Producer Contract - Timeline nodes, tracks, structure segments, and relationships are persisted and mirrored by the UI. - Ordering and enum semantics must stay stable unless accompanied by coordinated persistence and frontend updates. + +## Atomic adjacent sibling planning + +`sibling_reorder.rs` plans a swap before mutation: equal-parent and equal-level +adjacency, hierarchy/chronological agreement, duration/gap preservation, checked +translation of both subtrees and child containment. Invalid ranges fail without +changing nodes. The server owns revision receipts and atomic persisted writes; +core planning does not reconstruct concurrency evidence. Content locks retain +their AI-generation meaning and structural moves preserve locked content. diff --git a/crates/server/src/README.md b/crates/server/src/README.md index e5f111b3..b9a614ee 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -698,3 +698,15 @@ UserEdit history and rejects stale/ABA reads under the shared timeline writer gu recap titles actually supplied before provider I/O. Existing graph edges and proposal bindings support exact impact, stale/ABA refusal, historical/current or owned-deletion evidence, and explicit acceptance with refreshed consumed lineage. + +## Canonical arc assignment and sibling reorder + +`timeline_arc_membership` validates assigned IDs and owned field reads in the +existing history transaction. `timeline_sibling_order` validates the complete +sibling/parent membership read and applies the core two-subtree plan atomically +with source-bound screenplay placement. Sparse owned history and stored original +command receipts preserve replay after later edits, deletion or ABA. +`timeline_arc_assignment_history` and `timeline_sibling_reorder_history` validate +those original receipts instead of rebuilding them from today's timeline. +Existing generation/proposal dependency rows supply targeted review; saved +screenplay is replaced only by ordinary explicit acceptance. diff --git a/docs/dependency-governance.md b/docs/dependency-governance.md index a685c2b1..2bdcfbb3 100644 --- a/docs/dependency-governance.md +++ b/docs/dependency-governance.md @@ -50,7 +50,7 @@ EIDETIC_FETCH_PUMAS=1 scripts/prepare-pumas-dependency.sh Default source contract: - Repository: `https://github.com/MrScripty/Pumas-Library.git` -- Revision: `8444b50df28c3e2bd8db58fb3645fa4dd8664b27` +- Revision: `a94fd92021f27fdeedb6e2de6e01c41c250ef576` - Required package: `pumas-library` - Required local path: `../../ai-systems/Pumas-Library/rust/crates/pumas-core` @@ -68,9 +68,19 @@ private mirror or an already provisioned checkout. - Pumas remains a path dependency until the sibling repository has an agreed publication or git-dependency contract. CI must provision the exact expected checkout before running Cargo. -- npm currently reports low-severity `cookie <0.7.0` advisories through - `@sveltejs/kit`. The compatible `npm audit fix` path has already been applied; - npm's remaining `--force` suggestion would install `@sveltejs/kit@0.0.30`, so - it is rejected as a breaking downgrade. The dependency gate blocks - high/critical npm advisories and leaves this low advisory tracked here until a - compatible SvelteKit release resolves it. +- `cookie 0.6.0` remains a low-severity finding through SvelteKit's declared + `^0.6.0` dependency. The static desktop deployment has no Node HTTP cookie + serializer or server load/form endpoints; application input does not supply + cookie names, paths or domains. Do not override the producer's dependency + contract to make the audit count disappear. The frontend dependency owner must + select a supported parent update before adding server-side cookie handling or + accepting untrusted cookie metadata. See the exact finding and follow-up in + [PR29's dependency audit](reports/pr29-dependency-audit.md). + +The 2026-10-14 authoring integration carries the exact reviewed frontend repair +from PR29 commit `be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73`, without importing +its Pumas/model lifecycle changes. The security-relevant CI, package manifest, +lockfile and historical audit report match that source. The repair uses compatible +patches, SvelteKit 2.70.3 and coordinated Vitest/coverage 4.1.11, retaining Node +24.12.0 and Vite 6.x. The audit report's previous execution evidence belongs to +PR29; each new authoring integration must run its own checks and source CI. diff --git a/docs/reports/authoring-integration-readiness.md b/docs/reports/authoring-integration-readiness.md new file mode 100644 index 00000000..be2ba12b --- /dev/null +++ b/docs/reports/authoring-integration-readiness.md @@ -0,0 +1,72 @@ +# Authoring integration: source lineage and acceptance + +## Scope and merge order + +The user authorized preparing and merging useful retained branches. The parent +owns actual merges. These integrations preserve main and PR29 history; PR29 +`be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73` is not modified or pushed. +The canonical governing plan remains +`docs/plans/agent-story-workflows/plan.md`; this operation integrates its admitted +authoring slices and does not declare all M4 or trained-model work complete. + +Stage A starts at main `59793c1950916a067f6429db59bbc639fe3c4acf` and merges +`001356c69daca37ad89851d8640722d711c0267d` once, retaining the four source commits: + +| Source | Behavior | +| --- | --- | +| `9998e15d344de63d64e09c13cd827fabbdb76efb` | Preserve exact authored Bible freeform text. | +| `b8227132f8c086f78b7c846eca7e24c4d983a92c` | Retain unavailable screenplay drafts and require explicit copy/discard/retry. | +| `1f45e152fe68b9dd34d8058a8c1064ba6de3437f` | Author clip arc membership through the guarded canonical writer and targeted review. | +| `001356c69daca37ad89851d8640722d711c0267d` | Atomically reorder adjacent sibling subtrees, preserving authored material and original replay custody. | + +The accepted integration merge is `ed6b6fe7`; its parents retain both complete +histories. Main's architecture, native UI and canonical owners remain intact. + +Stage B will stack saved-block type product +`57b0d2ec4562017513f03d38698d93a17f87f43e` on Stage A. The historical qualifiers +`ee167a4e39f360e6e6191ed864eebb7874931e7e` and documentation seal +`3e22b31c41a6eb965dcfdf2b5bb68ad9c4a64f49` remain intact. Their frozen source +workflows are not reintroduced into current production or represented as current +acceptance. The staged PR targets Stage A so its review delta is disjoint. + +## Existing security repair + +Main's frontend lockfile retains the advisories already repaired and independently +reviewed on PR29. Stage A carries the exact functional security repair from +`be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73`: CI audit/build gates, package manifest, +lockfile and historical dependency-audit report. Those four files are copied +byte-for-byte; the dependency governance exception is reconciled with main's +actual `a94fd92021f27fdeedb6e2de6e01c41c250ef576` Pumas setup pin. This does not +import PR29's Pumas 0.8 model lifecycle or infer its trained-model gate passed. +The remaining low cookie finding retains the explicit supported-parent follow-up. + +## Current verification boundary + +Current tests, independent review, exact pushed heads and hosted CI are recorded +in the PRs and this integration's completion record. Historical branch test/native +reports retain their original source identities. Current verification must cover +exact text, missing draft custody, replay and ABA, clip membership, sibling +geometry/order and consumed block-type lineage on the composed source. + +The local executor has Rust 1.92.0, Node 24.12.0 and Chromium. Its native +glib/GTK/WebKit development libraries are absent. Hosted Linux/Windows workspace +CI supplies the full compilation/Clippy/test gate; local UI/public-service +qualification does not claim native Tauri/Bevy interaction or trained inference. +Synthetic test/provider responses remain explicitly synthetic. No trained-model +access, credentials, release or denial bypass belongs to this integration. + +PR29 compatibility is verified only in a disposable integration tree, resolving +additive documentation conflicts while preserving its producer pin, inference +lifecycle and reference prompt/retrieval surfaces. That tree is never pushed to +PR29. Its original remote SHA remains a protected postcondition. + +## Retained resources and retirement + +All useful source branches remain until parent merge acceptance. No history is +rewritten. Unique obsolete history needs a complete, verified durable recovery +bundle before any retirement; patch or ancestry labels alone are insufficient. +Stage worktrees are retained for exact-head parent review, owned by this delegated +integration, synchronized only by reviewed forward commits, and retired after +parent merge/rejection with their exact heads reachable from retained remote refs. +The isolated producer checkout exists only to test main's unchanged exact pin; +the user's existing Pumas checkout remains untouched. diff --git a/docs/reports/pr29-dependency-audit.md b/docs/reports/pr29-dependency-audit.md new file mode 100644 index 00000000..c507f921 --- /dev/null +++ b/docs/reports/pr29-dependency-audit.md @@ -0,0 +1,188 @@ +# PR29 dependency audit disposition + +## Identity and scope + +Reviewed on 2026-10-14 against PR29 head `1148fb0e44efc25420e1b71494eef6d7cae99840`. +Baseline `ui/package-lock.json` blob: `de5b74ee43de2d7f0c3b8fe46ce67eabe7e1f73f`; +SHA256: `71205506e0eae5f68915ce93faa124a6a46a911ab35a0ae1a0ad1afd6837700b`. +Repaired lockfile SHA256: +`99dfbea2e4f52337273b69856f59a4a5c1a0d8b99bb461e94ced87116927a657`. + +The baseline npm snapshot reported 14 high and 3 critical package rows, backed by +19 unique high/critical advisories. Inherited parent severity is distinguished +from an advisory against that parent's own code. The table accounts for all 17 +rows; package versions and parent paths come from the exact lock and `npm explain`. +Upstream GitHub advisories and published npm version/peer/engine metadata were +checked directly. `fixAvailable: false` in the initial npm result did not mean +that upstream lacked a compatible fixed release; the initial readiness report's +inference to that effect is superseded here. + +The write set is the UI manifest/lock, CI's frontend audit/static-build steps, +dependency governance and these readiness reports. No Rust/Pumas dependency or +application-source changes; credentials, model access and the trained-model gate +are unchanged. + +## Deployment and input boundaries + +`ui/svelte.config.js` uses `adapter-static`, writing only `dist/ui` for Tauri's +`frontendDist` in `src-tauri/tauri.conf.json`. Production runs no Node/SvelteKit +HTTP server. There are no application server loads, remote functions, cookie +setters or CSS/YAML/glob processing endpoints. Project/reference/model data is +handled by the existing Tauri/Rust commands, not those build-tool parsers. + +This does not make every `devDependencies` entry non-runtime: SvelteKit's client +source imports devalue and the framework can serialize navigation/server data. +Devalue is classified as runtime-capable and patched. No current hostile +application path into its vulnerable Buffer/async/uneval operations was found; +this is not a general claim that devalue is absent from every emitted bundle. + +Vite/SvelteKit also run in development. Kit 2.61.1's normal dev GET path calls +`server.respond` -> `is_action_json_request` -> `negotiate(Accept)` before checking +the method, making the Accept-header advisory reachable without remote forms. +The same-major Kit update below therefore fixes an actual available path. + +## All baseline high/critical rows + +| npm row / baseline version | Severity | Consumer and attack prerequisites | Repair | +| --- | --- | --- | --- | +| `@eslint/config-array 0.21.2` | High, inherited | Lint configuration glob patterns -> minimatch -> brace expansion; no project-text input. | Parent unchanged; patched brace dependency. | +| `@eslint/eslintrc 3.3.5` | High, inherited | Legacy lint YAML config and glob parsing, requiring malicious tool configuration. | Parent unchanged; YAML/brace patches. | +| `@tailwindcss/node 4.1.18` | High, inherited | CSS build source-map offsets through source-map-js; not runtime text processing. | Parent unchanged; source-map-js 1.2.2. | +| `@tailwindcss/vite 4.1.18` | High, inherited | Tailwind build dependency plus Vite dev-server paths. | Parent unchanged; source-map-js and Vite patches. | +| `@vitest/coverage-v8 3.2.4` | Critical, inherited | Test/coverage host via Vitest and magicast. | Coordinated 4.1.11 family; no Tinypool. | +| `brace-expansion 1.1.15 / 2.1.1 / 5.0.6` | High | ESLint/minimatch, TypeScript lint parser and coverage glob/test-exclude; attacker-controlled expansion patterns can exhaust CPU/stack/memory. | 1.1.21 and 5.0.12; obsolete coverage 2.x/5.x copies removed. | +| `devalue 5.8.1` | High | Runtime-capable framework dependency. Buffer serialization can expose backing storage; special uneval/async inputs can exhaust resources or reject unhandled promises. No current SSR Buffer/remote-input route, but retain no exemption. | 5.9.4. | +| `eslint 9.39.4` | High, inherited | Lint host through config-array/eslintrc/minimatch/YAML; development/CI, not shipped code. | Parent unchanged; patched affected dependencies, including humanfs 0.16.8. | +| `js-yaml 4.1.1` | High | ESLint legacy config parsing of attacker-controlled merge/omap YAML. | 4.3.2. | +| `magicast 0.3.5` | High, inherited | Coverage/config AST/source maps, not application reference documents. | Coverage-owned 0.5.5, with patched source-map-js. | +| `minimatch 3.1.5` | High, inherited | Lint glob selection, via brace expansion. | Parent unchanged; brace-expansion 1.1.21. | +| `nanoid 3.3.12` | High | PostCSS's generated input label calls `nanoid/non-secure` with constant positive size 6; app input cannot choose the negative/zero sizes in the advisories. | Compatible 3.3.20 despite absent current hostile-size path. | +| `postcss 8.5.15` | High | Node build/lint CSS and previous source-map loading; disclosure requires attacker CSS/map paths plus an observable generated result. No runtime CSS compilation endpoint. | 8.5.30 and patched nanoid/source-map-js; selector-parser 7.1.6 closes the related lower-severity lint chain. | +| `source-map-js 1.2.1` | High | Tailwind/PostCSS/magicast processing of malicious indexed source-map section offsets in the tool host. | 1.2.2. | +| `tinypool 1.1.1` | Critical | Vitest workers; an existing `Object.prototype` pollution primitive can materialize inherited worker/run options and obtain test-host execution. Not a desktop runtime worker. | Removed through supported Vitest 4 worker implementation; no forced 2.x override. | +| `vite 6.4.2` | High | Explicitly exposed dev server on Windows/NTFS with sensitive files in allowed directories; alternate paths bypass denial. Default launcher is loopback, but patch the supported Windows tool. | Same-line 6.4.4. | +| `vitest 3.2.4` | Critical | UI/browser/API server exposed or Windows UI mode; repository uses `vitest run` and no UI/browser/API configuration. Its worker dependency still needs repair independently. | 4.1.11 with matching coverage plugin. | + +## Upstream advisory verification + +These are the direct high/critical advisories in the baseline. Ranges/fixed +versions below describe the consumed major lines; selected versions exceed all +listed bounds. Linked upstream pages own complete multi-major ranges. + +| Advisory | Affected consumed line / first fixed release | +| --- | --- | +| [GHSA-3jxr-9vmj-r5cp](https://github.com/advisories/GHSA-3jxr-9vmj-r5cp) | Brace: <1.1.16 / <2.1.2 / 3.x–<5.0.7; fixes 1.1.16, 2.1.2, 5.0.7. | +| [GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg) | Brace: <1.1.17 / 2.x<2.1.3 / 4.x–<5.0.8; fixes 1.1.17, 2.1.3, 5.0.8. | +| [GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895) | Brace: <1.1.18 / 2.x<2.1.4 / 4.x–<5.0.9; fixes 1.1.18, 2.1.4, 5.0.9. | +| [GHSA-6j4f-fj2g-mc7p](https://github.com/advisories/GHSA-6j4f-fj2g-mc7p) | Brace: <1.1.19 / 2.x<2.1.5 / 4.x–<5.0.10; fixes 1.1.19, 2.1.5, 5.0.10. | +| [GHSA-qhr7-859c-m2p7](https://github.com/advisories/GHSA-qhr7-859c-m2p7) | Brace: <1.1.20 / 2.x<2.1.6 / 4.x–<5.0.11; fixes 1.1.20, 2.1.6, 5.0.11. | +| [GHSA-j22f-vq7h-c4qm](https://github.com/advisories/GHSA-j22f-vq7h-c4qm) | Devalue >=5.1.0–5.9.2; fixed 5.9.3. | +| [GHSA-mcm9-63f2-9j32](https://github.com/advisories/GHSA-mcm9-63f2-9j32) | Devalue <=5.9.2; fixed 5.9.3. | +| [GHSA-x5rw-q4pp-hg5g](https://github.com/advisories/GHSA-x5rw-q4pp-hg5g) | Devalue >=5.8.0–5.9.2; fixed 5.9.3. | +| [GHSA-52cp-r559-cp3m](https://github.com/advisories/GHSA-52cp-r559-cp3m) | YAML 4.x<4.3.0; fixed 4.3.0. | +| [GHSA-5p4m-2wfm-xmqj](https://github.com/advisories/GHSA-5p4m-2wfm-xmqj) | YAML 4.x<4.3.1; fixed 4.3.1. | +| [GHSA-2883-xcg3-v3hh](https://github.com/advisories/GHSA-2883-xcg3-v3hh) | YAML 4.x<4.3.2; fixed 4.3.2. | +| [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv) | Nanoid <3.3.16; fixed 3.3.16. | +| [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) | Nanoid <3.3.18; fixed 3.3.18. | +| [GHSA-r28c-9q8g-f849](https://github.com/advisories/GHSA-r28c-9q8g-f849) | PostCSS <=8.5.17; fixed 8.5.18. Selected 8.5.30 also fixes the later moderate map-loading advisory. | +| [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) | source-map-js 1.x<1.2.2; fixed 1.2.2. | +| [GHSA-5gmw-xhrv-c9v3](https://github.com/advisories/GHSA-5gmw-xhrv-c9v3) | Tinypool <=2.1.0; first fixed 2.1.1, insufficient for the next advisory. No patched 1.x. | +| [GHSA-85c8-ppgw-ccpr](https://github.com/advisories/GHSA-85c8-ppgw-ccpr) | Tinypool <2.1.2; fixed 2.1.2. No patched 1.x; selected graph removes it. | +| [GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff) | Vite <=6.4.2; fixed 6.4.3. | +| [GHSA-5xrq-8626-4rwp](https://github.com/advisories/GHSA-5xrq-8626-4rwp) | Current reviewed feed: Vitest <3.2.6 or 4.x<4.1.0; selected 4.1.11. | + +## Version decision and compatibility evidence + +Vitest 3.2.7 still requires Tinypool `^1.1.1`; patching only Vitest 3 cannot +remove that critical finding. A forced Tinypool major would contradict the +parent's supported range. [Vitest 4's migration guide](https://v4.vitest.dev/guide/migration) +documents removal of Tinypool, Vite >=6 and Node >=20. The repo already pins +Node 24.12.0 and Vite 6, and has no custom pools, browser/UI server, removed +workspace/coverage options or custom runner APIs. Vitest and coverage-v8 are +updated together to 4.1.11. Existing assertions and discovery remain unchanged. +The current 4.1.11 cohort also fixes the moderate mocker path issue in 3.x. + +Other changes stay in their existing families: Vite 6.4.4, Kit 2.70.3, devalue +5.9.4, PostCSS 8.5.30, nanoid 3.3.20, source-map-js 1.2.2, YAML 4.3.2, brace +1.1.21/5.0.12, humanfs/node 0.16.8 and selector-parser 7.1.6. The latter two +are compatible fixes within the already-reviewed ESLint/PostCSS chains. +Supporting Babel/Chai/magicast/worker-tool changes belong to the selected Vitest +cohort. Removed coverage/glob packages and Tinypool account for most lockfile +deletions. No direct Svelte, ESLint, Tailwind, TypeScript or Rust family upgrade, +no alternative registry, no overrides, and no `audit fix --force` are used. + +The additional Kit repair closes [Accept-header negotiation](https://github.com/advisories/GHSA-29g2-3rmr-qm68) +(fixed 2.70.2) and its remote-form issues; supported peers still include this +repo's Svelte 5, plugin 5, Vite 6 and Node 24. The same-family supporting repairs +close [humanfs symlink copies](https://github.com/advisories/GHSA-p498-v437-472g) +and [selector-parser CPU exhaustion](https://github.com/advisories/GHSA-rj75-hqrm-r3gf), +without relying on their absence from application input paths as an exception. + +## Remaining finding and required decision + +The final exact-lock audit reports zero critical, high or moderate findings and +three low package rows: cookie, Kit and adapter-static. There is no remaining +high/critical exception. The remaining direct advisory is +[cookie GHSA-pxg6-pf52-xh8x](https://github.com/advisories/GHSA-pxg6-pf52-xh8x), +low severity, `cookie <0.7.0`; the lock contains 0.6.0 through Kit's `^0.6.0`. +Npm may also report Kit/adapter parent rows for that same advisory. + +The prerequisite is an application passing untrusted cookie **names, paths or +domains** to the Node cookie serializer. No such caller, HTTP production server +or server load/form endpoint exists in this static Tauri application. The build +server output under `.svelte-kit` is not the shipped `dist/ui` payload. Normal +development does not introduce application cookie setters either. + +This is a specific existing low-severity disposition, not blanket acceptance. +The frontend dependency owner must reassess it before introducing SSR/HTTP +deployment, remote forms or application-supplied cookie metadata, and during +the next Kit dependency update. As of this review, the latest Kit 2 release is +2.70.3 and retains cookie 0.6; Kit 3.0.1 uses cookie `^2.0.1`. Parent decision: +schedule a separately reviewed Kit 3 migration or obtain a supported Kit 2 +backport before claiming that this dependency advisory is closed. Forcing +cookie >=0.7 under Kit 2 is not selected without a supported compatibility +contract. The existing high/critical gate is unchanged; no threshold is lowered. + +## Verification and retained gates + +The selected graph is installed with `npm ci` under pinned Node 24.12.0. Full +lint/format/typecheck, all 107 configured files / 608 tests and the static build +pass after the final dependency changes. The final audit has three low inherited/ +direct rows for one cookie advisory, and zero moderate/high/critical findings; +`npm audit --audit-level=high` exits successfully. `npm ls` and the existing +`scripts/check-dependencies.sh` pass, with explicit optional Rust-tool warnings. +No test assertions, discovery filters, +snapshots or application behavior were weakened for the runner migration. +The matched coverage provider also passed a five-test Pumas lifecycle/prompt +smoke with V8 coverage enabled before the final lint-only transitive patches. + +A bounded public-API regression used a synthetic two-byte Node Buffer view over +a 64-byte backing store, with no real process data. Devalue 5.8.1's +`parse(stringify(buffer))` reproduced a 64-byte backing-store disclosure; +5.9.4 preserves only the selected two bytes. Its `uneval` round trip also +preserves exactly those two bytes. These are dependency tests, not model output. + +Reproduce the source verification from `ui`: + +```bash +npm ci +npm audit --audit-level=high +npm ls +npm run lint +npm run format:check +npm run typecheck +npm run test +npm run build +``` + +CI now enforces the existing npm high/critical gate and the actual static build. +The PR record owns the exact pushed head, final independent review and hosted +run. Local full-native testing remains blocked by missing glib/GTK/WebKit; +the existing hosted Linux/Windows Rust checks are retained. Optional local Rust +audit/deny/machete tools remain absent; npm findings are not a claim that those +independent Rust audits were executed. + +PR29 remains draft for parent review. Trained chat/embedding inference remains +unexecuted, with the original access denial and operator acceptance procedure +preserved in [the readiness report](pr29-merge-readiness.md). No model access +bypass, release or PR merge is performed. diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 30147e96..82484ef1 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -102,3 +102,11 @@ The Bevy timeline bridge continues submitting legacy range commands with no expe The command registry includes `projection_bible_recall` for explicit selected Bible entity inspection; screenplay replacement remains behind existing review and acceptance commands. + +## Authoring command registration + +The invoke registry admits `command_timeline_node_arcs` and +`command_timeline_sibling_reorder` beside existing timeline operations. Their +adapters preserve the server's original read and replay response; current +projection events remain the refresh boundary. This adds no provider, model +bootstrap, native runner or canonical frontend state. diff --git a/src-tauri/src/commands/README.md b/src-tauri/src/commands/README.md index a730b10f..2fdf961f 100644 --- a/src-tauri/src/commands/README.md +++ b/src-tauri/src/commands/README.md @@ -106,3 +106,12 @@ The explicit `command_script_fact_proposal_analyze` command delegates one saved `command_timeline_node_name` saves a canonical clip title using the required exact title read receipt and existing timeline command publication. + +## Clip arc and sibling commands + +`command_timeline_node_arcs` and `command_timeline_sibling_reorder` forward typed +command envelopes to existing public server services. Successful new writes +publish normal timeline/selected-editor/script/change-review events after the +canonical transaction. Replays return the stored response without a second +mutation or source-clock reconstruction. Native adapters own neither membership +validation nor a duplicate timeline. diff --git a/ui/package-lock.json b/ui/package-lock.json index de5b74ee..80f4d54b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,12 +10,12 @@ "devDependencies": { "@eslint/js": "^9.38.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.16.0", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.2.3", "@typescript-eslint/types": "^8.56.1", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", @@ -28,28 +28,14 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "typescript-eslint": "^8.46.2", - "vite": "^6.1.0", - "vitest": "^3.2.4" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "vite": "^6.4.4", + "vitest": "^4.1.11" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -57,9 +43,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -67,13 +53,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -83,14 +69,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -680,29 +666,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -731,34 +731,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -804,17 +776,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1197,9 +1158,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.61.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", - "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", "peer": true, @@ -1489,6 +1450,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -1789,16 +1810,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1873,32 +1894,30 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1907,39 +1926,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1951,42 +1971,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1994,28 +2014,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2062,19 +2079,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2119,9 +2123,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2147,9 +2151,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2157,16 +2161,6 @@ "concat-map": "0.0.1" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2178,18 +2172,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2211,16 +2198,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2272,6 +2249,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2326,16 +2310,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2362,23 +2336,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "dev": true, "license": "MIT" }, @@ -2396,9 +2356,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2696,9 +2656,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2794,23 +2754,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2825,28 +2768,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2860,32 +2781,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -2969,16 +2864,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3033,21 +2918,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -3062,22 +2932,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3095,10 +2949,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3619,20 +3483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3643,15 +3493,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -3683,16 +3533,6 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3718,9 +3558,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3743,6 +3583,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3793,13 +3647,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3833,23 +3680,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3857,16 +3687,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3888,9 +3708,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -3909,9 +3729,9 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4012,9 +3832,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4202,19 +4022,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4230,10 +4037,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -4246,116 +4054,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4369,26 +4073,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4504,60 +4188,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4566,11 +4196,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4588,30 +4221,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -4715,9 +4328,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "peer": true, @@ -4790,29 +4403,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -4828,66 +4418,80 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4898,6 +4502,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -4944,101 +4551,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index ae6cea1a..4cb91b0d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,12 +19,12 @@ "devDependencies": { "@eslint/js": "^9.38.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.16.0", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.2.3", "@typescript-eslint/types": "^8.56.1", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", @@ -37,7 +37,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "typescript-eslint": "^8.46.2", - "vite": "^6.1.0", - "vitest": "^3.2.4" + "vite": "^6.4.4", + "vitest": "^4.1.11" } } diff --git a/ui/src/lib/README.md b/ui/src/lib/README.md index 12c80ff6..dfb07734 100644 --- a/ui/src/lib/README.md +++ b/ui/src/lib/README.md @@ -192,3 +192,11 @@ boundary, preserving unrelated author drafts and backend source/version custody. Timeline title transport carries exact text and its owned field read to the canonical native rename command; it does not use generic sparse object fields. + +## Guarded arc assignment and sibling reorder + +`timelineCommandApi.ts` sends `SetTimelineNodeArcsCommand` and +`ReorderTimelineSiblingCommand` through the existing desktop command envelope. +Owned expected reads, original command IDs and replay receipts remain backend +contracts; frontend drafts cannot manufacture a replacement current read after +an uncertain write. `timelineCommandTypes.ts` mirrors those contracts. diff --git a/ui/src/lib/components/sidebar/bible/README.md b/ui/src/lib/components/sidebar/bible/README.md index 98f76c5e..1039539e 100644 --- a/ui/src/lib/components/sidebar/bible/README.md +++ b/ui/src/lib/components/sidebar/bible/README.md @@ -140,3 +140,12 @@ verification and failure, and recovery does not rebase them. An initial uncached detail failure also exposes Retry saved facts in place. Retry uses the same owned refresh, shows Loading while pending, and admits only the current node/session response; selecting another node retires the old read. + +## Exact authored freeform values + +`bibleGraphFieldDrafts.svelte.ts` forwards the exact submitted freeform string, +including leading/trailing whitespace and Unicode. Empty input retains the +existing explicit clear; whitespace-only input is authored text. A verified +matching acknowledgement may clear only its exact owned draft. Conflicts and +failed or retired reads retain it. `BibleGraphPartFields` and the public writer +remain the only Save route. From f729aa0f13790c6e996bfd368f7a0c136d8dc62e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 14 Oct 2026 23:11:01 +0000 Subject: [PATCH 6/6] test(renderer): await bounded terminal panic status on Windows --- docs/reports/authoring-integration-readiness.md | 10 ++++++++++ src-tauri/src/README.md | 8 ++++++++ src-tauri/src/timeline_renderer_window_thread.rs | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/reports/authoring-integration-readiness.md b/docs/reports/authoring-integration-readiness.md index be2ba12b..da24d9a4 100644 --- a/docs/reports/authoring-integration-readiness.md +++ b/docs/reports/authoring-integration-readiness.md @@ -55,6 +55,16 @@ qualification does not claim native Tauri/Bevy interaction or trained inference. Synthetic test/provider responses remain explicitly synthetic. No trained-model access, credentials, release or denial bypass belongs to this integration. +Initial exact-head CI run 102 passed Linux full-workspace checks (986 tests), +frontend (636 tests) and traceability, but Windows failed the existing injected +renderer panic test. Its 200 ms receive could legitimately time out before the +panic hook/unwind and OS scheduling completed. The panic case now uses a bounded +five-second completion-channel wait with the same non-running/Panicked assertions. +Production stop behavior and the other 200 ms controlled-close tests are unchanged. +Independent review accepts this scoped synchronization repair; fresh hosted +Linux/Windows checks on the forward repair remain required. No failed run is +represented as passing and no test/discovery filter is weakened. + PR29 compatibility is verified only in a disposable integration tree, resolving additive documentation conflicts while preserving its producer pin, inference lifecycle and reference prompt/retrieval surfaces. That tree is never pushed to diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 82484ef1..1c8d84a6 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -110,3 +110,11 @@ The invoke registry admits `command_timeline_node_arcs` and adapters preserve the server's original read and replay response; current projection events remain the refresh boundary. This adds no provider, model bootstrap, native runner or canonical frontend state. + +## Injected window-thread panic verification + +The panic-result test waits on the actual completion channel with a bounded +five-second test budget. It requires a terminal non-running `Panicked` result, +without imposing 200 ms on platform scheduling or the panic hook. Production +stop behavior/timeouts and the controlled completion/close tests stay unchanged. +This injected-runner check supplies lifecycle unit evidence, not native pixels. diff --git a/src-tauri/src/timeline_renderer_window_thread.rs b/src-tauri/src/timeline_renderer_window_thread.rs index 6611d494..906849b9 100644 --- a/src-tauri/src/timeline_renderer_window_thread.rs +++ b/src-tauri/src/timeline_renderer_window_thread.rs @@ -217,7 +217,9 @@ mod tests { ) .unwrap(); - let status = handle.stop(Duration::from_millis(200)); + // Observe the terminal panic result; panic hooks and OS scheduling are + // not required to finish within the short graceful-close test budget. + let status = handle.stop(Duration::from_secs(5)); assert!(!status.running); assert_eq!(