@swirls/looms 0.2.0 → 0.4.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 (113) hide show
  1. package/CHANGELOG.md +31 -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 -38
  9. package/dist/agent/debugger/streaming-text.d.ts +6 -0
  10. package/dist/agent/debugger/streaming-text.js +33 -0
  11. package/dist/agent/debugger/summarize.js +2 -0
  12. package/dist/agent/effects.d.ts +30 -0
  13. package/dist/agent/events.d.ts +2 -0
  14. package/dist/agent/events.js +2 -0
  15. package/dist/agent/llm.js +1 -8
  16. package/dist/agent/module.d.ts +230 -160
  17. package/dist/agent/module.js +11 -7
  18. package/dist/agent/projections.d.ts +5 -0
  19. package/dist/agent/projections.js +43 -2
  20. package/dist/agent/scope.d.ts +2 -0
  21. package/dist/agent/signals.d.ts +5 -0
  22. package/dist/agent/signals.js +7 -0
  23. package/dist/agent/threads.js +35 -21
  24. package/dist/agent/transcript.d.ts +7 -0
  25. package/dist/agent/transcript.js +19 -0
  26. package/dist/agent/types.d.ts +10 -0
  27. package/dist/agent/types.js +4 -0
  28. package/dist/approval/debugger/approvals-view.js +5 -3
  29. package/dist/approval/effects.d.ts +12 -0
  30. package/dist/approval/module.d.ts +24 -0
  31. package/dist/client/client.d.ts +292 -0
  32. package/dist/client/client.js +17 -1
  33. package/dist/cloudflare/router.d.ts +1 -1
  34. package/dist/cloudflare/router.js +6 -8
  35. package/dist/core/attempts.d.ts +8 -0
  36. package/dist/core/attempts.js +27 -0
  37. package/dist/core/effects.d.ts +7 -0
  38. package/dist/core/effects.js +1 -0
  39. package/dist/core/fold.js +12 -6
  40. package/dist/core/fork.d.ts +35 -0
  41. package/dist/core/fork.js +129 -0
  42. package/dist/core/index.d.ts +3 -0
  43. package/dist/core/index.js +2 -0
  44. package/dist/core/module-scope.d.ts +1 -0
  45. package/dist/core/module.d.ts +2 -2
  46. package/dist/core/projection.d.ts +5 -4
  47. package/dist/core/projection.js +1 -0
  48. package/dist/core/protocol.d.ts +10 -1
  49. package/dist/core/protocol.js +11 -0
  50. package/dist/{react → core}/register.d.ts +9 -8
  51. package/dist/core/replay.d.ts +8 -0
  52. package/dist/core/snapshots.js +3 -17
  53. package/dist/core/state.d.ts +14 -0
  54. package/dist/core/state.js +25 -2
  55. package/dist/core/store.d.ts +1 -0
  56. package/dist/debugger/app/assets/index-CgDzWJg7.css +2 -0
  57. package/dist/debugger/app/assets/index-D--9esm9.js +22 -0
  58. package/dist/debugger/app/assets/routes-Dakrdusf.js +47 -0
  59. package/dist/debugger/app/index.html +2 -2
  60. package/dist/debugger/components/composer.d.ts +5 -2
  61. package/dist/debugger/components/composer.js +5 -4
  62. package/dist/debugger/components/run-tree.js +2 -5
  63. package/dist/debugger/events/catalog.js +1 -3
  64. package/dist/debugger/events/protocol-summarize.js +7 -0
  65. package/dist/debugger/lib/event-counts.d.ts +2 -2
  66. package/dist/debugger/lib/event-counts.js +5 -9
  67. package/dist/debugger/shell/debugger-shell.js +1 -1
  68. package/dist/debugger/shell/run-controls.d.ts +2 -1
  69. package/dist/debugger/shell/run-controls.js +40 -13
  70. package/dist/debugger/shell/run-search.js +1 -3
  71. package/dist/evaluator/effects.d.ts +45 -33
  72. package/dist/evaluator/module.d.ts +90 -66
  73. package/dist/projectors/index-model.d.ts +13 -7
  74. package/dist/projectors/index-model.js +21 -26
  75. package/dist/projectors/memory.js +19 -11
  76. package/dist/projectors/postgres.js +20 -19
  77. package/dist/projectors/schema.js +20 -18
  78. package/dist/projectors/sqlite.js +32 -27
  79. package/dist/react/derived.d.ts +26 -8
  80. package/dist/react/derived.js +23 -26
  81. package/dist/react/index.d.ts +3 -3
  82. package/dist/react/index.js +2 -2
  83. package/dist/react/materialize.js +16 -11
  84. package/dist/react/react.d.ts +12 -9
  85. package/dist/react/react.js +9 -6
  86. package/dist/react/store.d.ts +4 -5
  87. package/dist/react/tables.d.ts +3 -2
  88. package/dist/runtime/cancellation.js +14 -27
  89. package/dist/runtime/errors.d.ts +39 -0
  90. package/dist/runtime/errors.js +32 -0
  91. package/dist/runtime/helpers.d.ts +2 -0
  92. package/dist/runtime/helpers.js +23 -1
  93. package/dist/runtime/index.d.ts +1 -1
  94. package/dist/runtime/index.js +1 -1
  95. package/dist/runtime/looms.d.ts +4 -0
  96. package/dist/runtime/looms.js +1 -0
  97. package/dist/runtime/run-start.js +1 -3
  98. package/dist/runtime/runtime.d.ts +1 -1
  99. package/dist/runtime/runtime.js +45 -18
  100. package/dist/runtime/server/http.d.ts +2 -0
  101. package/dist/runtime/server/http.js +17 -2
  102. package/dist/runtime/server/routes/runs.js +35 -2
  103. package/dist/runtime/types.d.ts +8 -0
  104. package/dist/runtime/wake-engine.js +5 -2
  105. package/dist/s2/snapshot-store.js +2 -6
  106. package/dist/workflow/effects.d.ts +24 -0
  107. package/dist/workflow/effects.js +1 -0
  108. package/dist/workflow/module.d.ts +48 -0
  109. package/package.json +2 -2
  110. package/dist/debugger/app/assets/index-B3Ah8WAE.css +0 -2
  111. package/dist/debugger/app/assets/index-Qb371t_O.js +0 -22
  112. package/dist/debugger/app/assets/routes-CjtEHgPQ.js +0 -47
  113. /package/dist/{react → core}/register.js +0 -0
@@ -1,4 +1,4 @@
1
- import { isJsonObject, isJsonString } from '@swirls/looms/core';
1
+ import { completedRunStatus, isJsonObject, isJsonString, } from '@swirls/looms/core';
2
2
  import { emptyTables } from './tables.js';
3
3
  function payloadObject(event) {
4
4
  return isJsonObject(event.payload) ? event.payload : {};
@@ -7,6 +7,16 @@ function readString(obj, key) {
7
7
  const value = obj[key];
8
8
  return isJsonString(value) ? value : undefined;
9
9
  }
10
+ function setRunStatus(tables, runId, status) {
11
+ const existing = tables.runs.get(runId);
12
+ tables.runs.set(runId, {
13
+ runId,
14
+ status,
15
+ rootThreadId: existing?.rootThreadId ?? null,
16
+ kind: existing?.kind ?? null,
17
+ definitionName: existing?.definitionName ?? null,
18
+ });
19
+ }
10
20
  export function materializeEvents(events, from = emptyTables()) {
11
21
  const tables = {
12
22
  runs: from.runs,
@@ -28,17 +38,12 @@ export function materializeEvents(events, from = emptyTables()) {
28
38
  tables.runs.set(event.runId, next);
29
39
  break;
30
40
  }
31
- case 'runtime.run.completed': {
32
- const existing = tables.runs.get(event.runId);
33
- tables.runs.set(event.runId, {
34
- runId: event.runId,
35
- status: readString(payload, 'error') ? 'failed' : 'completed',
36
- rootThreadId: existing?.rootThreadId ?? null,
37
- kind: existing?.kind ?? null,
38
- definitionName: existing?.definitionName ?? null,
39
- });
41
+ case 'runtime.run.completed':
42
+ setRunStatus(tables, event.runId, completedRunStatus(payload));
43
+ break;
44
+ case 'runtime.run.cancelled':
45
+ setRunStatus(tables, event.runId, 'cancelled');
40
46
  break;
41
- }
42
47
  case 'runtime.thread.started': {
43
48
  const threadId = readString(payload, 'threadId') ?? event.threadId;
44
49
  const kind = readString(payload, 'kind');
@@ -1,7 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
- import { type ProjectionDefinition, type ThreadTree } from '@swirls/looms/core';
3
- import { createFold, foldEvents, type EventFoldDefinition } from './derived';
4
- import type { AnyEventEnvelope, RegisteredEvent } from './register';
2
+ import { type AnyEventEnvelope, type ProjectionDefinition, type RegisteredEvent, type RunStatus, type ThreadTree } from '@swirls/looms/core';
3
+ import { defineLiveFold, foldLive, type LiveFoldDefinition } from './derived';
5
4
  import { createLoomsStore, type CoalesceOption, type LoomsClientStore, type LoomsConnectionStatus } from './store';
6
5
  export interface LoomsProviderProps {
7
6
  children: ReactNode;
@@ -30,7 +29,11 @@ export declare function useProjection<S, TEvent extends AnyEventEnvelope = Regis
30
29
  /** Subscribe to arbitrary store derivations with an equality check. */
31
30
  export declare function useRunSelector<Selected, TEvent extends AnyEventEnvelope = RegisteredEvent>(store: LoomsClientStore<TEvent>, selector: (store: LoomsClientStore<TEvent>) => Selected, isEqual?: (a: Selected, b: Selected) => boolean): Selected;
32
31
  export interface RunSummary {
33
- readonly status: string;
32
+ readonly status: RunStatus;
33
+ /** True once the run is completed, failed, or cancelled and accepts no new signals. */
34
+ readonly finished: boolean;
35
+ /** True when the run failed or was cancelled, so a fork can continue it from where it stopped. */
36
+ readonly resumable: boolean;
34
37
  readonly kind: string | undefined;
35
38
  readonly definitionName: string | undefined;
36
39
  readonly rootThreadId: string | undefined;
@@ -51,10 +54,10 @@ export declare function useEventCounts<TEvent extends AnyEventEnvelope = Registe
51
54
  export declare function useEventBySeq<TEvent extends AnyEventEnvelope = RegisteredEvent>(store: LoomsClientStore<TEvent>, seq: number | undefined): TEvent | undefined;
52
55
  /** Subscribe to the latest event of a given type. */
53
56
  export declare function useLatestEvent<TEvent extends AnyEventEnvelope = RegisteredEvent>(store: LoomsClientStore<TEvent>, type: string): TEvent | undefined;
54
- /** Subscribe to a custom incremental fold. */
55
- export declare function useEventFold<S, TEvent extends AnyEventEnvelope = RegisteredEvent>(store: LoomsClientStore<TEvent>, fold: EventFoldDefinition<S, TEvent>): S;
57
+ /** Subscribe to a live fold, which includes ephemeral events. */
58
+ export declare function useLiveFold<S, TEvent extends AnyEventEnvelope = RegisteredEvent>(store: LoomsClientStore<TEvent>, fold: LiveFoldDefinition<S, TEvent>): S;
56
59
  export { createLoomsStore };
57
- export { createFold, foldEvents };
60
+ export { defineLiveFold, foldLive };
58
61
  export type { LoomsClientStore, LoomsClientStoreOptions, LoomsConnectionStatus, LoomsStoreQuery, CoalesceOption, StoreListener, } from './store';
59
- export type { EventFoldDefinition } from './derived';
60
- export type { LoomsRegister, RegisteredEvent, AnyEventEnvelope } from './register';
62
+ export type { LiveFoldDefinition } from './derived';
63
+ export type { LoomsRegister, RegisteredEvent, AnyEventEnvelope } from '@swirls/looms/core';
@@ -1,7 +1,7 @@
1
1
  import { Effect, Fiber } from 'effect';
2
2
  import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
3
- import { threadTree, toThreadTree } from '@swirls/looms/core';
4
- import { createFold, foldEvents } from './derived.js';
3
+ import { isTerminalStatus, threadTree, toThreadTree, } from '@swirls/looms/core';
4
+ import { defineLiveFold, foldLive } from './derived.js';
5
5
  import { createLoomsStore, } from './store.js';
6
6
  const LoomsContext = createContext({ endpoint: '' });
7
7
  const GRACE_MS = 5_000;
@@ -125,8 +125,11 @@ function shallowEqualSummary(a, b) {
125
125
  export function useRunSummary(store) {
126
126
  const selector = useCallback((s) => {
127
127
  const run = s.getState().runs.get(s.storeId);
128
+ const status = run?.status ?? 'running';
128
129
  return {
129
- status: run?.status ?? 'running',
130
+ status,
131
+ finished: isTerminalStatus(status),
132
+ resumable: status === 'failed' || status === 'cancelled',
130
133
  kind: run?.kind ?? undefined,
131
134
  definitionName: run?.definitionName ?? undefined,
132
135
  rootThreadId: run?.rootThreadId ?? undefined,
@@ -162,10 +165,10 @@ export function useLatestEvent(store, type) {
162
165
  const selector = useCallback((s) => s.query.latest(type), [type]);
163
166
  return useRunSelector(store, selector);
164
167
  }
165
- /** Subscribe to a custom incremental fold. */
166
- export function useEventFold(store, fold) {
168
+ /** Subscribe to a live fold, which includes ephemeral events. */
169
+ export function useLiveFold(store, fold) {
167
170
  const getSnapshot = useCallback(() => store.fold(fold), [store, fold]);
168
171
  return useStoreSnapshot(store, getSnapshot);
169
172
  }
170
173
  export { createLoomsStore };
171
- export { createFold, foldEvents };
174
+ export { defineLiveFold, foldLive };
@@ -1,7 +1,6 @@
1
- import { type EventInput, type ProjectionDefinition } from '@swirls/looms/core';
2
- import { type EventFoldDefinition } from './derived';
1
+ import { type AnyEventEnvelope, type EventInput, type ProjectionDefinition, type RegisteredEvent } from '@swirls/looms/core';
2
+ import { type LiveFoldDefinition } from './derived';
3
3
  import { type CoalesceOption } from './notify';
4
- import type { AnyEventEnvelope, RegisteredEvent } from './register';
5
4
  import { type MaterializedTables } from './tables';
6
5
  export type { CoalesceOption } from './notify';
7
6
  /** Fired after the store version bumps. Re-read via `events()` / `getState()` / `query`. */
@@ -30,7 +29,7 @@ export interface LoomsStoreQuery<TEvent extends AnyEventEnvelope = RegisteredEve
30
29
  bySeq: (seq: number) => TEvent | undefined;
31
30
  startedAt: () => number | undefined;
32
31
  latest: (type: string) => TEvent | undefined;
33
- fold: <S>(def: EventFoldDefinition<S, TEvent>) => S;
32
+ fold: <S>(def: LiveFoldDefinition<S, TEvent>) => S;
34
33
  }
35
34
  export interface LoomsClientStore<TEvent extends AnyEventEnvelope = RegisteredEvent> {
36
35
  storeId: string;
@@ -43,7 +42,7 @@ export interface LoomsClientStore<TEvent extends AnyEventEnvelope = RegisteredEv
43
42
  query: LoomsStoreQuery<TEvent>;
44
43
  subscribe: (listener: StoreListener) => () => void;
45
44
  project<S>(definition: ProjectionDefinition<S>): S;
46
- fold<S>(definition: EventFoldDefinition<S, TEvent>): S;
45
+ fold<S>(definition: LiveFoldDefinition<S, TEvent>): S;
47
46
  flush: () => void;
48
47
  commit: (event: EventInput | readonly EventInput[]) => Promise<void>;
49
48
  sync: () => Promise<void>;
@@ -1,6 +1,7 @@
1
+ import type { RunStatus, ThreadStatus } from '@swirls/looms/core';
1
2
  export interface RunRow {
2
3
  runId: string;
3
- status: string;
4
+ status: RunStatus;
4
5
  rootThreadId: string | null;
5
6
  kind: string | null;
6
7
  definitionName: string | null;
@@ -11,7 +12,7 @@ export interface ThreadRow {
11
12
  kind: string;
12
13
  definitionName: string;
13
14
  parentThreadId: string | null;
14
- status: string;
15
+ status: ThreadStatus;
15
16
  }
16
17
  /** Materialized run/thread rows. Event log lives only in `store.events()` / EventIndex. */
17
18
  export interface MaterializedTables {
@@ -1,29 +1,15 @@
1
1
  import { Clock, Effect } from 'effect';
2
- import { createEvent, EventStoreTag, foldRun, } from '@swirls/looms/core';
2
+ import { createEvent, descendantThreadIds, EventStoreTag, foldRun, } from '@swirls/looms/core';
3
3
  import { RuntimeExecutionError } from './errors.js';
4
4
  import { waitSatisfiedEvents, workerTask } from './helpers.js';
5
5
  import { notifyObserver } from './observer.js';
6
6
  import { isWorkerEffect } from './worker-effect-dispatch.js';
7
- function descendantThreadIds(state, targetThreadId) {
8
- return Object.values(state.threads)
9
- .filter((record) => {
10
- let parentThreadId = record.parentThreadId;
11
- while (parentThreadId) {
12
- if (parentThreadId === targetThreadId) {
13
- return true;
14
- }
15
- parentThreadId = state.threads[parentThreadId]?.parentThreadId ?? null;
16
- }
17
- return false;
18
- })
19
- .map((record) => record.threadId);
20
- }
21
7
  /**
22
8
  * Cancel a thread and its descendants. Worker attempts in flight are asked to stop and must
23
9
  * acknowledge; attempts waiting to retry were never handed out and are cancelled outright.
24
10
  */
25
11
  function planCancellation(input) {
26
- const { runId, state, registry, now } = input;
12
+ const { runId, seq, state, registry, now } = input;
27
13
  const targetThreadId = input.threadId ?? state.rootThreadId ?? '';
28
14
  const idempotencyKey = `cancel:${targetThreadId}`;
29
15
  if (state.processedIdempotencyKeys.includes(idempotencyKey)) {
@@ -65,18 +51,18 @@ function planCancellation(input) {
65
51
  idempotencyKey,
66
52
  }));
67
53
  }
68
- const threadEvents = [
69
- ...descendants.map((_, index) => descendants[descendants.length - 1 - index]),
70
- targetThreadId,
71
- ]
54
+ const threadEvents = [...descendants.toReversed(), targetThreadId]
72
55
  .filter((threadId) => threadId.length > 0)
73
- .map((threadId) => createEvent(runId, {
74
- type: 'runtime.thread.cancelled',
75
- payload: { threadId, reason: 'cancelled' },
76
- threadId,
77
- origin: { type: 'external' },
78
- idempotencyKey,
79
- }));
56
+ .map((threadId) => {
57
+ const payload = { threadId, reason: 'cancelled' };
58
+ return createEvent(runId, {
59
+ type: 'runtime.thread.cancelled',
60
+ payload: threadId === targetThreadId ? { ...payload, resumeSeq: seq } : payload,
61
+ threadId,
62
+ origin: { type: 'external' },
63
+ idempotencyKey,
64
+ });
65
+ });
80
66
  const batch = [...effectEvents, ...threadEvents];
81
67
  const folded = foldRun(batch, registry, { runId, initial: state });
82
68
  const satisfied = waitSatisfiedEvents(folded, batch).map((event) => createEvent(runId, event));
@@ -91,6 +77,7 @@ export function createCancellation(options) {
91
77
  return yield* repository.commit(store, runId, (current) => {
92
78
  const plan = planCancellation({
93
79
  runId,
80
+ seq: current.seq,
94
81
  state: current.state,
95
82
  threadId,
96
83
  registry,
@@ -19,6 +19,45 @@ export declare class StartRunConflictError extends StartRunConflictError_base<{
19
19
  }> {
20
20
  constructor(runId: string, requested: RunStartIdentity, existing: DurableRunStartIdentity, mismatches: readonly (keyof RunStartIdentity)[]);
21
21
  }
22
+ declare const RunTerminalError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
23
+ readonly _tag: "RunTerminalError";
24
+ } & Readonly<A>;
25
+ export declare class RunTerminalError extends RunTerminalError_base<{
26
+ readonly runId: string;
27
+ readonly status: string;
28
+ readonly message: string;
29
+ }> {
30
+ constructor(runId: string, status: string);
31
+ }
32
+ declare const RunNotFoundError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
33
+ readonly _tag: "RunNotFoundError";
34
+ } & Readonly<A>;
35
+ export declare class RunNotFoundError extends RunNotFoundError_base<{
36
+ readonly runId: string;
37
+ readonly message: string;
38
+ }> {
39
+ constructor(runId: string);
40
+ }
41
+ declare const RunNotFinishedError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
42
+ readonly _tag: "RunNotFinishedError";
43
+ } & Readonly<A>;
44
+ export declare class RunNotFinishedError extends RunNotFinishedError_base<{
45
+ readonly runId: string;
46
+ readonly status: string;
47
+ readonly message: string;
48
+ }> {
49
+ constructor(runId: string, status: string);
50
+ }
51
+ declare const ForkConflictError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
52
+ readonly _tag: "ForkConflictError";
53
+ } & Readonly<A>;
54
+ export declare class ForkConflictError extends ForkConflictError_base<{
55
+ readonly runId: string;
56
+ readonly sourceRunId: string;
57
+ readonly message: string;
58
+ }> {
59
+ constructor(runId: string, sourceRunId: string);
60
+ }
22
61
  declare const DuplicateEffectDispatchError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
23
62
  readonly _tag: "DuplicateEffectDispatchError";
24
63
  } & Readonly<A>;
@@ -11,6 +11,38 @@ export class StartRunConflictError extends Data.TaggedError('StartRunConflictErr
11
11
  this.name = 'StartRunConflictError';
12
12
  }
13
13
  }
14
+ export class RunTerminalError extends Data.TaggedError('RunTerminalError') {
15
+ constructor(runId, status) {
16
+ super({ runId, status, message: `Run "${runId}" is ${status} and accepts no new events` });
17
+ this.name = 'RunTerminalError';
18
+ }
19
+ }
20
+ export class RunNotFoundError extends Data.TaggedError('RunNotFoundError') {
21
+ constructor(runId) {
22
+ super({ runId, message: `Run "${runId}" does not exist` });
23
+ this.name = 'RunNotFoundError';
24
+ }
25
+ }
26
+ export class RunNotFinishedError extends Data.TaggedError('RunNotFinishedError') {
27
+ constructor(runId, status) {
28
+ super({
29
+ runId,
30
+ status,
31
+ message: `Run "${runId}" is ${status}; only finished runs can be forked`,
32
+ });
33
+ this.name = 'RunNotFinishedError';
34
+ }
35
+ }
36
+ export class ForkConflictError extends Data.TaggedError('ForkConflictError') {
37
+ constructor(runId, sourceRunId) {
38
+ super({
39
+ runId,
40
+ sourceRunId,
41
+ message: `Run "${runId}" already exists and is not a fork of "${sourceRunId}"`,
42
+ });
43
+ this.name = 'ForkConflictError';
44
+ }
45
+ }
14
46
  export class DuplicateEffectDispatchError extends Data.TaggedError('DuplicateEffectDispatchError') {
15
47
  constructor(runId, effectId, threadId) {
16
48
  super({
@@ -43,6 +43,8 @@ export declare function threadStartedEvents(definitions: ReadonlyMap<string, Reg
43
43
  parentThreadId: string | null;
44
44
  }): Effect.Effect<ReadonlyArray<EventInput>, UnknownDefinitionError>;
45
45
  export declare function synthesizedThreadFailed(before: RunState, after: RunState, written: readonly EventEnvelope[]): EventInput[];
46
+ /** Cancel whatever is still live below each thread `written` cancels, deepest first. */
47
+ export declare function cascadedCancellations(state: RunState, written: readonly EventEnvelope[]): EventInput[];
46
48
  export declare function waitSatisfiedEvents(state: RunState, events: readonly EventEnvelope[]): EventInput[];
47
49
  export declare function createEffectFailedEvent(runId: string, item: {
48
50
  effectId: string;
@@ -1,5 +1,5 @@
1
1
  import { Data, Effect, Layer, Predicate } from 'effect';
2
- import { asJson, createEvent, definitionKey, makeMemorySnapshotStore, matchingWaits, snapshotStoreOf, validateEventInput, validateEventsEffect, validateInputEffect, withSnapshotStore, InvalidEventError, } from '@swirls/looms/core';
2
+ import { asJson, createEvent, definitionKey, descendantThreadIds, isTerminalStatus, makeMemorySnapshotStore, matchingWaits, snapshotStoreOf, validateEventInput, validateEventsEffect, validateInputEffect, withSnapshotStore, InvalidEventError, } from '@swirls/looms/core';
3
3
  export function workerTask(runId, item, attempt) {
4
4
  return {
5
5
  runId,
@@ -180,6 +180,28 @@ export function synthesizedThreadFailed(before, after, written) {
180
180
  }
181
181
  return out;
182
182
  }
183
+ /** Cancel whatever is still live below each thread `written` cancels, deepest first. */
184
+ export function cascadedCancellations(state, written) {
185
+ const cancelled = written.flatMap((event) => {
186
+ if (event.type !== 'runtime.thread.cancelled') {
187
+ return [];
188
+ }
189
+ const payload = Predicate.isObject(event.payload) ? event.payload : {};
190
+ const threadId = Predicate.isString(payload.threadId) ? payload.threadId : event.threadId;
191
+ return threadId ? [threadId] : [];
192
+ });
193
+ const live = new Set(cancelled
194
+ .flatMap((threadId) => descendantThreadIds(state, threadId))
195
+ .filter((threadId) => {
196
+ const record = state.threads[threadId];
197
+ return record !== undefined && !isTerminalStatus(record.status);
198
+ }));
199
+ return [...live].toReversed().map((threadId) => ({
200
+ type: 'runtime.thread.cancelled',
201
+ payload: { threadId, reason: 'cancelled' },
202
+ threadId,
203
+ }));
204
+ }
183
205
  export function waitSatisfiedEvents(state, events) {
184
206
  const produced = [];
185
207
  const remaining = { ...state.waits };
@@ -1,4 +1,4 @@
1
- export { createRuntime, createTimeoutScheduler, DuplicateEffectDispatchError, MaxWakeIterationsError, RunOverloadedError, StartRunConflictError, type CreateRuntimeOptions, type EffectWorker, type EffectWorkerTask, type LoomsRuntime, type RunOperationalStatus, type RegisteredDefinition, type StartRunArgs, type WakeError, type WakeScheduler, type WorkerCallback, type WorkerCallbackInput, } from './runtime';
1
+ export { createRuntime, createTimeoutScheduler, DuplicateEffectDispatchError, ForkConflictError, MaxWakeIterationsError, RunNotFinishedError, RunNotFoundError, RunOverloadedError, RunTerminalError, StartRunConflictError, type CreateRuntimeOptions, type EffectWorker, type EffectWorkerTask, type LoomsRuntime, type RunOperationalStatus, type RegisteredDefinition, type StartRunArgs, type WakeError, type WakeScheduler, type WorkerCallback, type WorkerCallbackInput, } from './runtime';
2
2
  export { createLooms, type Looms, type CreateLoomsOptions, type StartResult } from './looms';
3
3
  export { serveHttp, createFetchHandler, isLoomsApiPath, type FetchHandlerOptions, authAllowed, authDenied, bearerAuth, defaultAuthorize, type AuthDecision, type AuthorizedAccess, type Authorize, type BearerTokens, type RouteAccess, type RouteInfo, type RunningServer, type LoomsFetchResult, } from './server';
4
4
  export { createEventStreamResponse, type EventStreamOptions } from './sse';
@@ -1,4 +1,4 @@
1
- export { createRuntime, createTimeoutScheduler, DuplicateEffectDispatchError, MaxWakeIterationsError, RunOverloadedError, StartRunConflictError, } from './runtime.js';
1
+ export { createRuntime, createTimeoutScheduler, DuplicateEffectDispatchError, ForkConflictError, MaxWakeIterationsError, RunNotFinishedError, RunNotFoundError, RunOverloadedError, RunTerminalError, StartRunConflictError, } from './runtime.js';
2
2
  export { createLooms } from './looms.js';
3
3
  export { serveHttp, createFetchHandler, isLoomsApiPath, authAllowed, authDenied, bearerAuth, defaultAuthorize, } from './server.js';
4
4
  export { createEventStreamResponse } from './sse.js';
@@ -89,6 +89,10 @@ export interface Looms {
89
89
  project<S>(runId: string, definition: ProjectionDefinition<S>): Promise<S>;
90
90
  replayTo(runId: string, seq: number): Promise<ReplayStep | null>;
91
91
  cancel(runId: string, threadId?: string): Promise<RunState>;
92
+ /** Start a new run that continues where a finished run ended. */
93
+ fork(sourceRunId: string, options?: {
94
+ runId?: string;
95
+ }): Promise<StartResult>;
92
96
  workerCallback(runId: string, input: WorkerCallbackInput): Promise<RunState>;
93
97
  fetch(req: Request): Promise<Response | null>;
94
98
  serve(options?: {
@@ -98,6 +98,7 @@ export function createLooms(options = {}) {
98
98
  project: (runId, definition) => runEffect((i) => i.runtime.project(runId, definition)),
99
99
  replayTo: (runId, seq) => runEffect((i) => i.runtime.replayTo(runId, seq)),
100
100
  cancel: (runId, threadId) => runEffect((i) => i.runtime.cancel(runId, threadId)),
101
+ fork: (sourceRunId, forkOpts) => runEffect((i) => i.runtime.fork(sourceRunId, forkOpts)),
101
102
  workerCallback: (runId, input) => runEffect((i) => i.runtime.workerCallback(runId, input)),
102
103
  fetch: (req) => {
103
104
  if (!handlesPath(new URL(req.url).pathname, debuggerMount)) {
@@ -5,9 +5,7 @@ function canonicalJson(value) {
5
5
  return `[${value.map(canonicalJson).join(',')}]`;
6
6
  }
7
7
  if (Predicate.isObject(value)) {
8
- const keys = Object.keys(value);
9
- // oxlint-disable-next-line unicorn/no-array-sort
10
- keys.sort();
8
+ const keys = Object.keys(value).toSorted();
11
9
  return `{${keys
12
10
  .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key] ?? null)}`)
13
11
  .join(',')}}`;
@@ -2,7 +2,7 @@ import { type AnyRuntimeModule } from '@swirls/looms/core';
2
2
  export type { RegisteredDefinition } from '@swirls/looms/core';
3
3
  export type { WakeScheduler } from './wake-scheduler';
4
4
  export { createTimeoutScheduler } from './wake-scheduler';
5
- export { DuplicateEffectDispatchError, MaxWakeIterationsError, StartRunConflictError, } from './errors';
5
+ export { DuplicateEffectDispatchError, ForkConflictError, MaxWakeIterationsError, RunNotFinishedError, RunNotFoundError, RunTerminalError, StartRunConflictError, } from './errors';
6
6
  export { RunOverloadedError } from './ingress';
7
7
  export type { CreateRuntimeOptions, EffectWorker, EffectWorkerTask, LoomsRuntime, RunOperationalStatus, StartResult, StartRunArgs, WakeError, } from './types';
8
8
  export type { WorkerCallback, WorkerCallbackInput } from '@swirls/looms/core';
@@ -1,8 +1,8 @@
1
1
  import { Clock, Effect, ManagedRuntime, Predicate } from 'effect';
2
- import { composeModules, createEvent, createThreadId, createRunId, DEFAULT_DEFINITION_VERSION, DEFAULT_SNAPSHOT_EVERY, definitionKey, EventStoreTag, foldRun, project, replayTo, summarizeRun, } from '@swirls/looms/core';
2
+ import { composeModules, createEvent, createThreadId, createRunId, DEFAULT_DEFINITION_VERSION, DEFAULT_SNAPSHOT_EVERY, definitionKey, descendantThreadIds, EventStoreTag, foldRun, isRunTerminal, planFork, project, replayTo, summarizeRun, } from '@swirls/looms/core';
3
3
  import { createCancellation } from './cancellation.js';
4
4
  import { nextRuntimeDeadline } from './deadline-transition-planner.js';
5
- import { RuntimeExecutionError } from './errors.js';
5
+ import { ForkConflictError, RunNotFinishedError, RunNotFoundError, RunTerminalError, RuntimeExecutionError, } from './errors.js';
6
6
  import { admitExternalEvents, moduleServices, resolveSnapshotStore, RunCursorCache, threadStartedEvents, UnknownDefinitionError, validateAndCreateEvents, waitSatisfiedEvents, } from './helpers.js';
7
7
  import { createRunIngress } from './ingress.js';
8
8
  import { notifyObserver } from './observer.js';
@@ -13,26 +13,21 @@ import { createWakeEngine } from './wake-engine.js';
13
13
  import { createTimeoutScheduler } from './wake-scheduler.js';
14
14
  import { createWorkerCallbacks } from './worker-callbacks.js';
15
15
  export { createTimeoutScheduler } from './wake-scheduler.js';
16
- export { DuplicateEffectDispatchError, MaxWakeIterationsError, StartRunConflictError, } from './errors.js';
16
+ export { DuplicateEffectDispatchError, ForkConflictError, MaxWakeIterationsError, RunNotFinishedError, RunNotFoundError, RunTerminalError, StartRunConflictError, } from './errors.js';
17
17
  export { RunOverloadedError } from './ingress.js';
18
18
  function currentTimeMillis() {
19
19
  return Math.floor(performance.timeOrigin + performance.now());
20
20
  }
21
- /** Threads whose in-process effects an admitted signal interrupts. */
22
- function interruptedThreadIds(events, rules) {
23
- const threadIds = new Set();
24
- for (const event of events) {
21
+ /** Threads whose in-process effects an admitted signal interrupts: each named one and its subtree. */
22
+ function interruptedThreadIds(events, rules, state) {
23
+ const named = events.flatMap((event) => {
25
24
  const payload = Predicate.isObject(event.payload) ? event.payload : {};
26
- if (event.type === 'runtime.thread.cancel.requested' && Predicate.isString(payload.threadId)) {
27
- threadIds.add(payload.threadId);
28
- }
29
- for (const rule of rules) {
30
- for (const threadId of rule(event)) {
31
- threadIds.add(threadId);
32
- }
33
- }
34
- }
35
- return threadIds;
25
+ const cancelled = event.type === 'runtime.thread.cancel.requested' && Predicate.isString(payload.threadId)
26
+ ? [payload.threadId]
27
+ : [];
28
+ return [...cancelled, ...rules.flatMap((rule) => rule(event))];
29
+ });
30
+ return new Set(named.flatMap((threadId) => [threadId, ...descendantThreadIds(state, threadId)]));
36
31
  }
37
32
  const DEFAULT_RUN_CACHE_SIZE = 0;
38
33
  const RESCAN_CONCURRENCY = 16;
@@ -83,6 +78,9 @@ export function createRuntime(options) {
83
78
  ...input,
84
79
  idempotencyKey: input.idempotencyKey ?? signalKey,
85
80
  })), { origin: { type: 'external' } });
81
+ if (isRunTerminal(current.state)) {
82
+ return yield* new RunTerminalError(runId, current.state.status);
83
+ }
86
84
  const folded = foldRun(batch, registry, { runId, initial: current.state });
87
85
  const satisfiedEvents = waitSatisfiedEvents(folded, batch).map((input) => createEvent(runId, input));
88
86
  return {
@@ -92,7 +90,7 @@ export function createRuntime(options) {
92
90
  };
93
91
  }));
94
92
  if (committed.append) {
95
- executionContexts.abort(runId, interruptedThreadIds(events, registry.interruptRules), 'Effect interrupted');
93
+ executionContexts.abort(runId, interruptedThreadIds(events, registry.interruptRules, committed.value), 'Effect interrupted');
96
94
  }
97
95
  return committed.value;
98
96
  });
@@ -295,6 +293,35 @@ export function createRuntime(options) {
295
293
  },
296
294
  signal: (runId, events, signalOpts) => admitExternalEvents(events).pipe(Effect.flatMap((admitted) => withRunIngress(runId, signalAdmitted(runId, admitted, signalOpts))), Effect.andThen(runtime.wake(runId))),
297
295
  cancel: cancellation,
296
+ fork: (sourceRunId, forkOptions) => {
297
+ const runId = forkOptions?.runId ?? createRunId();
298
+ return withRunIngress(runId, Effect.gen(function* () {
299
+ const store = yield* EventStoreTag;
300
+ const source = yield* loadCursor(store, sourceRunId);
301
+ if (source.seq === 0) {
302
+ return yield* new RunNotFoundError(sourceRunId);
303
+ }
304
+ if (!isRunTerminal(source.state)) {
305
+ return yield* new RunNotFinishedError(sourceRunId, source.state.status);
306
+ }
307
+ const plan = yield* planFork({
308
+ sourceRunId,
309
+ runId,
310
+ events: yield* store.read(sourceRunId),
311
+ registry,
312
+ });
313
+ // Appending at tail 0 numbers the copied events 1..n, the seqs they had in the source.
314
+ yield* commit(store, runId, (existing) => {
315
+ if (existing.seq === 0) {
316
+ return Effect.succeed({ events: plan.events, value: undefined });
317
+ }
318
+ return existing.state.forkedFrom?.runId === sourceRunId
319
+ ? Effect.succeed({ events: [], value: undefined })
320
+ : Effect.fail(new ForkConflictError(runId, sourceRunId));
321
+ });
322
+ return plan.rootThreadId;
323
+ })).pipe(Effect.flatMap((threadId) => runtime.wake(runId).pipe(Effect.map((state) => ({ runId, threadId, state })))));
324
+ },
298
325
  wake: wakeEngine,
299
326
  dispose: Effect.sync(() => scheduler.dispose?.()).pipe(Effect.andThen(services.disposeEffect)),
300
327
  };
@@ -29,6 +29,8 @@ export declare const EventInputBodySchema: Schema.Struct<{
29
29
  export declare function isLoomsApiPath(path: string): boolean;
30
30
  export declare function provideStore<A, E>(effect: Effect.Effect<A, E, EventStoreTag>, store: EventStore): Effect.Effect<A, E>;
31
31
  export declare const readJson: (req: Request) => Effect.Effect<JsonValue, RequestBodyError>;
32
+ /** `readJson` for routes whose body is optional: a request without one reads as `{}`. */
33
+ export declare const readOptionalJson: (req: Request) => Effect.Effect<JsonValue, RequestBodyError>;
32
34
  export declare function extractIdempotencyKey(req: Request, bodyKey?: string): string | undefined;
33
35
  export declare function acceptsEventStream(req: Request): boolean;
34
36
  export declare function readIntegerQuery(url: URL, name: string, options: {
@@ -1,6 +1,6 @@
1
1
  import { Data, Effect, Schema } from 'effect';
2
- import { EventStoreTag, InvalidEventError, InvalidInputError, JsonValueSchema, } from '@swirls/looms/core';
3
- import { RunOverloadedError, StartRunConflictError } from '../runtime.js';
2
+ import { EventStoreTag, ForkSourceError, InvalidEventError, InvalidInputError, JsonValueSchema, } from '@swirls/looms/core';
3
+ import { ForkConflictError, RunNotFinishedError, RunNotFoundError, RunOverloadedError, RunTerminalError, StartRunConflictError, } from '../runtime.js';
4
4
  export class RequestBodyError extends Data.TaggedError('RequestBodyError') {
5
5
  constructor(cause) {
6
6
  super({ cause, message: 'Invalid JSON request body' });
@@ -25,6 +25,10 @@ export const readJson = (req) => Effect.tryPromise({
25
25
  try: () => req.json(),
26
26
  catch: (cause) => new RequestBodyError(cause),
27
27
  }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(JsonValueSchema)), Effect.mapError((cause) => cause instanceof RequestBodyError ? cause : new RequestBodyError(cause)));
28
+ /** `readJson` for routes whose body is optional: a request without one reads as `{}`. */
29
+ export const readOptionalJson = (req) => req.body === null || req.headers.get('content-length') === '0'
30
+ ? Effect.succeed({})
31
+ : readJson(req);
28
32
  export function extractIdempotencyKey(req, bodyKey) {
29
33
  return (bodyKey ??
30
34
  req.headers.get('idempotency-key') ??
@@ -59,8 +63,19 @@ export function toErrorResponse(err) {
59
63
  if (err instanceof RunOverloadedError) {
60
64
  return Response.json({ error: err.message }, { status: 429 });
61
65
  }
66
+ if (err instanceof RunNotFoundError) {
67
+ return Response.json({ error: err.message }, { status: 404 });
68
+ }
69
+ if (err instanceof RunTerminalError) {
70
+ return Response.json({ error: err.message, status: err.status }, { status: 409 });
71
+ }
62
72
  if (err instanceof StartRunConflictError) {
63
73
  return Response.json({ error: err.message, mismatches: err.mismatches }, { status: 409 });
64
74
  }
75
+ if (err instanceof RunNotFinishedError ||
76
+ err instanceof ForkConflictError ||
77
+ err instanceof ForkSourceError) {
78
+ return Response.json({ error: err.message }, { status: 409 });
79
+ }
65
80
  return Response.json({ error: err.message }, { status: 500 });
66
81
  }