@swirls/looms 0.2.0 → 0.3.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/actor/host.js +4 -6
  3. package/dist/actor/index.d.ts +1 -1
  4. package/dist/actor/index.js +1 -1
  5. package/dist/actor/routing.d.ts +5 -0
  6. package/dist/actor/routing.js +14 -0
  7. package/dist/agent/agent-session.js +1 -0
  8. package/dist/agent/debugger/agent-chat.js +45 -11
  9. package/dist/agent/debugger/summarize.js +2 -0
  10. package/dist/agent/effects.d.ts +30 -0
  11. package/dist/agent/events.d.ts +2 -0
  12. package/dist/agent/events.js +2 -0
  13. package/dist/agent/llm.js +1 -8
  14. package/dist/agent/module.d.ts +604 -534
  15. package/dist/agent/module.js +11 -7
  16. package/dist/agent/projections.d.ts +5 -0
  17. package/dist/agent/projections.js +43 -2
  18. package/dist/agent/scope.d.ts +2 -0
  19. package/dist/agent/signals.d.ts +5 -0
  20. package/dist/agent/signals.js +7 -0
  21. package/dist/agent/threads.js +35 -21
  22. package/dist/agent/transcript.d.ts +7 -0
  23. package/dist/agent/transcript.js +19 -0
  24. package/dist/agent/types.d.ts +10 -0
  25. package/dist/agent/types.js +4 -0
  26. package/dist/approval/debugger/approvals-view.js +5 -3
  27. package/dist/approval/effects.d.ts +12 -0
  28. package/dist/approval/module.d.ts +24 -0
  29. package/dist/client/client.d.ts +292 -0
  30. package/dist/client/client.js +17 -1
  31. package/dist/cloudflare/router.d.ts +1 -1
  32. package/dist/cloudflare/router.js +6 -8
  33. package/dist/core/effects.d.ts +7 -0
  34. package/dist/core/effects.js +1 -0
  35. package/dist/core/fold.js +12 -6
  36. package/dist/core/fork.d.ts +35 -0
  37. package/dist/core/fork.js +129 -0
  38. package/dist/core/index.d.ts +1 -0
  39. package/dist/core/index.js +1 -0
  40. package/dist/core/module-scope.d.ts +1 -0
  41. package/dist/core/module.d.ts +2 -2
  42. package/dist/core/projection.js +1 -0
  43. package/dist/core/protocol.d.ts +10 -1
  44. package/dist/core/protocol.js +11 -0
  45. package/dist/core/replay.d.ts +8 -0
  46. package/dist/core/snapshots.js +3 -17
  47. package/dist/core/state.d.ts +14 -0
  48. package/dist/core/state.js +25 -2
  49. package/dist/core/store.d.ts +1 -0
  50. package/dist/debugger/app/assets/{index-Qb371t_O.js → index-BevQYa-U.js} +2 -2
  51. package/dist/debugger/app/assets/index-CgDzWJg7.css +2 -0
  52. package/dist/debugger/app/assets/routes-B17Om93i.js +47 -0
  53. package/dist/debugger/app/index.html +2 -2
  54. package/dist/debugger/components/composer.d.ts +5 -2
  55. package/dist/debugger/components/composer.js +5 -4
  56. package/dist/debugger/components/run-tree.js +2 -5
  57. package/dist/debugger/events/catalog.js +1 -3
  58. package/dist/debugger/events/protocol-summarize.js +7 -0
  59. package/dist/debugger/lib/event-counts.d.ts +2 -2
  60. package/dist/debugger/lib/event-counts.js +5 -9
  61. package/dist/debugger/shell/debugger-shell.js +1 -1
  62. package/dist/debugger/shell/run-controls.d.ts +2 -1
  63. package/dist/debugger/shell/run-controls.js +40 -13
  64. package/dist/debugger/shell/run-search.js +1 -3
  65. package/dist/evaluator/effects.d.ts +12 -0
  66. package/dist/evaluator/module.d.ts +24 -0
  67. package/dist/projectors/index-model.d.ts +13 -7
  68. package/dist/projectors/index-model.js +21 -26
  69. package/dist/projectors/memory.js +19 -11
  70. package/dist/projectors/postgres.js +20 -19
  71. package/dist/projectors/schema.js +20 -18
  72. package/dist/projectors/sqlite.js +32 -27
  73. package/dist/react/materialize.js +16 -11
  74. package/dist/react/react.d.ts +6 -2
  75. package/dist/react/react.js +5 -2
  76. package/dist/react/tables.d.ts +3 -2
  77. package/dist/runtime/cancellation.js +14 -27
  78. package/dist/runtime/errors.d.ts +39 -0
  79. package/dist/runtime/errors.js +32 -0
  80. package/dist/runtime/helpers.d.ts +2 -0
  81. package/dist/runtime/helpers.js +23 -1
  82. package/dist/runtime/index.d.ts +1 -1
  83. package/dist/runtime/index.js +1 -1
  84. package/dist/runtime/looms.d.ts +4 -0
  85. package/dist/runtime/looms.js +1 -0
  86. package/dist/runtime/run-start.js +1 -3
  87. package/dist/runtime/runtime.d.ts +1 -1
  88. package/dist/runtime/runtime.js +45 -18
  89. package/dist/runtime/server/http.d.ts +2 -0
  90. package/dist/runtime/server/http.js +17 -2
  91. package/dist/runtime/server/routes/runs.js +35 -2
  92. package/dist/runtime/types.d.ts +8 -0
  93. package/dist/runtime/wake-engine.js +5 -2
  94. package/dist/s2/snapshot-store.js +2 -6
  95. package/dist/workflow/effects.d.ts +24 -0
  96. package/dist/workflow/effects.js +1 -0
  97. package/dist/workflow/module.d.ts +48 -0
  98. package/package.json +1 -1
  99. package/dist/debugger/app/assets/index-B3Ah8WAE.css +0 -2
  100. package/dist/debugger/app/assets/routes-CjtEHgPQ.js +0 -47
@@ -4,9 +4,9 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Looms debugger</title>
7
- <script type="module" crossorigin src="./assets/index-Qb371t_O.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-BevQYa-U.js"></script>
8
8
  <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-CbXtAM7H.js">
9
- <link rel="stylesheet" crossorigin href="./assets/index-B3Ah8WAE.css">
9
+ <link rel="stylesheet" crossorigin href="./assets/index-CgDzWJg7.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -1,12 +1,15 @@
1
1
  import type { ReactNode } from 'react';
2
2
  /**
3
3
  * Message input with one primary action. Enter sends, Shift+Enter adds a newline,
4
- * and `toolbar` holds secondary controls such as delivery mode.
4
+ * and `toolbar` holds secondary controls such as delivery mode. `onStop` adds a
5
+ * Stop button beside Send while work is in progress.
5
6
  */
6
- export declare function Composer({ value, onChange, onSubmit, pending, disabled, placeholder, submitLabel, toolbar, error, className, }: {
7
+ export declare function Composer({ value, onChange, onSubmit, onStop, stopping, pending, disabled, placeholder, submitLabel, toolbar, error, className, }: {
7
8
  value: string;
8
9
  onChange: (value: string) => void;
9
10
  onSubmit: () => void;
11
+ onStop?: () => void;
12
+ stopping?: boolean;
10
13
  pending?: boolean;
11
14
  disabled?: boolean;
12
15
  placeholder?: string;
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowUp, LoaderCircle } from 'lucide-react';
2
+ import { ArrowUp, LoaderCircle, Square } from 'lucide-react';
3
3
  import { cn } from '../lib/cn.js';
4
4
  import { Button } from '../ui/button.js';
5
5
  import { Kbd } from '../ui/panel.js';
6
6
  /**
7
7
  * Message input with one primary action. Enter sends, Shift+Enter adds a newline,
8
- * and `toolbar` holds secondary controls such as delivery mode.
8
+ * and `toolbar` holds secondary controls such as delivery mode. `onStop` adds a
9
+ * Stop button beside Send while work is in progress.
9
10
  */
10
- export function Composer({ value, onChange, onSubmit, pending = false, disabled = false, placeholder = 'Message', submitLabel = 'Send', toolbar, error, className, }) {
11
+ export function Composer({ value, onChange, onSubmit, onStop, stopping = false, pending = false, disabled = false, placeholder = 'Message', submitLabel = 'Send', toolbar, error, className, }) {
11
12
  const canSubmit = !pending && !disabled && value.trim().length > 0;
12
13
  return (_jsxs("form", { className: cn('border-border shrink-0 border-t p-3', className), onSubmit: (event) => {
13
14
  event.preventDefault();
@@ -21,5 +22,5 @@ export function Composer({ value, onChange, onSubmit, pending = false, disabled
21
22
  onSubmit();
22
23
  }
23
24
  }
24
- }, className: "placeholder:text-muted-foreground block field-sizing-content max-h-48 min-h-11 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-[13px] leading-relaxed outline-none disabled:cursor-not-allowed disabled:opacity-50" }), _jsxs("div", { className: "flex h-10 items-center gap-1 pr-1.5 pl-1.5", children: [toolbar, _jsxs("span", { className: "text-muted-foreground ml-auto hidden items-center gap-1 pr-1 text-[10px] @sm:flex", children: [_jsx(Kbd, { children: "\u21B5" }), submitLabel.toLowerCase(), _jsx(Kbd, { className: "ml-1.5", children: "\u21E7\u21B5" }), "newline"] }), _jsx(Button, { type: "submit", size: "icon-sm", "aria-label": submitLabel, disabled: !canSubmit, className: "ml-auto rounded-full @sm:ml-0", children: pending ? _jsx(LoaderCircle, { className: "animate-spin" }) : _jsx(ArrowUp, {}) })] })] }), error ? _jsx("p", { className: "text-status-failed mt-2 px-1 text-[11px]", children: error }) : null] }));
25
+ }, className: "placeholder:text-muted-foreground block field-sizing-content max-h-48 min-h-11 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-[13px] leading-relaxed outline-none disabled:cursor-not-allowed disabled:opacity-50" }), _jsxs("div", { className: "flex h-10 items-center gap-1 pr-1.5 pl-1.5", children: [toolbar, _jsxs("span", { className: "text-muted-foreground ml-auto hidden items-center gap-1 pr-1 text-[10px] @sm:flex", children: [_jsx(Kbd, { children: "\u21B5" }), submitLabel.toLowerCase(), _jsx(Kbd, { className: "ml-1.5", children: "\u21E7\u21B5" }), "newline"] }), onStop ? (_jsx(Button, { type: "button", size: "icon-sm", variant: "outline", "aria-label": "Stop", title: "Stop the current turn", disabled: stopping, onClick: onStop, className: "ml-auto rounded-full @sm:ml-0", children: stopping ? (_jsx(LoaderCircle, { className: "animate-spin" })) : (_jsx(Square, { className: "fill-current" })) })) : null, _jsx(Button, { type: "submit", size: "icon-sm", "aria-label": submitLabel, disabled: !canSubmit, className: cn('rounded-full', !onStop && 'ml-auto @sm:ml-0'), children: pending ? _jsx(LoaderCircle, { className: "animate-spin" }) : _jsx(ArrowUp, {}) })] })] }), error ? _jsx("p", { className: "text-status-failed mt-2 px-1 text-[11px]", children: error }) : null] }));
25
26
  }
@@ -21,11 +21,8 @@ function flatten(root, collapsed) {
21
21
  }
22
22
  rows.push(next);
23
23
  if (!collapsed.has(next.node.threadId)) {
24
- for (let index = next.node.children.length - 1; index >= 0; index--) {
25
- const child = next.node.children[index];
26
- if (child) {
27
- stack.push({ node: child, depth: next.depth + 1 });
28
- }
24
+ for (const child of next.node.children.toReversed()) {
25
+ stack.push({ node: child, depth: next.depth + 1 });
29
26
  }
30
27
  }
31
28
  }
@@ -12,9 +12,7 @@ function searchText(event, summary) {
12
12
  /** Each event type belongs to the family view with the longest matching prefix. */
13
13
  export function createEventCatalog(plugins = []) {
14
14
  const views = [...plugins.flatMap((plugin) => plugin.families ?? []), ...builtinFamilies];
15
- // ES2022 has no Array#toSorted. `views` is a fresh array.
16
- // oxlint-disable-next-line unicorn/no-array-sort
17
- const byPrefix = views.sort((left, right) => prefixOf(right).length - prefixOf(left).length);
15
+ const byPrefix = views.toSorted((left, right) => prefixOf(right).length - prefixOf(left).length);
18
16
  const colors = new Map(views.map((view) => [view.family, view.color]));
19
17
  function viewOf(type) {
20
18
  return byPrefix.find((view) => type.startsWith(prefixOf(view))) ?? runtimeFamily;
@@ -19,6 +19,13 @@ export function summarizeProtocolEvent(event) {
19
19
  };
20
20
  case 'runtime.run.cancelled':
21
21
  return { title: 'run cancelled', detail: jsonText(payload.reason) };
22
+ case 'runtime.run.forked':
23
+ return {
24
+ title: 'run forked',
25
+ detail: `from ${jsonText(payload.sourceRunId) ?? 'unknown'} at seq ${jsonNumberText(payload.sourceSeq) ?? '?'}`,
26
+ };
27
+ case 'runtime.fork.elided':
28
+ return { title: 'left out of fork', detail: jsonText(payload.type) };
22
29
  case 'runtime.thread.started':
23
30
  return {
24
31
  title: 'thread started',
@@ -1,5 +1,5 @@
1
- import { type EventEnvelope } from '@swirls/looms/core';
1
+ import { type EventEnvelope, type RunStatus } from '@swirls/looms/core';
2
2
  /** Build thread→count map via the shared EventIndex. */
3
3
  export declare function countEventsByThread(events: readonly EventEnvelope[]): Map<string, number>;
4
- export declare function runStatusFromEvents(events: readonly EventEnvelope[]): string;
4
+ export declare function runStatusFromEvents(events: readonly EventEnvelope[]): RunStatus;
5
5
  export declare function startedAtFromEvents(events: readonly EventEnvelope[]): number | undefined;
@@ -1,4 +1,4 @@
1
- import { EventIndex, isJsonObject, isJsonString } from '@swirls/looms/core';
1
+ import { completedRunStatus, EventIndex } from '@swirls/looms/core';
2
2
  /** Build thread→count map via the shared EventIndex. */
3
3
  export function countEventsByThread(events) {
4
4
  const index = new EventIndex();
@@ -6,15 +6,11 @@ export function countEventsByThread(events) {
6
6
  return new Map(index.getCounts());
7
7
  }
8
8
  export function runStatusFromEvents(events) {
9
- for (let index = events.length - 1; index >= 0; index -= 1) {
10
- const event = events[index];
11
- if (!event || event.type !== 'runtime.run.completed') {
12
- continue;
13
- }
14
- const payload = isJsonObject(event.payload) ? event.payload : {};
15
- return isJsonString(payload.error) && payload.error.length > 0 ? 'failed' : 'completed';
9
+ const ended = events.findLast((event) => event.type === 'runtime.run.cancelled' || event.type === 'runtime.run.completed');
10
+ if (!ended) {
11
+ return 'running';
16
12
  }
17
- return 'running';
13
+ return ended.type === 'runtime.run.cancelled' ? 'cancelled' : completedRunStatus(ended.payload);
18
14
  }
19
15
  export function startedAtFromEvents(events) {
20
16
  const index = new EventIndex();
@@ -36,7 +36,7 @@ function DebuggerFrame({ selection, onSelectionChange, }) {
36
36
  kind: run.kind ?? undefined,
37
37
  name: run.definitionName ?? undefined,
38
38
  run: run.runId,
39
- }) }), _jsxs("div", { className: "ml-auto flex min-w-0 items-center gap-2", children: [error ? (_jsx("p", { className: "text-status-failed truncate text-[11px]", title: error, children: error })) : null, selection.run ? _jsx(RunControls, { runId: selection.run }) : null] })] }), _jsxs(ResizablePanelGroup, { id: "looms-debugger-columns", className: "min-h-0 flex-1", defaultLayout: layout.defaultLayout, onLayoutChanged: layout.onLayoutChanged, children: [_jsx(ResizablePanel, { id: "tree", defaultSize: "17%", minSize: "12rem", maxSize: "30%", className: "bg-sidebar", children: _jsx("section", { "aria-label": "Run tree", className: "h-full min-h-0 overflow-hidden", children: selection.run ? (_jsx(RunTreePane, { runId: selection.run, threadId: selection.thread, onSelectThread: (thread) => onSelectionChange({ ...selection, thread, seq: undefined }) })) : (_jsxs("div", { className: "flex h-full flex-col", children: [_jsx(PanelHeader, { children: _jsx(PanelTitle, { children: "Run tree" }) }), _jsx(EmptyState, { icon: _jsx(ListTree, {}), title: "No run selected", children: "Threads and child runs appear here once a run starts." })] })) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "workspace", defaultSize: "29%", minSize: "16rem", children: _jsx("section", { "aria-label": "Workspace", className: "@container h-full min-h-0 overflow-hidden", children: _jsx(Workspace, { definition: selected, runId: selection.run, onStarted: (run) => onSelectionChange({ kind, name, run }) }) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "events", defaultSize: "32%", minSize: "16rem", children: _jsx("section", { "aria-label": "Events", className: "h-full min-h-0 overflow-hidden", children: selection.run ? (_jsx(EventsPane, { runId: selection.run, threadId: selection.thread, seq: selection.seq, onSelectSeq: (seq) => onSelectionChange({ ...selection, seq }), onClearThread: () => onSelectionChange({ ...selection, thread: undefined }) })) : (_jsxs("div", { className: "flex h-full flex-col", children: [_jsx(PanelHeader, { children: _jsx(PanelTitle, { children: "Events" }) }), _jsx(EmptyState, { icon: _jsx(Activity, {}), title: "No events yet", children: "Events stream in live once a run starts." })] })) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "projections", defaultSize: "22%", minSize: "14rem", className: "bg-sidebar", children: _jsx("section", { "aria-label": "Projections", className: "h-full min-h-0 overflow-hidden", children: _jsx(ProjectionsPanel, { runId: selection.run, names: catalog.projections }) }) })] })] }));
39
+ }) }), _jsxs("div", { className: "ml-auto flex min-w-0 items-center gap-2", children: [error ? (_jsx("p", { className: "text-status-failed truncate text-[11px]", title: error, children: error })) : null, selection.run ? (_jsx(RunControls, { runId: selection.run, onForked: (run) => onSelectionChange({ kind, name, run }) })) : null] })] }), _jsxs(ResizablePanelGroup, { id: "looms-debugger-columns", className: "min-h-0 flex-1", defaultLayout: layout.defaultLayout, onLayoutChanged: layout.onLayoutChanged, children: [_jsx(ResizablePanel, { id: "tree", defaultSize: "17%", minSize: "12rem", maxSize: "30%", className: "bg-sidebar", children: _jsx("section", { "aria-label": "Run tree", className: "h-full min-h-0 overflow-hidden", children: selection.run ? (_jsx(RunTreePane, { runId: selection.run, threadId: selection.thread, onSelectThread: (thread) => onSelectionChange({ ...selection, thread, seq: undefined }) })) : (_jsxs("div", { className: "flex h-full flex-col", children: [_jsx(PanelHeader, { children: _jsx(PanelTitle, { children: "Run tree" }) }), _jsx(EmptyState, { icon: _jsx(ListTree, {}), title: "No run selected", children: "Threads and child runs appear here once a run starts." })] })) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "workspace", defaultSize: "29%", minSize: "16rem", children: _jsx("section", { "aria-label": "Workspace", className: "@container h-full min-h-0 overflow-hidden", children: _jsx(Workspace, { definition: selected, runId: selection.run, onStarted: (run) => onSelectionChange({ kind, name, run }) }) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "events", defaultSize: "32%", minSize: "16rem", children: _jsx("section", { "aria-label": "Events", className: "h-full min-h-0 overflow-hidden", children: selection.run ? (_jsx(EventsPane, { runId: selection.run, threadId: selection.thread, seq: selection.seq, onSelectSeq: (seq) => onSelectionChange({ ...selection, seq }), onClearThread: () => onSelectionChange({ ...selection, thread: undefined }) })) : (_jsxs("div", { className: "flex h-full flex-col", children: [_jsx(PanelHeader, { children: _jsx(PanelTitle, { children: "Events" }) }), _jsx(EmptyState, { icon: _jsx(Activity, {}), title: "No events yet", children: "Events stream in live once a run starts." })] })) }) }), _jsx(ResizableHandle, {}), _jsx(ResizablePanel, { id: "projections", defaultSize: "22%", minSize: "14rem", className: "bg-sidebar", children: _jsx("section", { "aria-label": "Projections", className: "h-full min-h-0 overflow-hidden", children: _jsx(ProjectionsPanel, { runId: selection.run, names: catalog.projections }) }) })] })] }));
40
40
  }
41
41
  export function DebuggerShell({ plugins, selection, onSelectionChange, endpoint, }) {
42
42
  return (_jsx(DebuggerProvider, { endpoint: endpoint, plugins: plugins, children: _jsx(DebuggerFrame, { selection: selection, onSelectionChange: onSelectionChange }) }));
@@ -1,4 +1,5 @@
1
1
  /** Run-scoped actions that live beside the run switcher. */
2
- export declare function RunControls({ runId }: {
2
+ export declare function RunControls({ runId, onForked, }: {
3
3
  runId: string;
4
+ onForked: (runId: string) => void;
4
5
  }): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlarmClock, CircleStop, Radio } from 'lucide-react';
3
- import { useState } from 'react';
2
+ import { Effect, Fiber } from 'effect';
3
+ import { AlarmClock, CircleStop, GitFork, Radio } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
4
5
  import { cleanUndefined } from '@swirls/looms/core';
5
6
  import { useRunStore, useRunSummary } from '@swirls/looms/react';
6
7
  import { useDebugger } from '../context.js';
@@ -10,7 +11,7 @@ import { Input } from '../ui/input.js';
10
11
  import { Kbd } from '../ui/panel.js';
11
12
  import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
12
13
  import { Textarea } from '../ui/textarea.js';
13
- const CANCEL_TYPE = 'runtime.thread.cancel.requested';
14
+ const NOTICE_MS = 3_000;
14
15
  function useRunAction() {
15
16
  const [error, setError] = useState(null);
16
17
  const [pending, setPending] = useState(false);
@@ -18,7 +19,7 @@ function useRunAction() {
18
19
  setPending(true);
19
20
  setError(null);
20
21
  action()
21
- .then(() => onDone?.())
22
+ .then((value) => onDone?.(value))
22
23
  .catch((cause) => setError(errorMessage(cause)))
23
24
  .finally(() => setPending(false));
24
25
  }
@@ -29,7 +30,7 @@ function Field({ label, hint, children }) {
29
30
  }
30
31
  function SignalForm({ runId, rootThreadId }) {
31
32
  const { client } = useDebugger();
32
- const [type, setType] = useState(CANCEL_TYPE);
33
+ const [type, setType] = useState('');
33
34
  const [payload, setPayload] = useState('{}');
34
35
  const [thread, setThread] = useState('');
35
36
  const [sent, setSent] = useState(false);
@@ -53,7 +54,7 @@ function SignalForm({ runId, rootThreadId }) {
53
54
  return (_jsxs("form", { className: "grid gap-3", onSubmit: (event) => {
54
55
  event.preventDefault();
55
56
  submit();
56
- }, children: [_jsxs("div", { children: [_jsx("p", { className: "text-[13px] font-medium", children: "Send a signal" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Append an event to this run's log." })] }), _jsx(Field, { label: "Type", children: _jsx(Input, { value: type, onChange: (event) => setType(event.target.value), className: "h-7 font-mono text-xs", autoFocus: true }) }), _jsx(Field, { label: "Thread", hint: "defaults to root", children: _jsx(Input, { value: thread, placeholder: rootThreadId ?? 'thread id', onChange: (event) => setThread(event.target.value), className: "h-7 font-mono text-xs" }) }), _jsx(Field, { label: "Payload", hint: "JSON", children: _jsx(Textarea, { value: payload, onChange: (event) => setPayload(event.target.value), onKeyDown: (event) => {
57
+ }, children: [_jsxs("div", { children: [_jsx("p", { className: "text-[13px] font-medium", children: "Send a signal" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Append an event to this run's log." })] }), _jsx(Field, { label: "Type", children: _jsx(Input, { value: type, placeholder: "module.event", onChange: (event) => setType(event.target.value), className: "h-7 font-mono text-xs", autoFocus: true }) }), _jsx(Field, { label: "Thread", hint: "defaults to root", children: _jsx(Input, { value: thread, placeholder: rootThreadId ?? 'thread id', onChange: (event) => setThread(event.target.value), className: "h-7 font-mono text-xs" }) }), _jsx(Field, { label: "Payload", hint: "JSON", children: _jsx(Textarea, { value: payload, onChange: (event) => setPayload(event.target.value), onKeyDown: (event) => {
57
58
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
58
59
  event.preventDefault();
59
60
  submit();
@@ -61,12 +62,38 @@ function SignalForm({ runId, rootThreadId }) {
61
62
  }, className: "max-h-48 min-h-20 font-mono text-xs", spellCheck: false }) }), _jsxs("div", { className: "flex items-center gap-2", children: [error ? (_jsx("p", { className: "text-status-failed min-w-0 flex-1 truncate text-[11px]", title: error, children: error })) : sent ? (_jsx("p", { className: "text-status-completed flex-1 text-[11px]", children: "Signal sent" })) : (_jsxs("p", { className: "text-muted-foreground flex flex-1 items-center gap-1 text-[11px]", children: [_jsx(Kbd, { children: "\u2318" }), _jsx(Kbd, { children: "\u21B5" })] })), _jsx(Button, { type: "submit", size: "sm", disabled: pending || type.length === 0, children: "Send signal" })] })] }));
62
63
  }
63
64
  /** Run-scoped actions that live beside the run switcher. */
64
- export function RunControls({ runId }) {
65
+ export function RunControls({ runId, onForked, }) {
65
66
  const { client } = useDebugger();
66
- const { rootThreadId, status } = useRunSummary(useRunStore(runId));
67
- const { run, pending, error } = useRunAction();
68
- const finished = status === 'completed' || status === 'failed' || status === 'cancelled';
69
- return (_jsxs("div", { className: "flex items-center gap-0.5", children: [error ? (_jsx("span", { className: "text-status-failed mr-2 max-w-60 truncate text-[11px]", title: error, children: error })) : null, _jsxs(Button, { variant: "ghost", size: "sm", disabled: pending, onClick: () => run(() => client.wake(runId)), children: [_jsx(AlarmClock, {}), "Wake"] }), _jsxs(Button, { variant: "destructive", size: "sm", disabled: pending || finished, onClick: () => run(() => client.signal(runId, [
70
- { type: CANCEL_TYPE, payload: { threadId: rootThreadId ?? '' } },
71
- ])), children: [_jsx(CircleStop, {}), "Cancel"] }), _jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "data-[state=open]:bg-accent data-[state=open]:text-foreground", children: [_jsx(Radio, {}), "Signal"] }) }), _jsx(PopoverContent, { align: "end", className: "w-80", children: _jsx(SignalForm, { runId: runId, rootThreadId: rootThreadId }) })] })] }));
67
+ const { rootThreadId, finished, resumable } = useRunSummary(useRunStore(runId));
68
+ // A wake holds its request open until the run parks, so Cancel needs its own pending state.
69
+ const waking = useRunAction();
70
+ const cancelling = useRunAction();
71
+ const forking = useRunAction();
72
+ const error = forking.error ?? cancelling.error ?? waking.error;
73
+ const [notice, setNotice] = useState(null);
74
+ useEffect(() => {
75
+ if (!notice) {
76
+ return undefined;
77
+ }
78
+ const clear = Effect.runFork(Effect.sleep(NOTICE_MS).pipe(Effect.andThen(Effect.sync(() => setNotice(null)))));
79
+ return () => {
80
+ Effect.runFork(Fiber.interrupt(clear));
81
+ };
82
+ }, [notice]);
83
+ function wake() {
84
+ setNotice(null);
85
+ cancelling.setError(null);
86
+ waking.run(() => client.wake(runId), ({ appended }) => setNotice(appended === 0
87
+ ? 'Nothing pending'
88
+ : `Woke: ${appended} new event${appended === 1 ? '' : 's'}`));
89
+ }
90
+ return (_jsxs("div", { className: "flex items-center gap-0.5", children: [error ? (_jsx("span", { className: "text-status-failed mr-2 max-w-60 truncate text-[11px]", title: error, children: error })) : notice ? (_jsx("span", { role: "status", className: "text-muted-foreground mr-2 text-[11px]", children: notice })) : null, _jsxs(Button, { variant: "ghost", size: "sm", disabled: waking.pending, title: "Process pending work: due timers, retries, and effects stalled by a restart", onClick: wake, children: [_jsx(AlarmClock, {}), "Wake"] }), resumable ? (_jsxs(Button, { variant: "ghost", size: "sm", disabled: forking.pending, title: "Start a new run that picks up where this one stopped", onClick: () => {
91
+ setNotice(null);
92
+ waking.setError(null);
93
+ forking.run(() => client.fork(runId), (forked) => onForked(forked.runId));
94
+ }, children: [_jsx(GitFork, {}), "Continue"] })) : (_jsxs(Button, { variant: "destructive", size: "sm", disabled: cancelling.pending || finished, onClick: () => {
95
+ setNotice(null);
96
+ waking.setError(null);
97
+ cancelling.run(() => client.cancel(runId));
98
+ }, children: [_jsx(CircleStop, {}), "Cancel"] })), _jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "data-[state=open]:bg-accent data-[state=open]:text-foreground", children: [_jsx(Radio, {}), "Signal"] }) }), _jsx(PopoverContent, { align: "end", className: "w-80", children: _jsx(SignalForm, { runId: runId, rootThreadId: rootThreadId }) })] })] }));
72
99
  }
@@ -43,9 +43,7 @@ function indexRuns(runs) {
43
43
  .join(' ')
44
44
  .toLowerCase(),
45
45
  }));
46
- // ES2022 has no Array#toSorted. `indexed` is a fresh array.
47
- // oxlint-disable-next-line unicorn/no-array-sort
48
- return indexed.sort((left, right) => right.createdAt - left.createdAt);
46
+ return indexed.toSorted((left, right) => right.createdAt - left.createdAt);
49
47
  }
50
48
  function matches(entry, terms) {
51
49
  return terms.every((term) => entry.haystack.includes(term));
@@ -120,6 +120,11 @@ export declare const evaluateEffect: NoInfer<import("@swirls/looms/core").Effect
120
120
  readonly attempt: number;
121
121
  readonly deadlineAt: number | null;
122
122
  };
123
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
124
+ readonly type: "runtime.fork.elided";
125
+ readonly payload: {
126
+ readonly type: string;
127
+ };
123
128
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
124
129
  readonly type: "runtime.run.cancelled";
125
130
  readonly payload: {
@@ -131,6 +136,12 @@ export declare const evaluateEffect: NoInfer<import("@swirls/looms/core").Effect
131
136
  readonly output: Schema.Json;
132
137
  readonly error: string | null;
133
138
  };
139
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
140
+ readonly type: "runtime.run.forked";
141
+ readonly payload: {
142
+ readonly sourceRunId: string;
143
+ readonly sourceSeq: number;
144
+ };
134
145
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
135
146
  readonly type: "runtime.run.started";
136
147
  readonly payload: {
@@ -163,6 +174,7 @@ export declare const evaluateEffect: NoInfer<import("@swirls/looms/core").Effect
163
174
  readonly payload: {
164
175
  readonly threadId: string;
165
176
  readonly reason?: string | undefined;
177
+ readonly resumeSeq?: number | undefined;
166
178
  };
167
179
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
168
180
  readonly type: "runtime.thread.completed";
@@ -155,6 +155,11 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
155
155
  readonly attempt: number;
156
156
  readonly deadlineAt: number | null;
157
157
  };
158
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
159
+ readonly type: "runtime.fork.elided";
160
+ readonly payload: {
161
+ readonly type: string;
162
+ };
158
163
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
159
164
  readonly type: "runtime.run.cancelled";
160
165
  readonly payload: {
@@ -166,6 +171,12 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
166
171
  readonly output: import("effect/Schema").Json;
167
172
  readonly error: string | null;
168
173
  };
174
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
175
+ readonly type: "runtime.run.forked";
176
+ readonly payload: {
177
+ readonly sourceRunId: string;
178
+ readonly sourceSeq: number;
179
+ };
169
180
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
170
181
  readonly type: "runtime.run.started";
171
182
  readonly payload: {
@@ -198,6 +209,7 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
198
209
  readonly payload: {
199
210
  readonly threadId: string;
200
211
  readonly reason?: string | undefined;
212
+ readonly resumeSeq?: number | undefined;
201
213
  };
202
214
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
203
215
  readonly type: "runtime.thread.completed";
@@ -438,6 +450,11 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
438
450
  readonly attempt: number;
439
451
  readonly deadlineAt: number | null;
440
452
  };
453
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
454
+ readonly type: "runtime.fork.elided";
455
+ readonly payload: {
456
+ readonly type: string;
457
+ };
441
458
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
442
459
  readonly type: "runtime.run.cancelled";
443
460
  readonly payload: {
@@ -449,6 +466,12 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
449
466
  readonly output: import("effect/Schema").Json;
450
467
  readonly error: string | null;
451
468
  };
469
+ }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
470
+ readonly type: "runtime.run.forked";
471
+ readonly payload: {
472
+ readonly sourceRunId: string;
473
+ readonly sourceSeq: number;
474
+ };
452
475
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
453
476
  readonly type: "runtime.run.started";
454
477
  readonly payload: {
@@ -481,6 +504,7 @@ export declare function evaluator(options?: EvaluatorModuleOptions): import("@sw
481
504
  readonly payload: {
482
505
  readonly threadId: string;
483
506
  readonly reason?: string | undefined;
507
+ readonly resumeSeq?: number | undefined;
484
508
  };
485
509
  }) | (Omit<import("@swirls/looms/core").EventInput, "payload" | "type"> & {
486
510
  readonly type: "runtime.thread.completed";
@@ -1,6 +1,8 @@
1
- import type { EventEnvelope } from '@swirls/looms/core';
1
+ import { type EventEnvelope } from '@swirls/looms/core';
2
2
  import type { Projector } from './projector';
3
+ /** A run (`actorId === runId`) or one of its threads. Thread ids are unique within a run. */
3
4
  export interface ActorIndexRow {
5
+ runId: string;
4
6
  actorId: string;
5
7
  kind: string | null;
6
8
  status: string | null;
@@ -9,14 +11,15 @@ export interface ActorIndexRow {
9
11
  updatedAt: number;
10
12
  }
11
13
  export interface ReviewIndexRow {
14
+ runId: string;
12
15
  reviewId: string;
13
- actorId: string;
14
16
  status: string;
15
17
  title: string;
16
18
  updatedAt: number;
17
19
  }
18
20
  export type IndexOp = {
19
21
  type: 'upsertActor';
22
+ runId: string;
20
23
  actorId: string;
21
24
  kind: string | null;
22
25
  status: string | null;
@@ -25,32 +28,35 @@ export type IndexOp = {
25
28
  updatedAt: number;
26
29
  } | {
27
30
  type: 'setActorStatus';
31
+ runId: string;
28
32
  actorId: string;
29
33
  status: string;
30
34
  updatedAt: number;
31
35
  } | {
32
36
  type: 'upsertReview';
37
+ runId: string;
33
38
  reviewId: string;
34
- actorId: string;
35
39
  status: string;
36
40
  title: string;
37
41
  updatedAt: number;
38
42
  } | {
39
43
  type: 'setReviewStatus';
44
+ runId: string;
40
45
  reviewId: string;
41
46
  status: string;
42
47
  updatedAt: number;
43
48
  };
44
49
  export interface IndexBackend {
45
50
  applyOps: (ops: ReadonlyArray<IndexOp>) => Promise<void>;
46
- getActor: (actorId: string) => Promise<ActorIndexRow | null>;
47
- listReviews: (actorId?: string) => Promise<ReviewIndexRow[]>;
51
+ getActor: (runId: string, actorId: string) => Promise<ActorIndexRow | null>;
52
+ listReviews: (runId?: string) => Promise<ReviewIndexRow[]>;
48
53
  init?: () => Promise<void>;
49
54
  dispose?: () => Promise<void>;
50
55
  }
51
56
  export interface IndexProjector extends Projector {
52
- getActor: (actorId: string) => Promise<ActorIndexRow | null>;
53
- listReviews: (actorId?: string) => Promise<ReviewIndexRow[]>;
57
+ /** The run's row, or one of its threads when `actorId` is given. */
58
+ getActor: (runId: string, actorId?: string) => Promise<ActorIndexRow | null>;
59
+ listReviews: (runId?: string) => Promise<ReviewIndexRow[]>;
54
60
  }
55
61
  export declare function indexOpsFor(event: EventEnvelope): IndexOp[];
56
62
  export declare function createIndexProjector(name: string, backend: IndexBackend, version?: string): IndexProjector;
@@ -1,4 +1,5 @@
1
1
  import { Predicate } from 'effect';
2
+ import { completedRunStatus } from '@swirls/looms/core';
2
3
  function payloadObject(event) {
3
4
  if (!Predicate.isObject(event.payload)) {
4
5
  return {};
@@ -11,12 +12,15 @@ function readString(obj, key) {
11
12
  }
12
13
  export function indexOpsFor(event) {
13
14
  const payload = payloadObject(event);
15
+ const runId = event.runId;
16
+ const actorId = readString(payload, 'threadId') ?? event.threadId ?? runId;
14
17
  switch (event.type) {
15
18
  case 'runtime.run.started':
16
19
  return [
17
20
  {
18
21
  type: 'upsertActor',
19
- actorId: event.runId,
22
+ runId,
23
+ actorId: runId,
20
24
  kind: readString(payload, 'kind') ?? null,
21
25
  status: 'running',
22
26
  definitionName: readString(payload, 'definitionName') ?? null,
@@ -28,7 +32,8 @@ export function indexOpsFor(event) {
28
32
  return [
29
33
  {
30
34
  type: 'upsertActor',
31
- actorId: readString(payload, 'threadId') ?? event.threadId ?? event.runId,
35
+ runId,
36
+ actorId,
32
37
  kind: readString(payload, 'kind') ?? null,
33
38
  status: 'running',
34
39
  definitionName: readString(payload, 'definitionName') ?? null,
@@ -37,39 +42,28 @@ export function indexOpsFor(event) {
37
42
  },
38
43
  ];
39
44
  case 'runtime.run.completed':
40
- case 'runtime.thread.completed':
41
45
  return [
42
46
  {
43
47
  type: 'setActorStatus',
44
- actorId: readString(payload, 'threadId') ?? event.threadId ?? event.runId,
45
- status: 'completed',
48
+ runId,
49
+ actorId: runId,
50
+ status: completedRunStatus(event.payload),
46
51
  updatedAt: event.ts,
47
52
  },
48
53
  ];
54
+ case 'runtime.thread.completed':
55
+ return [{ type: 'setActorStatus', runId, actorId, status: 'completed', updatedAt: event.ts }];
49
56
  case 'runtime.thread.failed':
50
- return [
51
- {
52
- type: 'setActorStatus',
53
- actorId: readString(payload, 'threadId') ?? event.threadId ?? event.runId,
54
- status: 'failed',
55
- updatedAt: event.ts,
56
- },
57
- ];
57
+ return [{ type: 'setActorStatus', runId, actorId, status: 'failed', updatedAt: event.ts }];
58
+ case 'runtime.run.cancelled':
58
59
  case 'runtime.thread.cancelled':
59
- return [
60
- {
61
- type: 'setActorStatus',
62
- actorId: readString(payload, 'threadId') ?? event.threadId ?? event.runId,
63
- status: 'cancelled',
64
- updatedAt: event.ts,
65
- },
66
- ];
60
+ return [{ type: 'setActorStatus', runId, actorId, status: 'cancelled', updatedAt: event.ts }];
67
61
  case 'approval.requested':
68
62
  return [
69
63
  {
70
64
  type: 'upsertReview',
65
+ runId,
71
66
  reviewId: readString(payload, 'approvalId') ?? 'unknown',
72
- actorId: event.runId,
73
67
  status: 'pending',
74
68
  title: readString(payload, 'title') ?? 'Approval',
75
69
  updatedAt: event.ts,
@@ -79,8 +73,8 @@ export function indexOpsFor(event) {
79
73
  return [
80
74
  {
81
75
  type: 'upsertReview',
76
+ runId,
82
77
  reviewId: readString(payload, 'approvalId') ?? 'unknown',
83
- actorId: event.runId,
84
78
  status: payload.outcome === 'reject' ? 'rejected' : 'approved',
85
79
  title: 'Approval',
86
80
  updatedAt: event.ts,
@@ -90,6 +84,7 @@ export function indexOpsFor(event) {
90
84
  return [
91
85
  {
92
86
  type: 'setReviewStatus',
87
+ runId,
93
88
  reviewId: readString(payload, 'approvalId') ?? 'unknown',
94
89
  status: 'timed_out',
95
90
  updatedAt: event.ts,
@@ -99,7 +94,7 @@ export function indexOpsFor(event) {
99
94
  return [];
100
95
  }
101
96
  }
102
- export function createIndexProjector(name, backend, version = '1') {
97
+ export function createIndexProjector(name, backend, version = '2') {
103
98
  return {
104
99
  name,
105
100
  version,
@@ -109,7 +104,7 @@ export function createIndexProjector(name, backend, version = '1') {
109
104
  const ops = events.flatMap(indexOpsFor);
110
105
  return ops.length === 0 ? Promise.resolve() : backend.applyOps(ops);
111
106
  },
112
- getActor: (actorId) => backend.getActor(actorId),
113
- listReviews: (actorId) => backend.listReviews(actorId),
107
+ getActor: (runId, actorId) => backend.getActor(runId, actorId ?? runId),
108
+ listReviews: (runId) => backend.listReviews(runId),
114
109
  };
115
110
  }
@@ -1,9 +1,13 @@
1
1
  import { createIndexProjector, } from './index-model.js';
2
2
  import { runProjectorSync } from './projector.js';
3
+ function rowKey(runId, id) {
4
+ return `${runId}\0${id}`;
5
+ }
3
6
  function applyOp(actors, reviews, op) {
4
7
  switch (op.type) {
5
8
  case 'upsertActor':
6
- actors.set(op.actorId, {
9
+ actors.set(rowKey(op.runId, op.actorId), {
10
+ runId: op.runId,
7
11
  actorId: op.actorId,
8
12
  kind: op.kind,
9
13
  status: op.status,
@@ -13,8 +17,10 @@ function applyOp(actors, reviews, op) {
13
17
  });
14
18
  return;
15
19
  case 'setActorStatus': {
16
- const existing = actors.get(op.actorId);
17
- actors.set(op.actorId, {
20
+ const key = rowKey(op.runId, op.actorId);
21
+ const existing = actors.get(key);
22
+ actors.set(key, {
23
+ runId: op.runId,
18
24
  actorId: op.actorId,
19
25
  kind: existing?.kind ?? null,
20
26
  status: op.status,
@@ -25,10 +31,11 @@ function applyOp(actors, reviews, op) {
25
31
  return;
26
32
  }
27
33
  case 'upsertReview': {
28
- const existing = reviews.get(op.reviewId);
29
- reviews.set(op.reviewId, {
34
+ const key = rowKey(op.runId, op.reviewId);
35
+ const existing = reviews.get(key);
36
+ reviews.set(key, {
37
+ runId: op.runId,
30
38
  reviewId: op.reviewId,
31
- actorId: op.actorId,
32
39
  status: op.status,
33
40
  title: existing?.title ?? op.title,
34
41
  updatedAt: op.updatedAt,
@@ -36,9 +43,10 @@ function applyOp(actors, reviews, op) {
36
43
  return;
37
44
  }
38
45
  case 'setReviewStatus': {
39
- const existing = reviews.get(op.reviewId);
46
+ const key = rowKey(op.runId, op.reviewId);
47
+ const existing = reviews.get(key);
40
48
  if (existing) {
41
- reviews.set(op.reviewId, { ...existing, status: op.status, updatedAt: op.updatedAt });
49
+ reviews.set(key, { ...existing, status: op.status, updatedAt: op.updatedAt });
42
50
  }
43
51
  return;
44
52
  }
@@ -57,10 +65,10 @@ export function memory() {
57
65
  applyOp(actors, reviews, op);
58
66
  }
59
67
  }),
60
- getActor: (actorId) => runProjectorSync('memory', () => actors.get(actorId) ?? null),
61
- listReviews: (actorId) => runProjectorSync('memory', () => {
68
+ getActor: (runId, actorId) => runProjectorSync('memory', () => actors.get(rowKey(runId, actorId)) ?? null),
69
+ listReviews: (runId) => runProjectorSync('memory', () => {
62
70
  const rows = [...reviews.values()];
63
- return actorId ? rows.filter((row) => row.actorId === actorId) : rows;
71
+ return runId ? rows.filter((row) => row.runId === runId) : rows;
64
72
  }),
65
73
  dispose: () => runProjectorSync('memory', () => {
66
74
  actors.clear();