@dfosco/storyboard 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/favicon.svg +12 -0
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +13907 -10816
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +138 -127
- package/scaffold/AGENTS.md +9 -7
- package/scaffold/README.md +18 -0
- package/scaffold/deploy.yml +21 -0
- package/scaffold/gitignore +8 -14
- package/scaffold/preview.yml +40 -0
- package/scaffold/{manifest.json → scaffold.config.json} +73 -3
- package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/scaffold/src/_prototype.jsx +17 -0
- package/scaffold/src/_story.jsx +13 -0
- package/scaffold/src/library/README.md +34 -0
- package/scaffold/src/library/_app.jsx +53 -0
- package/scaffold/src/library/_app.module.css +8 -0
- package/scaffold/src/library/create.jsx +10 -0
- package/scaffold/src/library/index.jsx +14 -0
- package/scaffold/src/library/mount.jsx +52 -0
- package/scaffold/src/library/prototypes-entry.jsx +57 -0
- package/scaffold/src/library/routes.jsx +205 -0
- package/scaffold/src/library/viewfinder.jsx +13 -0
- package/scaffold/src/library/workspace.jsx +17 -0
- package/scripts/aggregate-artifacts.mjs +257 -0
- package/src/core/artifact/operations.js +19 -13
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/githubEmbeds.js +12 -0
- package/src/core/canvas/githubEmbeds.test.js +71 -2
- package/src/core/canvas/hot-pool.js +10 -11
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +324 -69
- package/src/core/canvas/server.test.js +365 -2
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/buildManifest.js +34 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/create.js +116 -18
- package/src/core/cli/dev.js +23 -35
- package/src/core/cli/index.js +21 -0
- package/src/core/cli/schemas.js +2 -3
- package/src/core/cli/serverUrl.js +24 -0
- package/src/core/cli/setup.js +14 -25
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/cli/updateVersion.js +42 -2
- package/src/core/cli/workleaf.js +140 -0
- package/src/core/comments/api.js +1 -1
- package/src/core/comments/queries.js +3 -0
- package/src/core/comments/ui/CommentWindow.jsx +3 -2
- package/src/core/data/artifactIndex.js +235 -0
- package/src/core/data/artifactIndex.test.js +203 -0
- package/src/core/data/artifactManifest.js +290 -0
- package/src/core/data/artifactManifest.test.js +207 -0
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/index.js +6 -1
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
- package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
- package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
- package/src/core/lib/components/ui/dialog/index.js +4 -34
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
- package/src/core/lib/components/ui/label/label.jsx +1 -2
- package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
- package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
- package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
- package/src/core/lib/components/ui/panel/panel.jsx +11 -2
- package/src/core/lib/components/ui/separator/separator.jsx +6 -4
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
- package/src/core/mountStoryboardCore.js +234 -74
- package/src/core/mountStoryboardCore.test.js +174 -0
- package/src/core/rename-watcher/watcher.js +329 -36
- package/src/core/rename-watcher/watcher.test.js +253 -0
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +247 -0
- package/src/core/scaffold.js +4 -97
- package/src/core/stores/configSchema.js +41 -2
- package/src/core/stores/recentArtifacts.js +49 -2
- package/src/core/stores/recentArtifacts.test.js +59 -6
- package/src/core/stores/themeBootstrap.js +264 -0
- package/src/core/stores/themeBootstrap.test.js +211 -0
- package/src/core/stores/themeStore.test.js +154 -0
- package/src/core/stores/themeStore.ts +235 -220
- package/src/core/styles/generateThemesCss.js +63 -0
- package/src/core/styles/tailwind.css +12 -4
- package/src/core/styles/workspace-chrome.css +30 -0
- package/src/core/tools/handlers/paletteTheme.js +11 -8
- package/src/core/ui/ActionMenuButton.jsx +2 -1
- package/src/core/ui/CanvasCreateMenu.jsx +1 -19
- package/src/core/ui/CoreUIBar.jsx +10 -7
- package/src/core/ui/ThemeMenuButton.jsx +45 -30
- package/src/core/vite/server-plugin.js +36 -0
- package/src/core/workleaf/cleanup.js +102 -0
- package/src/core/workleaf/create.js +199 -0
- package/src/core/workleaf/delete.js +101 -0
- package/src/core/workleaf/list.js +57 -0
- package/src/core/workleaf/merge.js +238 -0
- package/src/core/workleaf/paths.js +146 -0
- package/src/core/workleaf/paths.test.js +230 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +28 -21
- package/src/core/workshop/features/createFlow/server.js +5 -1
- package/src/core/workshop/features/createPrototype/server.js +5 -1
- package/src/core/workshop/features/templateIndex.js +28 -1
- package/src/core/worktree/port.js +48 -5
- package/src/core/worktree/port.test.js +42 -0
- package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
- package/src/internals/ArtifactForm/ArtifactForm.module.css +48 -66
- package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
- package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +25 -19
- package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
- package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
- package/src/internals/CommandPalette/createHelpers.js +30 -0
- package/src/internals/CreatePage/CreatePage.jsx +57 -0
- package/src/internals/CreatePage/CreatePage.module.css +34 -0
- package/src/internals/Dialog.jsx +51 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +533 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
- package/src/internals/Tooltip.jsx +39 -0
- package/src/internals/Viewfinder.jsx +196 -48
- package/src/internals/Viewfinder.module.css +72 -1
- package/src/internals/canvas/CanvasPage.jsx +267 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +14 -0
- package/src/internals/canvas/canvasApi.js +40 -5
- package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
- package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
- package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
- package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
- package/src/internals/canvas/widgets/LinkPreview.jsx +45 -1
- package/src/internals/canvas/widgets/LinkPreview.module.css +71 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +69 -10
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +114 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +8 -4
- package/src/internals/canvas/widgets/StoryWidget.jsx +21 -38
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
- package/src/internals/canvas/widgets/WidgetChrome.module.css +15 -0
- package/src/internals/canvas/widgets/embedTheme.js +2 -2
- package/src/internals/canvas/widgets/expandUtils.js +19 -19
- package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
- package/src/internals/context.jsx +22 -1
- package/src/internals/context.test.jsx +47 -0
- package/src/internals/hooks/useArtifactIndex.js +31 -0
- package/src/internals/hooks/useArtifactIndex.test.js +45 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
- package/src/internals/index.js +6 -0
- package/src/internals/loadConsumerWrapper.js +60 -0
- package/src/internals/story/ComponentSetPage.jsx +75 -34
- package/src/internals/story/StoryPage.jsx +16 -25
- package/src/internals/vite/data-plugin.js +313 -148
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
- package/terminal.config.json +1 -1
- package/src/core/lib/components/ui/card/card-action.jsx +0 -11
- package/src/core/lib/components/ui/card/card-content.jsx +0 -11
- package/src/core/lib/components/ui/card/card-description.jsx +0 -11
- package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
- package/src/core/lib/components/ui/card/card-header.jsx +0 -19
- package/src/core/lib/components/ui/card/card-title.jsx +0 -11
- package/src/core/lib/components/ui/card/card.jsx +0 -17
- package/src/core/lib/components/ui/card/index.js +0 -25
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/index.js +0 -13
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
- package/src/core/lib/components/ui/popover/index.js +0 -28
- package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
- package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
- package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
- package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
- package/src/core/lib/components/ui/popover/popover.jsx +0 -4
- package/src/core/lib/components/ui/select/index.js +0 -37
- package/src/core/lib/components/ui/select/select-content.jsx +0 -30
- package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
- package/src/core/lib/components/ui/select/select-group.jsx +0 -15
- package/src/core/lib/components/ui/select/select-item.jsx +0 -26
- package/src/core/lib/components/ui/select/select-label.jsx +0 -11
- package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
- package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
- package/src/core/lib/components/ui/select/select.jsx +0 -4
- package/src/core/lib/components/ui/sheet/index.js +0 -34
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
- package/src/core/lib/components/ui/toggle/index.js +0 -8
- package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
- package/src/core/lib/components/ui/toggle-group/index.js +0 -10
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
- package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
- package/src/internals/canvas/componentIsolate.jsx +0 -144
- package/src/internals/canvas/componentSetIsolate.jsx +0 -300
- package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
- package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
- package/src/primer/ThemeSync.jsx +0 -73
- package/src/primer/index.js +0 -1
|
@@ -14,7 +14,7 @@ function setup() {
|
|
|
14
14
|
const canvasDir = path.join(root, 'src', 'canvas')
|
|
15
15
|
fs.mkdirSync(canvasDir, { recursive: true })
|
|
16
16
|
|
|
17
|
-
const lastResponse = { status: null, body: null }
|
|
17
|
+
const lastResponse = { status: null, body: null, bytes: null, headers: null }
|
|
18
18
|
|
|
19
19
|
const ctx = {
|
|
20
20
|
root,
|
|
@@ -26,8 +26,24 @@ function setup() {
|
|
|
26
26
|
|
|
27
27
|
const handler = createCanvasHandler(ctx)
|
|
28
28
|
|
|
29
|
+
// Streaming mock for binary GET responses (image fetches). Records the
|
|
30
|
+
// bytes + headers so tests can assert without coupling to real http.
|
|
31
|
+
function makeRes() {
|
|
32
|
+
let buf = Buffer.alloc(0)
|
|
33
|
+
return {
|
|
34
|
+
writeHead(status, headers) {
|
|
35
|
+
lastResponse.status = status
|
|
36
|
+
lastResponse.headers = headers
|
|
37
|
+
},
|
|
38
|
+
end(data) {
|
|
39
|
+
if (data) buf = Buffer.concat([buf, Buffer.isBuffer(data) ? data : Buffer.from(data)])
|
|
40
|
+
lastResponse.bytes = buf
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
const invoke = (routePath, method, body = {}) =>
|
|
30
|
-
handler(null,
|
|
46
|
+
handler(null, makeRes(), { path: routePath, method, body })
|
|
31
47
|
|
|
32
48
|
return { invoke, root, canvasDir, lastResponse }
|
|
33
49
|
}
|
|
@@ -377,3 +393,350 @@ describe('POST /batch', () => {
|
|
|
377
393
|
expect(refs['1']).toMatch(/^connector-/)
|
|
378
394
|
})
|
|
379
395
|
})
|
|
396
|
+
|
|
397
|
+
describe('POST /undo and POST /redo (event-id targeting)', () => {
|
|
398
|
+
let root, canvasDir, invoke, lastResponse
|
|
399
|
+
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
402
|
+
writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
async function addSticky(text) {
|
|
410
|
+
await invoke('/widget', 'POST', {
|
|
411
|
+
name: 'undo-canvas',
|
|
412
|
+
type: 'sticky-note',
|
|
413
|
+
props: { text },
|
|
414
|
+
position: { x: 0, y: 0 },
|
|
415
|
+
})
|
|
416
|
+
return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function readState() {
|
|
420
|
+
await invoke('/read?name=undo-canvas', 'GET', {})
|
|
421
|
+
return lastResponse.body
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
it('appends an event id on every mutation', async () => {
|
|
425
|
+
const { eventId } = await addSticky('a')
|
|
426
|
+
expect(eventId).toMatch(/^evt_/)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
|
|
430
|
+
const a = await addSticky('a')
|
|
431
|
+
const b = await addSticky('b')
|
|
432
|
+
const c = await addSticky('c')
|
|
433
|
+
|
|
434
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
|
|
435
|
+
const delAEventId = lastResponse.body.eventId
|
|
436
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
|
|
437
|
+
const delBEventId = lastResponse.body.eventId
|
|
438
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
|
|
439
|
+
const delCEventId = lastResponse.body.eventId
|
|
440
|
+
|
|
441
|
+
expect((await readState()).widgets).toEqual([])
|
|
442
|
+
|
|
443
|
+
// Undo only the LAST delete (c).
|
|
444
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
|
|
445
|
+
expect(lastResponse.status).toBe(200)
|
|
446
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
|
|
447
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
|
|
448
|
+
|
|
449
|
+
const after = await readState()
|
|
450
|
+
expect(after.widgets).toHaveLength(1)
|
|
451
|
+
expect(after.widgets[0].id).toBe(c.widgetId)
|
|
452
|
+
// a and b stay deleted — the bug class would have resurrected them too.
|
|
453
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
|
|
454
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
|
|
455
|
+
|
|
456
|
+
// Tracking that we don't leave delAEventId / delBEventId mentioned.
|
|
457
|
+
void delAEventId
|
|
458
|
+
void delBEventId
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('undo of widget_updated reverts the props using prevProps', async () => {
|
|
462
|
+
const { widgetId } = await addSticky('original')
|
|
463
|
+
|
|
464
|
+
await invoke('/widget', 'PATCH', {
|
|
465
|
+
name: 'undo-canvas',
|
|
466
|
+
widgetId,
|
|
467
|
+
props: { text: 'edited' },
|
|
468
|
+
})
|
|
469
|
+
const editEventId = lastResponse.body.eventIds[0]
|
|
470
|
+
|
|
471
|
+
let state = await readState()
|
|
472
|
+
expect(state.widgets[0].props.text).toBe('edited')
|
|
473
|
+
|
|
474
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
|
|
475
|
+
state = await readState()
|
|
476
|
+
expect(state.widgets[0].props.text).toBe('original')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('undo of widget_moved reverts the position using prevPosition', async () => {
|
|
480
|
+
const { widgetId } = await addSticky('a')
|
|
481
|
+
|
|
482
|
+
await invoke('/widget', 'PATCH', {
|
|
483
|
+
name: 'undo-canvas',
|
|
484
|
+
widgetId,
|
|
485
|
+
position: { x: 500, y: 600 },
|
|
486
|
+
})
|
|
487
|
+
const moveEventId = lastResponse.body.eventIds[0]
|
|
488
|
+
|
|
489
|
+
let state = await readState()
|
|
490
|
+
expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
|
|
491
|
+
|
|
492
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
|
|
493
|
+
state = await readState()
|
|
494
|
+
expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('redo of an undone delete re-removes the widget', async () => {
|
|
498
|
+
const { widgetId } = await addSticky('a')
|
|
499
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
|
|
500
|
+
const delEventId = lastResponse.body.eventId
|
|
501
|
+
|
|
502
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
|
|
503
|
+
const undoEventId = lastResponse.body.eventId
|
|
504
|
+
expect((await readState()).widgets).toHaveLength(1)
|
|
505
|
+
|
|
506
|
+
// Redo with the undo event id — server emits the inverse of the undo,
|
|
507
|
+
// which is a fresh widget_removed.
|
|
508
|
+
await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
|
|
509
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
|
|
510
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
|
|
511
|
+
|
|
512
|
+
expect((await readState()).widgets).toEqual([])
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('rejects undo on an unknown event id with 404', async () => {
|
|
516
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
|
|
517
|
+
expect(lastResponse.status).toBe(404)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
|
|
521
|
+
// Trigger a widgets_replaced via PUT /update with replaceAll
|
|
522
|
+
await invoke('/update', 'PUT', {
|
|
523
|
+
name: 'undo-canvas',
|
|
524
|
+
widgets: [],
|
|
525
|
+
replaceAll: true,
|
|
526
|
+
})
|
|
527
|
+
// Read the file to get the event id of the widgets_replaced event.
|
|
528
|
+
const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
|
|
529
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
530
|
+
const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
|
|
531
|
+
expect(replacedEvt?.id).toBeTruthy()
|
|
532
|
+
|
|
533
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
|
|
534
|
+
expect(lastResponse.status).toBe(400)
|
|
535
|
+
expect(lastResponse.body.error).toMatch(/cannot be undone/i)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('runaway-session hard ceiling compaction', () => {
|
|
540
|
+
let root, canvasDir, invoke, lastResponse
|
|
541
|
+
|
|
542
|
+
beforeEach(() => {
|
|
543
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
544
|
+
writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
afterEach(() => {
|
|
548
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
// setImmediate-based test helper
|
|
552
|
+
function flushSetImmediate() {
|
|
553
|
+
return new Promise((resolve) => setImmediate(resolve))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
it('does not compact a small file (under the 2 MB threshold)', async () => {
|
|
557
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
558
|
+
await invoke('/widget', 'POST', {
|
|
559
|
+
name: 'big-canvas',
|
|
560
|
+
type: 'sticky-note',
|
|
561
|
+
props: { text: 'hello' },
|
|
562
|
+
position: { x: 0, y: 0 },
|
|
563
|
+
})
|
|
564
|
+
await flushSetImmediate()
|
|
565
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
566
|
+
expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
|
|
567
|
+
// File should still contain both canvas_created + widget_added events.
|
|
568
|
+
const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
569
|
+
expect(events.length).toBe(2)
|
|
570
|
+
expect(events[0].event).toBe('canvas_created')
|
|
571
|
+
expect(events[1].event).toBe('widget_added')
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('compacts the file once it exceeds the runaway threshold', async () => {
|
|
575
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
576
|
+
|
|
577
|
+
// Pre-bloat the file past 2 MB by appending raw garbage events. Each
|
|
578
|
+
// event has a unique id so the materializer treats it as valid.
|
|
579
|
+
const padding = 'x'.repeat(4000)
|
|
580
|
+
const bloatLines = []
|
|
581
|
+
for (let i = 0; i < 600; i++) {
|
|
582
|
+
bloatLines.push(JSON.stringify({
|
|
583
|
+
id: `evt_bloat_${i}`,
|
|
584
|
+
event: 'widget_added',
|
|
585
|
+
timestamp: '2026-01-01',
|
|
586
|
+
widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
|
|
587
|
+
}))
|
|
588
|
+
}
|
|
589
|
+
fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
|
|
590
|
+
const sizeBefore = fs.statSync(filePath).size
|
|
591
|
+
expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
|
|
592
|
+
|
|
593
|
+
// Trigger appendEvent — this should detect the runaway size and kick
|
|
594
|
+
// off a compaction.
|
|
595
|
+
await invoke('/widget', 'POST', {
|
|
596
|
+
name: 'big-canvas',
|
|
597
|
+
type: 'sticky-note',
|
|
598
|
+
props: { text: 'trigger' },
|
|
599
|
+
position: { x: 0, y: 0 },
|
|
600
|
+
})
|
|
601
|
+
// Compaction runs on setImmediate; flush it.
|
|
602
|
+
await flushSetImmediate()
|
|
603
|
+
await flushSetImmediate()
|
|
604
|
+
|
|
605
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
606
|
+
expect(sizeAfter).toBeLessThan(sizeBefore)
|
|
607
|
+
|
|
608
|
+
// After compaction, file should be a single canvas_created event
|
|
609
|
+
// containing all the materialized widgets.
|
|
610
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
|
|
611
|
+
expect(lines.length).toBe(1)
|
|
612
|
+
const baseline = JSON.parse(lines[0])
|
|
613
|
+
expect(baseline.event).toBe('canvas_created')
|
|
614
|
+
// 600 bloat widgets + 1 from the triggering append = 601.
|
|
615
|
+
expect(baseline.widgets.length).toBe(601)
|
|
616
|
+
// Sanity: state still reads cleanly via the normal /read endpoint.
|
|
617
|
+
await invoke('/read?name=big-canvas', 'GET', {})
|
|
618
|
+
expect(lastResponse.status).toBe(200)
|
|
619
|
+
expect(lastResponse.body.widgets.length).toBe(601)
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('image routes — drafts/ subdirectory privacy layer', () => {
|
|
624
|
+
// Minimal 1x1 PNG (base64) for upload tests.
|
|
625
|
+
const PNG_DATA_URL =
|
|
626
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
|
|
627
|
+
|
|
628
|
+
it('routes a draft-canvas upload into images/drafts/ and returns the drafts/ prefix', async () => {
|
|
629
|
+
const { invoke, root, lastResponse } = setup()
|
|
630
|
+
|
|
631
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'drafts/storyboarding' })
|
|
632
|
+
|
|
633
|
+
expect(lastResponse.status).toBe(201)
|
|
634
|
+
expect(lastResponse.body.success).toBe(true)
|
|
635
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\//)
|
|
636
|
+
const filename = lastResponse.body.filename
|
|
637
|
+
const basename = filename.slice('drafts/'.length)
|
|
638
|
+
const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts', basename)
|
|
639
|
+
const publicFile = path.join(root, 'assets', 'canvas', 'images', basename)
|
|
640
|
+
expect(fs.existsSync(draftFile)).toBe(true)
|
|
641
|
+
expect(fs.existsSync(publicFile)).toBe(false)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('routes a non-draft-canvas upload into images/ as before', async () => {
|
|
645
|
+
const { invoke, root, lastResponse } = setup()
|
|
646
|
+
|
|
647
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'storyboarding' })
|
|
648
|
+
|
|
649
|
+
expect(lastResponse.status).toBe(201)
|
|
650
|
+
expect(lastResponse.body.filename).not.toMatch(/^drafts\//)
|
|
651
|
+
const filename = lastResponse.body.filename
|
|
652
|
+
const publicFile = path.join(root, 'assets', 'canvas', 'images', filename)
|
|
653
|
+
expect(fs.existsSync(publicFile)).toBe(true)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('treats a canvas with `drafts` anywhere in its segments as draft', async () => {
|
|
657
|
+
const { invoke, root, lastResponse } = setup()
|
|
658
|
+
|
|
659
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'widget/drafts/v6' })
|
|
660
|
+
|
|
661
|
+
expect(lastResponse.status).toBe(201)
|
|
662
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\//)
|
|
663
|
+
const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts',
|
|
664
|
+
lastResponse.body.filename.slice('drafts/'.length))
|
|
665
|
+
expect(fs.existsSync(draftFile)).toBe(true)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('GET /images/drafts/<basename> serves a draft image', async () => {
|
|
669
|
+
const { invoke, root, lastResponse } = setup()
|
|
670
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
671
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
672
|
+
const payload = Buffer.from([1, 2, 3])
|
|
673
|
+
fs.writeFileSync(path.join(draftsDir, 'hello.png'), payload)
|
|
674
|
+
|
|
675
|
+
await invoke('/images/drafts/hello.png', 'GET', {})
|
|
676
|
+
expect(lastResponse.status).toBe(200)
|
|
677
|
+
expect(lastResponse.bytes).toEqual(payload)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('GET /images/<basename> does NOT fall back to drafts/', async () => {
|
|
681
|
+
const { invoke, root, lastResponse } = setup()
|
|
682
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
683
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
684
|
+
fs.writeFileSync(path.join(draftsDir, 'leak.png'), Buffer.from([1, 2, 3]))
|
|
685
|
+
|
|
686
|
+
await invoke('/images/leak.png', 'GET', {})
|
|
687
|
+
expect(lastResponse.status).toBe(404)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('GET rejects deeper paths than one optional drafts/ segment', async () => {
|
|
691
|
+
const { invoke, lastResponse } = setup()
|
|
692
|
+
|
|
693
|
+
await invoke('/images/drafts/sub/foo.png', 'GET', {})
|
|
694
|
+
expect(lastResponse.status).toBe(400)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('GET rejects path traversal even with drafts prefix', async () => {
|
|
698
|
+
const { invoke, lastResponse } = setup()
|
|
699
|
+
|
|
700
|
+
await invoke('/images/drafts/../etc.png', 'GET', {})
|
|
701
|
+
expect(lastResponse.status).toBe(400)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('toggle-private preserves drafts/ prefix in returned filename', async () => {
|
|
705
|
+
const { invoke, root, lastResponse } = setup()
|
|
706
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
707
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
708
|
+
fs.writeFileSync(path.join(draftsDir, 'foo.png'), Buffer.from([1, 2, 3]))
|
|
709
|
+
|
|
710
|
+
await invoke('/image/toggle-private', 'POST', { filename: 'drafts/foo.png' })
|
|
711
|
+
|
|
712
|
+
expect(lastResponse.status).toBe(200)
|
|
713
|
+
expect(lastResponse.body).toEqual({ success: true, filename: 'drafts/~foo.png', private: true })
|
|
714
|
+
expect(fs.existsSync(path.join(draftsDir, '~foo.png'))).toBe(true)
|
|
715
|
+
expect(fs.existsSync(path.join(draftsDir, 'foo.png'))).toBe(false)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('toggle-private still works on bare basenames in images/', async () => {
|
|
719
|
+
const { invoke, root, lastResponse } = setup()
|
|
720
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
721
|
+
fs.mkdirSync(imagesDir, { recursive: true })
|
|
722
|
+
fs.writeFileSync(path.join(imagesDir, 'bar.png'), Buffer.from([1, 2, 3]))
|
|
723
|
+
|
|
724
|
+
await invoke('/image/toggle-private', 'POST', { filename: 'bar.png' })
|
|
725
|
+
|
|
726
|
+
expect(lastResponse.status).toBe(200)
|
|
727
|
+
expect(lastResponse.body.filename).toBe('~bar.png')
|
|
728
|
+
expect(fs.existsSync(path.join(imagesDir, '~bar.png'))).toBe(true)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('duplicate preserves drafts/ prefix', async () => {
|
|
732
|
+
const { invoke, root, lastResponse } = setup()
|
|
733
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
734
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
735
|
+
fs.writeFileSync(path.join(draftsDir, 'storyboarding--2026-01-01--12-00-00.png'), Buffer.from([1, 2, 3]))
|
|
736
|
+
|
|
737
|
+
await invoke('/image/duplicate', 'POST', { filename: 'drafts/storyboarding--2026-01-01--12-00-00.png' })
|
|
738
|
+
|
|
739
|
+
expect(lastResponse.status).toBe(201)
|
|
740
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\/storyboarding--/)
|
|
741
|
+
})
|
|
742
|
+
})
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { execSync } from 'node:child_process'
|
|
26
|
-
import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync } from 'node:fs'
|
|
26
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs'
|
|
27
27
|
import { resolve, join, dirname } from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
29
29
|
import { tmpdir } from 'node:os'
|
|
@@ -409,6 +409,110 @@ function publicSnapshotDir() {
|
|
|
409
409
|
return join(process.cwd(), 'assets', '.storyboard-public', 'terminal-snapshots')
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
/** Public snapshot index filenames (single consolidated files for all agents). */
|
|
413
|
+
const SNAPSHOT_INDEX_JSON = 'agents.snapshot.json'
|
|
414
|
+
const SNAPSHOT_INDEX_TXT = 'agents-txt.snapshot.json'
|
|
415
|
+
|
|
416
|
+
/** Max entries kept in the snapshot indexes before GC drops the oldest by timestamp. */
|
|
417
|
+
const SNAPSHOT_INDEX_MAX_ENTRIES = 50
|
|
418
|
+
|
|
419
|
+
/** In-memory cache of the snapshot indexes to avoid re-reading on every capture. */
|
|
420
|
+
let snapshotIndexCache = null
|
|
421
|
+
let snapshotTxtIndexCache = null
|
|
422
|
+
let snapshotLegacyFilesCleaned = false
|
|
423
|
+
|
|
424
|
+
function emptyIndex() {
|
|
425
|
+
return { version: 1, updatedAt: null, agents: {} }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function readIndexFile(filePath) {
|
|
429
|
+
try {
|
|
430
|
+
if (!existsSync(filePath)) return emptyIndex()
|
|
431
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
432
|
+
if (parsed && typeof parsed === 'object' && parsed.agents && typeof parsed.agents === 'object') {
|
|
433
|
+
return { version: parsed.version || 1, updatedAt: parsed.updatedAt || null, agents: parsed.agents }
|
|
434
|
+
}
|
|
435
|
+
} catch { /* fall through */ }
|
|
436
|
+
return emptyIndex()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function loadSnapshotIndexes() {
|
|
440
|
+
if (!snapshotIndexCache) {
|
|
441
|
+
snapshotIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON))
|
|
442
|
+
}
|
|
443
|
+
if (!snapshotTxtIndexCache) {
|
|
444
|
+
snapshotTxtIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_TXT))
|
|
445
|
+
}
|
|
446
|
+
return { jsonIndex: snapshotIndexCache, txtIndex: snapshotTxtIndexCache }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Garbage-collect index entries to keep size manageable.
|
|
451
|
+
* Drops oldest entries by timestamp until under the cap. Operates on both
|
|
452
|
+
* indexes together so they stay in sync.
|
|
453
|
+
*/
|
|
454
|
+
function gcSnapshotIndexes(jsonIndex, txtIndex) {
|
|
455
|
+
const keys = Object.keys(jsonIndex.agents)
|
|
456
|
+
if (keys.length <= SNAPSHOT_INDEX_MAX_ENTRIES) return
|
|
457
|
+
const sorted = keys
|
|
458
|
+
.map((k) => ({ k, t: jsonIndex.agents[k]?.timestamp || '' }))
|
|
459
|
+
.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
|
|
460
|
+
const dropCount = keys.length - SNAPSHOT_INDEX_MAX_ENTRIES
|
|
461
|
+
for (let i = 0; i < dropCount; i++) {
|
|
462
|
+
const id = sorted[i].k
|
|
463
|
+
delete jsonIndex.agents[id]
|
|
464
|
+
delete txtIndex.agents[id]
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function atomicWriteJson(filePath, data) {
|
|
469
|
+
const tmpPath = filePath + '.tmp'
|
|
470
|
+
try {
|
|
471
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
|
|
472
|
+
renameSync(tmpPath, filePath)
|
|
473
|
+
} catch (err) {
|
|
474
|
+
try { if (existsSync(tmpPath)) unlinkSync(tmpPath) } catch {} // eslint-disable-line no-empty
|
|
475
|
+
throw err
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function writeSnapshotIndexes(jsonIndex, txtIndex) {
|
|
480
|
+
const sDir = publicSnapshotDir()
|
|
481
|
+
try { mkdirSync(sDir, { recursive: true }) } catch {} // eslint-disable-line no-empty
|
|
482
|
+
const now = new Date().toISOString()
|
|
483
|
+
jsonIndex.updatedAt = now
|
|
484
|
+
txtIndex.updatedAt = now
|
|
485
|
+
try {
|
|
486
|
+
atomicWriteJson(join(sDir, SNAPSHOT_INDEX_JSON), jsonIndex)
|
|
487
|
+
} catch (err) {
|
|
488
|
+
devLog().logEvent('error', 'Failed to write agents.snapshot.json', { error: err.message })
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
atomicWriteJson(join(sDir, SNAPSHOT_INDEX_TXT), txtIndex)
|
|
492
|
+
} catch (err) {
|
|
493
|
+
devLog().logEvent('error', 'Failed to write agents-txt.snapshot.json', { error: err.message })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* One-time cleanup: remove legacy per-widget snapshot files left in the
|
|
499
|
+
* public dir from before the consolidated-index format. Runs on first call.
|
|
500
|
+
*/
|
|
501
|
+
function cleanupLegacySnapshotFiles() {
|
|
502
|
+
if (snapshotLegacyFilesCleaned) return
|
|
503
|
+
snapshotLegacyFilesCleaned = true
|
|
504
|
+
const sDir = publicSnapshotDir()
|
|
505
|
+
if (!existsSync(sDir)) return
|
|
506
|
+
try {
|
|
507
|
+
for (const file of readdirSync(sDir)) {
|
|
508
|
+
if (file === SNAPSHOT_INDEX_JSON || file === SNAPSHOT_INDEX_TXT) continue
|
|
509
|
+
if (file.endsWith('.snapshot.json') || file.endsWith('.snapshot.txt')) {
|
|
510
|
+
try { unlinkSync(join(sDir, file)) } catch {} // eslint-disable-line no-empty
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
} catch { /* empty */ }
|
|
514
|
+
}
|
|
515
|
+
|
|
412
516
|
/**
|
|
413
517
|
* Read the `private` prop for a widget from the terminal config.
|
|
414
518
|
* Returns true if the widget has props.private === true.
|
|
@@ -496,39 +600,28 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
|
|
|
496
600
|
try { if (existsSync(txtTmpPath)) unlinkSync(txtTmpPath) } catch {} // eslint-disable-line no-empty
|
|
497
601
|
}
|
|
498
602
|
|
|
499
|
-
// ── Public snapshot (assets/.storyboard-public/terminal-snapshots/) ──
|
|
603
|
+
// ── Public snapshot index (assets/.storyboard-public/terminal-snapshots/) ──
|
|
604
|
+
// We maintain two consolidated index files keyed by widgetId:
|
|
605
|
+
// - agents.snapshot.json → structured data
|
|
606
|
+
// - agents-txt.snapshot.json → human-readable text payloads
|
|
607
|
+
cleanupLegacySnapshotFiles()
|
|
500
608
|
const isPrivate = isWidgetPrivate(widgetId, canvasId)
|
|
501
|
-
const
|
|
502
|
-
const snapshotPath = join(sDir, `${widgetId}.snapshot.json`)
|
|
503
|
-
const snapshotTxtPath = join(sDir, `${widgetId}.snapshot.txt`)
|
|
504
|
-
const tildeSnapshotPath = join(sDir, `~${widgetId}.snapshot.json`)
|
|
505
|
-
const tildeSnapshotTxtPath = join(sDir, `~${widgetId}.snapshot.txt`)
|
|
609
|
+
const { jsonIndex, txtIndex } = loadSnapshotIndexes()
|
|
506
610
|
|
|
507
611
|
if (isPrivate) {
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (existsSync(snapshotTxtPath)) {
|
|
513
|
-
try { renameSync(snapshotTxtPath, tildeSnapshotTxtPath) } catch {} // eslint-disable-line no-empty
|
|
514
|
-
}
|
|
612
|
+
let changed = false
|
|
613
|
+
if (jsonIndex.agents[widgetId]) { delete jsonIndex.agents[widgetId]; changed = true }
|
|
614
|
+
if (txtIndex.agents[widgetId]) { delete txtIndex.agents[widgetId]; changed = true }
|
|
615
|
+
if (changed) writeSnapshotIndexes(jsonIndex, txtIndex)
|
|
515
616
|
return
|
|
516
617
|
}
|
|
517
618
|
|
|
518
|
-
// If un-privated, restore from tilde if the public files don't exist yet
|
|
519
|
-
if (existsSync(tildeSnapshotPath) && !existsSync(snapshotPath)) {
|
|
520
|
-
try { renameSync(tildeSnapshotPath, snapshotPath) } catch {} // eslint-disable-line no-empty
|
|
521
|
-
}
|
|
522
|
-
if (existsSync(tildeSnapshotTxtPath) && !existsSync(snapshotTxtPath)) {
|
|
523
|
-
try { renameSync(tildeSnapshotTxtPath, snapshotTxtPath) } catch {} // eslint-disable-line no-empty
|
|
524
|
-
}
|
|
525
|
-
|
|
526
619
|
const snapshotScrollback = getRollingBufferContent(tmuxName, SNAPSHOT_MAX_AGE_MS)
|
|
527
620
|
const strippedPane = stripAnsi(paneContent)
|
|
528
621
|
const strippedScrollback = stripAnsi(snapshotScrollback)
|
|
529
622
|
|
|
530
|
-
// ── JSON
|
|
531
|
-
|
|
623
|
+
// ── JSON entry ──
|
|
624
|
+
jsonIndex.agents[widgetId] = {
|
|
532
625
|
widgetId,
|
|
533
626
|
canvasId,
|
|
534
627
|
prettyName: prettyName || null,
|
|
@@ -539,24 +632,7 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
|
|
|
539
632
|
scrollback: strippedScrollback,
|
|
540
633
|
}
|
|
541
634
|
|
|
542
|
-
|
|
543
|
-
mkdirSync(sDir, { recursive: true })
|
|
544
|
-
} catch (err) {
|
|
545
|
-
devLog().logEvent('error', 'Failed to create public snapshot dir', { dir: sDir, error: err.message })
|
|
546
|
-
return
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const snapshotTmpPath = snapshotPath + '.tmp'
|
|
550
|
-
try {
|
|
551
|
-
writeFileSync(snapshotTmpPath, JSON.stringify(snapshotData, null, 2), 'utf8')
|
|
552
|
-
renameSync(snapshotTmpPath, snapshotPath)
|
|
553
|
-
} catch (err) {
|
|
554
|
-
devLog().logEvent('error', 'Failed to write public snapshot JSON', { widgetId, error: err.message, path: snapshotPath })
|
|
555
|
-
try { if (existsSync(snapshotTmpPath)) unlinkSync(snapshotTmpPath) } catch {} // eslint-disable-line no-empty
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// ── Human-readable text snapshot ──
|
|
559
|
-
const snapshotTxtTmpPath = snapshotTxtPath + '.tmp'
|
|
635
|
+
// ── Text entry ──
|
|
560
636
|
try {
|
|
561
637
|
const screenText = strippedPane.replace(/\r\n/g, '\n').replace(/\n+$/, '')
|
|
562
638
|
const scrollText = strippedScrollback.replace(/\r\n/g, '\n').replace(/\n+$/, '')
|
|
@@ -583,12 +659,13 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
|
|
|
583
659
|
snpTxt += scrollText + '\n'
|
|
584
660
|
}
|
|
585
661
|
|
|
586
|
-
|
|
587
|
-
renameSync(snapshotTxtTmpPath, snapshotTxtPath)
|
|
662
|
+
txtIndex.agents[widgetId] = snpTxt
|
|
588
663
|
} catch (err) {
|
|
589
|
-
devLog().logEvent('error', 'Failed to
|
|
590
|
-
try { if (existsSync(snapshotTxtTmpPath)) unlinkSync(snapshotTxtTmpPath) } catch {} // eslint-disable-line no-empty
|
|
664
|
+
devLog().logEvent('error', 'Failed to build snapshot text entry', { widgetId, error: err.message })
|
|
591
665
|
}
|
|
666
|
+
|
|
667
|
+
gcSnapshotIndexes(jsonIndex, txtIndex)
|
|
668
|
+
writeSnapshotIndexes(jsonIndex, txtIndex)
|
|
592
669
|
}
|
|
593
670
|
|
|
594
671
|
/** Start periodic snapshot capture for a session */
|
|
@@ -1209,11 +1286,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
|
|
|
1209
1286
|
const finalize = () => {
|
|
1210
1287
|
if (completed) return
|
|
1211
1288
|
completed = true
|
|
1289
|
+
// Identity/role MUST be injected before postStartup. Commands like
|
|
1290
|
+
// `/autopilot` flip the agent into a mode where the next message is
|
|
1291
|
+
// consumed as a task — so we want canvas context in conversation
|
|
1292
|
+
// history *first*, then the mode-toggling slash command.
|
|
1293
|
+
injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
|
|
1294
|
+
injectRoleMessageForWidget(tmuxName, widgetId)
|
|
1212
1295
|
if (postStartup) {
|
|
1213
1296
|
tmuxSubmit(tmuxName, postStartup)
|
|
1214
1297
|
}
|
|
1215
|
-
injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
|
|
1216
|
-
injectRoleMessageForWidget(tmuxName, widgetId)
|
|
1217
1298
|
// Bind to messaging bus for live delivery + backfill missed messages
|
|
1218
1299
|
setTimeout(() => {
|
|
1219
1300
|
migratePendingMessages(widgetId, branch, canvasId).then(() => {
|
|
@@ -1408,11 +1489,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
|
|
|
1408
1489
|
clearInterval(pollInterval)
|
|
1409
1490
|
try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
|
|
1410
1491
|
setTimeout(() => {
|
|
1492
|
+
// Identity/role/messaging-bind MUST happen BEFORE postStartup.
|
|
1493
|
+
// Slash commands like `/autopilot` flip the agent into a mode
|
|
1494
|
+
// where the next message is consumed as a task — so we want
|
|
1495
|
+
// canvas context (widgetId, configFile, role) in conversation
|
|
1496
|
+
// history first, then the mode-toggling slash command last.
|
|
1497
|
+
softBind()
|
|
1411
1498
|
if (postStartup) {
|
|
1412
|
-
tmuxSubmit(tmuxName, postStartup)
|
|
1499
|
+
setTimeout(() => tmuxSubmit(tmuxName, postStartup), 1500)
|
|
1413
1500
|
}
|
|
1414
|
-
// Ensure identity/bind happened (no-op if already soft-bound)
|
|
1415
|
-
softBind()
|
|
1416
1501
|
}, 500)
|
|
1417
1502
|
if (reason === 'soft-bind-timeout') {
|
|
1418
1503
|
devLog(`[terminal-server] readiness slow for ${widgetId} (${tmuxName}); soft-binding now, will run postStartup when .ready arrives`)
|
|
@@ -1765,15 +1850,17 @@ export function readTerminalBuffer(widgetId, { maxLength } = {}) {
|
|
|
1765
1850
|
}
|
|
1766
1851
|
|
|
1767
1852
|
/**
|
|
1768
|
-
* Read a terminal public snapshot by widget ID.
|
|
1769
|
-
*
|
|
1853
|
+
* Read a terminal public snapshot by widget ID from the consolidated index.
|
|
1854
|
+
* Falls back to legacy nested path if not present.
|
|
1770
1855
|
*/
|
|
1771
1856
|
export function readTerminalSnapshot(widgetId, canvasId) {
|
|
1772
|
-
// New
|
|
1773
|
-
const
|
|
1857
|
+
// New: consolidated index at agents.snapshot.json
|
|
1858
|
+
const indexPath = join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON)
|
|
1774
1859
|
try {
|
|
1775
|
-
if (existsSync(
|
|
1776
|
-
|
|
1860
|
+
if (existsSync(indexPath)) {
|
|
1861
|
+
const parsed = JSON.parse(readFileSync(indexPath, 'utf8'))
|
|
1862
|
+
const entry = parsed?.agents?.[widgetId]
|
|
1863
|
+
if (entry) return entry
|
|
1777
1864
|
}
|
|
1778
1865
|
} catch { /* empty */ }
|
|
1779
1866
|
|