Skip to content

feat(studio): fullscreen button for the workflow canvas - #60

Closed
modacker wants to merge 7 commits into
MiniMax-AI:mainfrom
modacker:community/canvas-fullscreen
Closed

modacker wants to merge 7 commits into
MiniMax-AI:mainfrom
modacker:community/canvas-fullscreen

Conversation

@modacker

@modacker modacker commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

feat(studio): fullscreen button for the workflow canvas

A "Fullscreen" button is added to the canvas toolbar, placed before the topology-mode toggle in the structure-topology section. Clicking it expands the canvas to fill the viewport; clicking again (or pressing Esc) returns it to its inline size.

  • Two-shape fullscreen: the Fullscreen API is tried first (requestFullscreen, webkit prefix included); when it is unavailable or rejected (embedded pages, iframe restrictions, permission denial) the canvas automatically falls back to a fixed-position opaque overlay (position:fixed; inset:0; 100dvh) — same behavior either way, with the toolbar (zoom, topology mode, exit button) staying inside the panel.
  • Zoom refit on both transitions: entering and leaving fullscreen reset the auto-zoom and re-run the fit once, so the graph uses the new viewport instead of keeping inline-scale.
  • Interaction state survives data refresh: switching runs or the periodic poll re-render keeps the fullscreen state (data refreshes, interaction state is preserved — the same boundary the lineage panel now follows); an empty run view releases fullscreen.
  • Esc routing: when a dialog (node/report) is open, Esc closes the dialog first instead of leaving fullscreen.
  • The mode-selection state machine is extracted as pure functions (web/fullscreen-model.mjs) and pinned by checks/canvas-fullscreen.check.mjs (6 cases: fallback order, idempotent re-entry, rejection downgrade, fullscreenchange tracking, dialog-first Esc, button placement). DOM-level outcomes (enter → survives polls → button flips to "Exit fullscreen" → Esc exits and the panel returns to inline size) were verified interactively against the real dashboard.

Suites: npm test 142 → 148 green; Linux clean-tree gate green; Windows + CodeQL via the fork preview mirror. Bilingual i18n (zh/en); no new dependencies; stacked on #59's branch and rebases cleanly once it merges.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

moc added 7 commits September 21, 2026 20:31
Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, first half).

- Tombstone soft delete on terminal runs (succeeded/failed/
  completed_with_gaps/cancelled/interrupted): run body gains
  deletedAt/deletedBy/purgeAfter plus an append-only run.deleted audit
  event; steps, events, result and the integrity ledger stay untouched.
- Query faces all filter tombstones: run list, snapshot (workflow_status,
  HTTP GET), cross-run reuse candidate scan, repair source scan, resume,
  and requestId idempotent replay (rejected with a recovery hint instead
  of resurrecting ghost data).
- Restore clears the tombstone and appends run.restored; the run
  reappears byte-identical. Repeat deletes are idempotent and never
  append a second event.
- Retention: trashRetentionDays setting (default 30, 0 = manual-only
  expiry) via the store settings face; changing it restamps tombstones
  already in trash so the displayed countdown stays truthful.
- Exposure: HTTP DELETE /api/runs/:id, POST /api/runs/:id/restore,
  GET /api/runs?trash=1, GET/POST /api/trash; MCP workflow_delete /
  workflow_restore (old daemons gated via the trashManagement feature
  flag); Studio trash view with restore button and retention countdown.
- checks/trash.check.mjs pins hide/restore round trips, every filtered
  face, idempotence, the reuse interaction, retention math and the
  HTTP/MCP surfaces; tool-count assertions move from 11 to 13.
Task package: sih-engine/sih/state/plan/run-lifecycle.md (PR1, second half).

- Rotation compaction exports due tombstones (purgeAfter <= now) into the
  sidecar <data-dir>/archive.db and physically deletes their live runs/steps
  rows; the events table never loses a row (program red line). The audit
  event archive.rotated {rotationId, runs, manifestHash, runCount, bytes} is
  appended in the same transaction as the deletes, anchoring the manifest on
  the MiniMax-AI#48 events hash chain.
- manifestHash is computed by one shared implementation
  (archiveManifestHash: canonical JSON over runs+steps rows in (runId, id)
  order) used by both rotation and verification, so the two hashes cannot
  drift; checks/archive-rotate.check.mjs recomputes it independently.
- Archive rows are keyed by (rotationId, runId) instead of a bare runs PK:
  a run that is restored, re-deleted and re-rotated writes a fresh copy and
  can never rewrite rows an earlier rotation's manifest still covers.
- Verification (verifyArchive) recomputes every rotation's manifest from
  the archived rows and compares it against both the rotations record and
  the chained event; chained-but-missing rotations and orphan archive rows
  fail closed. verifyIntegrity() gains an archive face alongside
  events/repair without touching their semantics.
- Restore from archive copies rows back (idempotent upserts), clears the
  tombstone and appends run.restored {origin:'archive', rotationId}; a
  requestId claimed by a newer live run blocks the restore loudly.
  workflow_delete on a rotated run reports the archive state truthfully.
- Triggers: CLI --rotate-archive (with --verify for the shared verify
  outlet; null verdicts from empty chains are not failures) and --restore
  <runId>, both forwarding to a live service instead of fighting the owner
  lock; service startup auto-rotates when tombstones exceed 500 or the
  runs table exceeds ~100MB (constants documented in store.mjs), skipped
  entirely when trashRetentionDays=0 (manual-only). HTTP face:
  POST /api/archive/rotate.
- checks/archive-rotate.check.mjs pins due-only rotation, the events-row
  red line, tamper detection (step body, forged rotations hash, deleted
  rotation record), archive restore and re-rotation verifiability, the
  501-vs-400 startup threshold against a real daemon, and the CLI faces
  with real exit codes.
…eage

Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系),
stacked on PR1's tombstone/restore/rotation commits.

A rerun starts a NEW pending_review run from the source's script+input and
never writes the source. The child carries rerunOf/lineageRoot/rerunSeq on
its body, so the family survives unlimited reruns, trash and archive
rotation (members are annotated, never removed).

Key decisions:

- requestId is synthesized as `<root>#rerun-<n>`; n counts live, tombstoned
  AND archived family members so a seq is never reused after rotation, with
  a numeric-suffix retry when the id is already claimed (150-char cap kept).
- reuseAcrossRuns defaults to false on reruns (explicit opt-in only): silent
  adoption would turn a rerun into a fake execution and poison comparison.
- Reruns pass through the existing pending_review/approve gate; the rerun
  action itself only creates the draft, it never auto-executes. Tombstoned
  sources are refused until restored; rerunning straight from the archive is
  deliberately not offered (restore first).
- GET /api/runs/:id/lineage resolves the family from any member id (live,
  tombstoned or archived), ordered root-first then by seq, with per-member
  status/duration/timestamps/result summary; ?results=1 adds full results
  for the read-only compare face. Durations come from the events ledger,
  which rotation never moves.
- MCP workflow_rerun (input override + reuseAcrossRuns flag) is gated behind
  a rerunLineage feature flag, mirroring the trash tools' old-daemon guard.
- Studio gains a lineage panel (member list with trash/archive flags) and a
  two-column read-only JSON result compare.
- New checks/rerun-lineage.check.mjs pins: source byte-identity across a
  rerun, family ordering over 3 reruns, default no-reuse vs explicit reuse,
  trash/rotation refusal with family integrity, requestId collision retry,
  HTTP lineage shape and the MCP schema.

Files: src/store.mjs src/engine.mjs src/http.mjs src/tools.mjs src/main.mjs
web/index.html web/app.source.mjs web/app.js web/i18n.mjs web/style.css
checks/rerun-lineage.check.mjs checks/package.check.mjs
checks/workspace-router.check.mjs test/package.test.mjs README.md dist/main.mjs
Fork preview run 35506090983 (first full windows-latest pass) failed check MiniMax-AI#9
"packaged MCP advertises reuseAcrossRuns and accepts it through the public
tool surface" with:

  EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wf-cross-mcp-NCVTMk'

Every assertion passed; the failure came from the finally block.
StdioClientTransport.close() resolving does not mean the spawned dist/main.mjs
server process has exited, so the child still held the dataDir (the mkdtemp
tmp dir) handle when rm() ran, and win32 refuses to rmdir a busy directory.
Linux/darwin unlink open files, which is why all local runs were green.

- add a local rmWithRetry helper: retry rm on EBUSY/ENOTEMPTY/EPERM with
  exponential backoff (200ms base, x2, 5 retries, ~6s total budget), then
  rethrow as-is -- cleanup failures stay loud, we only give the OS time to
  release handles
- finally: wrap client/transport close in try/catch so a close error cannot
  skip rm; rethrow the close error after cleanup so it still fails the test

Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no other
checks, no new dependencies. Local verification: npm test exit=0, 126/126.
…-reuse MCP check

Round two of the same failure. Fork preview run 35506404071 (with 14a7fd5)
failed the same check MiniMax-AI#9 with the same code:

  EBUSY: resource busy or locked, rmdir '...\wf-cross-mcp-3VJDll'

The test ran 7945ms -- the full ~6.2s retry budget burned while the
directory stayed locked. Not a transient lock, so retrying cannot fix it.

Real root cause (src/main.mjs, stdio branch): when no existing service is
found, --stdio spawns a detached daemon -- spawn(process.execPath, args,
{cwd:workspace, detached:true, ...}); child.unref() -- with cwd set to the
workspace (the mkdtemp tmp dir), service.log and the database inside dataDir
(the same tmp dir). By design "the service, workers and dashboard outlive"
the chat transport, so client.close()/transport.close() only tear down the
stdio face and the daemon keeps running, holding its cwd handle plus
service.log/DB handles inside the tree. win32 refuses to rmdir a directory
tree a live process is sitting in; Linux/darwin unlink open files, which is
why local runs stay green. Layer two, from run 35506090983: transport.close()
resolving does not mean a child has fully exited, so handle release can lag
an instant even after the daemon is gone.

The codebase's own convention already stops the service first
(checks/fail-loud.check.mjs:80, checks/lifecycle.check.mjs:19): run
dist/main.mjs --stop-service --workspace <dir> --data-dir <dir> after closing
the client. Identity matches this test because its --settings file sets
workspace=dataDir=dir, the same endpoint.json the daemon registered under.

- finally: after client/transport close (still deferred-rethrow), run
  --stop-service wrapped in try/catch so a stop failure cannot block rm
- keep rmWithRetry as the release-lag fallback, still failing loud when
  exhausted
- rewrite the comment to the two-layer root cause with both run ids

Only checks/cross-reuse-mcp.check.mjs is touched; no src/, no dist/, no new
dependencies. Local verification: npm test exit=0, 126/126; ps confirms the
daemon spawned by the check is actually terminated by the stop-service step
(no stray wf-cross-mcp-* processes remain).
…r flags

Task package: sih-engine/sih/state/plan/run-lifecycle.md, PR2 (复跑谱系),
UI patch from the egolite real-panel interaction pass. Two bugs, one
render path, fixed as one decision: data refreshes, interaction state
survives.

Bug 1 (compare view wiped by polling): with two members selected, clicking
对比结果 shows the two-column diff (measured 1090x138 visible); ~6s later
the poll re-render hides it again (hidden:true, 0x0). Root cause:
renderLineage() ran unconditionally on every poll (refreshCurrent ->
renderRun -> renderLineage) and executed `#lineage-diff.hidden=true` plus
a full select rebuild each pass, resetting the user's diff visibility and
left/right selection every few seconds.

Bug 2 (stale deleted flag after trash restore): restoring rerun-1 left
its lineage card wearing the 已删除(回收站) flag across 8s+ and multiple
poll cycles even though the lineage API already returned deleted:null
(three members live, trash empty). Root cause: the flag renders from the
client's lineage cache, which was only refetched on selectRun and panel
toggle - neither happens on restore, so polls kept painting stale data
forever (not a DOM-residue issue: rows were rebuilt from stale input).

Fix:

- renderLineage() now splits data from interaction. Member cards (status,
  duration, trash/archive flags, preview) re-render on every pass from
  whatever the cache holds. The compare controls (selects, open diff) are
  rebuilt only when member identity changes, gated by a pure signature
  (language + ordered id:rerunSeq:name, web/lineage-model.mjs). Signature
  unchanged -> selects untouched, #lineage-diff.hidden untouched. Member
  added/removed/reordered/renamed or language switch -> full rebuild and
  the diff resets (member set changed, reset is legitimate). First render
  is unchanged (empty previous signature always rebuilds).
- refreshCurrent() refetches the lineage payload (background mode) when
  the panel is open, so member state can no longer go stale across polls;
  background errors keep the last good cache instead of blanking the open
  panel. Trash restore additionally triggers an immediate refetch.
- Regression nail: checks/lineage-panel.check.mjs pins the gate logic in
  node:test (poll-shaped refresh and flag clears never rebuild; identity/
  language/name changes always do). The DOM-level outcome (diff stays
  visible across 6s+ of polling) cannot be carried by node:test (no DOM);
  to be re-verified with the egolite interaction protocol: click compare
  -> still visible after 6s+ of polling.

Files: plugins/hetaoBackend/mcode-dynamic-workflows/web/app.source.mjs
plugins/hetaoBackend/mcode-dynamic-workflows/web/lineage-model.mjs
plugins/hetaoBackend/mcode-dynamic-workflows/web/app.js (rebuilt via
scripts/build-web.mjs)
plugins/hetaoBackend/mcode-dynamic-workflows/checks/lineage-panel.check.mjs
User request: "工作流画布应该也可以变大,在结构拓扑前添加按钮,点击后
可以全屏" - the run-detail canvas (structure topology block) gets a button
that expands the canvas to the full screen; clicking again or pressing Esc
exits.

What lands:

- Button placement: #graph-fullscreen sits first in the canvas toolbar
  action row (web/index.html), immediately before the 结构拓扑 toggle
  (#graph-mode), so it precedes the topology block as requested. Label is
  bilingual via i18n keys canvasFullscreen / canvasExitFullscreen
  (web/i18n.mjs); aria-pressed tracks the mode and renderRun re-syncs the
  label so a language switch while fullscreen keeps the exit semantics.

- Two fullscreen forms, API first with overlay fallback: the click asks the
  panel's requestFullscreen() (webkit prefix covered) and only falls back to
  an opaque fixed overlay (.canvas-overlay, position:fixed inset:0, z-index
  40 - web/style.css) when the API is unavailable or denied (embedded
  iframe without allow=fullscreen, permission refusal; the rejected promise
  and webkitfullscreenerror both route to the fallback). Esc exits either
  form: natively for the API form, via a document keydown handler for the
  overlay. The toolbar stays inside the fullscreen panel, so the exit
  control is always reachable.

- Interaction state survives: switching runs or polling re-renders graph
  content without leaving fullscreen (data refreshes, fullscreen state
  stays on the panel), Esc never exits past an open dialog (node reader /
  report keep their own close), and service disconnects cannot strand the
  canvas - the exit paths are document-level and unconditional. When the
  run view empties (no run), fullscreen is released as state hygiene.

- After each geometry change (enter/exit either form) zoomAuto is reset and
  renderGraph() refits once to the new panel size; pan/zoom interaction is
  the existing canvas machinery, untouched.

- Pure logic extracted: web/fullscreen-model.mjs holds the mode transitions
  (requestMode / rejectApi / apiChange / shouldExitOnKey). Regression nail
  checks/canvas-fullscreen.check.mjs pins the fallback order, the dialog
  precedence for Escape, and the button position ahead of #graph-mode.

Files: web/index.html, web/i18n.mjs, web/app.source.mjs,
web/fullscreen-model.mjs, web/style.css, checks/canvas-fullscreen.check.mjs,
web/app.js (via scripts/build-web.mjs), dist/main.mjs (i18n keys propagate
through src/reports.mjs -> bundled translate). Verified: npm test 148 pass,
npm run build byte-stable across two runs. DOM-level behaviour (button
render, both fullscreen forms, refit, poll persistence) is to be confirmed
through the egolite interaction protocol on the real panel.
@modacker

Copy link
Copy Markdown
Contributor Author

Closing for now — folding in additional changes before resubmitting.

@modacker modacker closed this Sep 21, 2026
@modacker
modacker deleted the community/canvas-fullscreen branch September 21, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant