@skein-js/agent-protocol 0.5.0 → 0.6.3

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 (3) hide show
  1. package/dist/index.d.ts +182 -44
  2. package/dist/index.js +634 -99
  3. package/package.json +6 -6
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GraphSchema, SkeinStore, RunQueue, RunEventBus, AuthEngine, Assistant, AuthUser, Config, StreamMode, Metadata, MultitaskStrategy, RunFrame, DefaultValues, Run, RunStatus, StorePutOptions, Item, StoreSearchQuery, SearchItem, Thread, ThreadSearchQuery, ThreadState, StoreRepo } from '@skein-js/core';
1
+ import { GraphSchema, SkeinStore, RunQueue, RunEventBus, AuthEngine, AuthUser, Metadata, Thread, ThreadSearchQuery, ThreadState, Assistant, AssistantSearchQuery, AssistantCreate, AssistantUpdate, AssistantVersionsQuery, AssistantVersion, AssistantGraph, Config, StreamMode, MultitaskStrategy, RunFrame, DefaultValues, Run, RunStatus, StorePutOptions, Item, StoreSearchQuery, SearchItem, StoreRepo } from '@skein-js/core';
2
2
  import { CompiledGraph, BaseCheckpointSaver, BaseStore, Item as Item$1, Operation, OperationResults } from '@langchain/langgraph';
3
3
 
4
4
  /** A factory export: called (optionally with per-run config) to produce a compiled graph. */
@@ -23,6 +23,18 @@ interface GraphResolver {
23
23
  }
24
24
  /** A source of the current time; injected so tests are deterministic. */
25
25
  type Clock = () => Date;
26
+ /**
27
+ * Delivers a run-completion webhook: POST `payload` (as JSON) to `url`. Injected so the transport is
28
+ * swappable and tests can capture deliveries without a network. Should resolve once sent and reject
29
+ * on failure — the engine calls it best-effort (a rejection is logged, never fails the run).
30
+ *
31
+ * Security: `url` is client-supplied, so the dispatcher makes a *server-side* request to a target the
32
+ * caller chose (an SSRF surface) and sends it the run's final `values`. The default dispatcher
33
+ * restricts the scheme to `http(s)` but, since skein is self-hosted and internal webhook targets are a
34
+ * legitimate use, does **not** block private/loopback hosts. Deployments that accept untrusted
35
+ * `webhook` URLs should inject a dispatcher that validates the resolved host against an allowlist.
36
+ */
37
+ type WebhookDispatcher = (url: string, payload: unknown) => Promise<void>;
26
38
  /** A minimal structured logger. Defaults to a no-op so nothing is required. */
27
39
  interface Logger {
28
40
  debug(message: string, meta?: unknown): void;
@@ -58,6 +70,11 @@ interface ProtocolDeps {
58
70
  logRunActivity?: boolean;
59
71
  /** Optional per-run wall-clock timeout in ms. When set, a run exceeding it becomes `"timeout"`. */
60
72
  runTimeoutMs?: number;
73
+ /**
74
+ * Delivers run-completion webhooks (the run's `webhook` field). Defaults to a `globalThis.fetch`
75
+ * POST with a JSON body; inject to customize transport/retries or to capture deliveries in tests.
76
+ */
77
+ webhookDispatcher?: WebhookDispatcher;
61
78
  /**
62
79
  * Optional auth engine. When set, every request is authenticated (401 on failure) and authorized
63
80
  * per resource + action (403 on deny), with ownership filters scoping reads and stamping writes.
@@ -65,28 +82,23 @@ interface ProtocolDeps {
65
82
  */
66
83
  auth?: AuthEngine;
67
84
  }
68
- /** Fill in the optional deps (`clock`, `logger`) so the rest of the code can rely on them. */
85
+ /** Fill in the optional deps (`clock`, `logger`, `webhookDispatcher`) so the rest can rely on them. */
69
86
  interface ResolvedDeps extends ProtocolDeps {
70
87
  clock: Clock;
71
88
  logger: Logger;
89
+ webhookDispatcher: WebhookDispatcher;
72
90
  }
73
91
 
74
- interface AssistantSearch {
75
- graph_id?: string;
76
- limit?: number;
77
- offset?: number;
78
- }
79
- interface AssistantService {
80
- /** Register one assistant per declared graph (idempotent safe to call on every boot). */
81
- registerGraphAssistants(): Promise<Assistant[]>;
82
- get(assistantId: string): Promise<Assistant>;
83
- list(): Promise<Assistant[]>;
84
- search(query: AssistantSearch): Promise<Assistant[]>;
85
- schemas(assistantId: string): Promise<GraphSchemas>;
86
- }
87
-
88
- /** Why a run was aborted. Absent (`null`) means the run failed on its own. */
89
- type AbortReason = "cancel" | "timeout";
92
+ /**
93
+ * Why a run was aborted. Absent (`null`) means the run failed on its own.
94
+ * - `cancel` — explicit `POST /runs/{id}/cancel`; the run settles `cancelled`.
95
+ * - `timeout` — the run exceeded `runTimeoutMs`; it settles `timeout`.
96
+ * - `interrupt` — displaced by a `multitask_strategy: "interrupt"` run; it settles `interrupted`,
97
+ * keeping its checkpoint writes.
98
+ * - `rollback` displaced by a `multitask_strategy: "rollback"` run; it settles terminally and the
99
+ * displacing run then discards its checkpoint writes (see the run service).
100
+ */
101
+ type AbortReason = "cancel" | "timeout" | "interrupt" | "rollback";
90
102
  /** Handle handed to the engine for a run: the signal to thread into the graph, and the reason box. */
91
103
  interface RunControl {
92
104
  signal: AbortSignal;
@@ -115,10 +127,42 @@ declare class ThreadLocks {
115
127
  run<T>(key: string, task: () => Promise<T>): Promise<T>;
116
128
  }
117
129
 
130
+ /**
131
+ * What a `multitask_strategy: "rollback"` run must do to the thread *before* it executes: drop the
132
+ * displaced run(s)' checkpoint writes and delete their run rows. Registered at create time and
133
+ * applied once the displaced run has settled (see `startRunExecution`). In-process, like
134
+ * {@link RunControlRegistry} — a single-process seam.
135
+ *
136
+ * `revertToCheckpoint` is `false` when no displaced run wrote checkpoints (all were still pending),
137
+ * so only the rows are deleted; otherwise it carries the base tip to revert to (`baseCheckpointId
138
+ * === undefined` means the displaced run started on a fresh thread, so the revert is a clean wipe).
139
+ */
140
+ interface RollbackPlan {
141
+ revertToCheckpoint: {
142
+ baseCheckpointId: string | undefined;
143
+ } | false;
144
+ displacedRunIds: string[];
145
+ }
118
146
  interface ProtocolContext {
119
147
  deps: ResolvedDeps;
120
148
  control: RunControlRegistry;
149
+ /** Per-thread lock closing the create-guard's check-then-insert race. */
121
150
  locks: ThreadLocks;
151
+ /**
152
+ * Per-thread lock serializing run *execution* (distinct from {@link locks}, which only guards
153
+ * creation). Every run executes inside it, so a second run on a busy thread waits its turn — this
154
+ * is what makes `multitask_strategy: "enqueue"` run behind the active run, and what makes an
155
+ * `interrupt`/`rollback` run start only after the displaced run has fully settled.
156
+ */
157
+ executionLocks: ThreadLocks;
158
+ /**
159
+ * runId → the thread's checkpoint tip when that run started executing (`undefined` if the thread
160
+ * had no checkpoints yet). Captured by the engine at `pending -> running`, cleared when the run
161
+ * settles; read to compute a {@link RollbackPlan}. In-process, per {@link control}'s convention.
162
+ */
163
+ runBaseCheckpoints: Map<string, string | undefined>;
164
+ /** New runId → the {@link RollbackPlan} its execution must apply before running. In-process. */
165
+ rollbackPlans: Map<string, RollbackPlan>;
122
166
  /**
123
167
  * The authenticated caller for the current request, populated by the authorizing handler wrapper
124
168
  * so the run service can stamp it onto a run's kwargs. Undefined when no auth engine is
@@ -130,6 +174,79 @@ interface ProtocolContext {
130
174
  }
131
175
  declare function createContext(deps: ProtocolDeps): ProtocolContext;
132
176
 
177
+ interface CreateThreadInput {
178
+ thread_id?: string;
179
+ metadata?: Metadata;
180
+ }
181
+ interface PatchThreadInput {
182
+ metadata?: Metadata;
183
+ }
184
+ interface HistoryOptions {
185
+ limit?: number;
186
+ }
187
+ interface ThreadService {
188
+ create(input?: CreateThreadInput): Promise<Thread>;
189
+ get(threadId: string): Promise<Thread>;
190
+ list(): Promise<Thread[]>;
191
+ /** Filtered + paginated listing — `POST /threads/search`. */
192
+ search(query: ThreadSearchQuery): Promise<Thread[]>;
193
+ patch(threadId: string, patch: PatchThreadInput): Promise<Thread>;
194
+ /** Duplicate a thread (new id) together with its full checkpoint history — `POST /threads/{id}/copy`. */
195
+ copy(threadId: string): Promise<Thread>;
196
+ delete(threadId: string): Promise<void>;
197
+ history(threadId: string, options?: HistoryOptions): Promise<ThreadState[]>;
198
+ /** The thread's current state snapshot — `GET /threads/{id}/state`, what `useStream` hydrates from. */
199
+ getState(threadId: string): Promise<ThreadState>;
200
+ }
201
+
202
+ /** `POST /assistants` — create input plus LangGraph's `if_exists` conflict policy. */
203
+ interface CreateAssistantInput extends AssistantCreate {
204
+ /** When the `assistant_id` already exists: `"raise"` (default, 409) or `"do_nothing"` (return it). */
205
+ ifExists?: "raise" | "do_nothing";
206
+ }
207
+ /** `DELETE /assistants/{id}` options. */
208
+ interface DeleteAssistantOptions {
209
+ /** Also delete threads whose `metadata.assistant_id` is this assistant (aborting their runs). */
210
+ deleteThreads?: boolean;
211
+ }
212
+ /** `GET /assistants/{id}/graph` options. */
213
+ interface DrawGraphOptions {
214
+ /** Include subgraphs in the drawn graph; an integer bounds the depth (mirrors LangGraph). */
215
+ xray?: number | boolean;
216
+ }
217
+ /** `GET /assistants/{id}/subgraphs[/{namespace}]` options. */
218
+ interface SubgraphsOptions {
219
+ /** Restrict to this subgraph namespace. */
220
+ namespace?: string;
221
+ /** Recurse into nested subgraphs. */
222
+ recurse?: boolean;
223
+ }
224
+ interface AssistantService {
225
+ /** Register one assistant per declared graph (idempotent — safe to call on every boot). */
226
+ registerGraphAssistants(): Promise<Assistant[]>;
227
+ get(assistantId: string): Promise<Assistant>;
228
+ list(): Promise<Assistant[]>;
229
+ /** Filtered + paginated listing — `POST /assistants/search`. */
230
+ search(query: AssistantSearchQuery): Promise<Assistant[]>;
231
+ /** Count matching assistants (ignores limit/offset) — `POST /assistants/count`. */
232
+ count(query: AssistantSearchQuery): Promise<number>;
233
+ schemas(assistantId: string): Promise<GraphSchemas>;
234
+ /** Create an assistant, honoring `if_exists` — `POST /assistants`. */
235
+ create(input: CreateAssistantInput): Promise<Assistant>;
236
+ /** Patch an assistant, minting a new version — `PATCH /assistants/{id}`. */
237
+ update(assistantId: string, patch: AssistantUpdate): Promise<Assistant>;
238
+ /** Delete an assistant (and, optionally, its threads) — `DELETE /assistants/{id}`. */
239
+ delete(assistantId: string, options?: DeleteAssistantOptions): Promise<void>;
240
+ /** Version history, newest-first — `POST /assistants/{id}/versions`. */
241
+ listVersions(assistantId: string, query?: AssistantVersionsQuery): Promise<AssistantVersion[]>;
242
+ /** Roll back to an existing version — `POST /assistants/{id}/latest`. */
243
+ setLatest(assistantId: string, version: number): Promise<Assistant>;
244
+ /** The drawable graph JSON — `GET /assistants/{id}/graph`. */
245
+ drawGraph(assistantId: string, options?: DrawGraphOptions): Promise<AssistantGraph>;
246
+ /** Each subgraph's schema, keyed by namespace — `GET /assistants/{id}/subgraphs`. */
247
+ subgraphs(assistantId: string, options?: SubgraphsOptions): Promise<GraphSchemas>;
248
+ }
249
+
133
250
  /** A run request as it arrives from any of the run endpoints (already validated). */
134
251
  interface CreateRunInput {
135
252
  thread_id?: string;
@@ -147,6 +264,8 @@ interface CreateRunInput {
147
264
  multitask_strategy?: MultitaskStrategy;
148
265
  interrupt_before?: string[] | "*";
149
266
  interrupt_after?: string[] | "*";
267
+ /** Absolute `http(s)` URL POSTed with the settled run once it reaches a terminal status. */
268
+ webhook?: string;
150
269
  }
151
270
  /** A started streaming run: its id, plus the live frame iterable to serialize as SSE. */
152
271
  interface StartedStream {
@@ -174,31 +293,6 @@ interface StoreService {
174
293
  listNamespaces(prefix?: string[]): Promise<string[][]>;
175
294
  }
176
295
 
177
- interface CreateThreadInput {
178
- thread_id?: string;
179
- metadata?: Metadata;
180
- }
181
- interface PatchThreadInput {
182
- metadata?: Metadata;
183
- }
184
- interface HistoryOptions {
185
- limit?: number;
186
- }
187
- interface ThreadService {
188
- create(input?: CreateThreadInput): Promise<Thread>;
189
- get(threadId: string): Promise<Thread>;
190
- list(): Promise<Thread[]>;
191
- /** Filtered + paginated listing — `POST /threads/search`. */
192
- search(query: ThreadSearchQuery): Promise<Thread[]>;
193
- patch(threadId: string, patch: PatchThreadInput): Promise<Thread>;
194
- /** Duplicate a thread (new id) together with its full checkpoint history — `POST /threads/{id}/copy`. */
195
- copy(threadId: string): Promise<Thread>;
196
- delete(threadId: string): Promise<void>;
197
- history(threadId: string, options?: HistoryOptions): Promise<ThreadState[]>;
198
- /** The thread's current state snapshot — `GET /threads/{id}/state`, what `useStream` hydrates from. */
199
- getState(threadId: string): Promise<ThreadState>;
200
- }
201
-
202
296
  /** Body for `POST /threads/{id}/stream` — a run request without the thread id (it's in the path). */
203
297
  interface ThreadStreamInput {
204
298
  assistant_id: string;
@@ -270,7 +364,15 @@ type ProtocolHandler = (req: ProtocolRequest) => Promise<ProtocolResponse>;
270
364
  interface ProtocolHandlers {
271
365
  getAssistant: ProtocolHandler;
272
366
  searchAssistants: ProtocolHandler;
367
+ countAssistants: ProtocolHandler;
273
368
  getAssistantSchemas: ProtocolHandler;
369
+ createAssistant: ProtocolHandler;
370
+ updateAssistant: ProtocolHandler;
371
+ deleteAssistant: ProtocolHandler;
372
+ listAssistantVersions: ProtocolHandler;
373
+ setAssistantLatestVersion: ProtocolHandler;
374
+ getAssistantGraph: ProtocolHandler;
375
+ getAssistantSubgraphs: ProtocolHandler;
274
376
  createThread: ProtocolHandler;
275
377
  getThread: ProtocolHandler;
276
378
  listThreads: ProtocolHandler;
@@ -327,6 +429,42 @@ interface ProtocolRuntime {
327
429
  */
328
430
  declare function createProtocolRuntime(deps: ProtocolDeps, options?: ProtocolRuntimeOptions): ProtocolRuntime;
329
431
 
432
+ type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
433
+ interface RouteBinding {
434
+ method: HttpMethod;
435
+ /** Path with `:param` placeholders, e.g. `/threads/:thread_id/runs/stream`. */
436
+ path: string;
437
+ handler: keyof ProtocolHandlers;
438
+ /**
439
+ * Fold the path `thread_id` into the request body before dispatch. The SDK addresses a
440
+ * thread-scoped run by its path (`POST /threads/{id}/runs/stream`) while carrying only
441
+ * `assistant_id` in the body, but the stateless run handlers read `thread_id` from the body — so a
442
+ * thread-scoped mount must copy it across, or the run would target a stray new thread.
443
+ */
444
+ foldThreadIdIntoBody?: boolean;
445
+ }
446
+ /** The route table, ordered most-specific-first within each method so literals win over params. */
447
+ declare const skeinRoutes: readonly RouteBinding[];
448
+ /**
449
+ * Copy the path `thread_id` into an object body so a stateless run handler runs on the right thread.
450
+ * A no-op when there is no `thread_id` param. Used by every adapter for the `foldThreadIdIntoBody`
451
+ * routes, so the folding rule lives in one place.
452
+ */
453
+ declare function foldThreadId(request: ProtocolRequest): ProtocolRequest;
454
+ /** A resolved route: the matched binding plus the path params extracted from the URL. */
455
+ interface RouteMatch {
456
+ binding: RouteBinding;
457
+ params: Record<string, string>;
458
+ }
459
+ /**
460
+ * Match a `method` + `pathname` (no query string) against the route table, returning the binding and
461
+ * extracted path params, or `undefined` when nothing matches. Iterates in table order, which is
462
+ * most-specific-first, so a literal segment wins over a `:param`. This is what adapters that dispatch
463
+ * from a catch-all route (NestJS middleware, Next.js route handlers) use in place of a framework
464
+ * router; Express/Fastify bind `skeinRoutes` to their native router instead.
465
+ */
466
+ declare function matchSkeinRoute(method: string, pathname: string): RouteMatch | undefined;
467
+
330
468
  type LangGraphSearchItem = Item$1 & {
331
469
  score?: number;
332
470
  };
@@ -380,4 +518,4 @@ declare function toSseEvents(frames: AsyncIterable<RunFrame>, finalStatus: () =>
380
518
  */
381
519
  declare function parseAfterSeq(lastEventId: string | undefined): number;
382
520
 
383
- export { type AssistantSearch, type AssistantService, type Clock, type CommandInput, type CompiledGraphFactory, type CreateRunInput, type CreateThreadInput, type GraphResolver, type GraphSchemas, type HistoryOptions, type Logger, type PatchThreadInput, type ProtocolContext, type ProtocolDeps, type ProtocolHandler, type ProtocolHandlers, type ProtocolRequest, type ProtocolResponse, type ProtocolRuntime, type ProtocolRuntimeOptions, type ProtocolService, type ResolvedGraph, type RunService, type RunWorker, type RunWorkerOptions, SSE_HEADERS, SkeinBaseStore, type StartedStream, type StoreService, type ThreadService, type ThreadStreamInput, type ThreadStreamService, buildProtocolService, createContext, createProtocolHandlers, createProtocolRuntime, createProtocolService, createRunWorker, encodeFrame, encodeTerminal, parseAfterSeq, toSseEvents };
521
+ export { type AssistantService, type Clock, type CommandInput, type CompiledGraphFactory, type CreateAssistantInput, type CreateRunInput, type CreateThreadInput, type DeleteAssistantOptions, type DrawGraphOptions, type GraphResolver, type GraphSchemas, type HistoryOptions, type HttpMethod, type Logger, type PatchThreadInput, type ProtocolContext, type ProtocolDeps, type ProtocolHandler, type ProtocolHandlers, type ProtocolRequest, type ProtocolResponse, type ProtocolRuntime, type ProtocolRuntimeOptions, type ProtocolService, type ResolvedGraph, type RouteBinding, type RouteMatch, type RunService, type RunWorker, type RunWorkerOptions, SSE_HEADERS, SkeinBaseStore, type StartedStream, type StoreService, type SubgraphsOptions, type ThreadService, type ThreadStreamInput, type ThreadStreamService, buildProtocolService, createContext, createProtocolHandlers, createProtocolRuntime, createProtocolService, createRunWorker, encodeFrame, encodeTerminal, foldThreadId, matchSkeinRoute, parseAfterSeq, skeinRoutes, toSseEvents };
package/dist/index.js CHANGED
@@ -75,7 +75,9 @@ var runCreateSchema = z.object({
75
75
  metadata: z.record(z.unknown()).optional(),
76
76
  multitask_strategy: multitaskStrategySchema.optional(),
77
77
  interrupt_before: interruptWhenSchema.optional(),
78
- interrupt_after: interruptWhenSchema.optional()
78
+ interrupt_after: interruptWhenSchema.optional(),
79
+ /** Run-completion webhook: an absolute `http(s)` URL POSTed the settled run when it finishes. */
80
+ webhook: z.string().url().optional()
79
81
  }).passthrough();
80
82
  var threadStreamSchema = z.object({
81
83
  assistant_id: z.string().min(1),
@@ -111,11 +113,47 @@ var threadSearchSchema = z.object({
111
113
  sort_by: z.enum(["thread_id", "status", "created_at", "updated_at"]).optional(),
112
114
  sort_order: z.enum(["asc", "desc"]).optional()
113
115
  }).passthrough();
116
+ var assistantCreateSchema = z.object({
117
+ graph_id: z.string().min(1),
118
+ assistant_id: z.string().min(1).optional(),
119
+ name: z.string().optional(),
120
+ description: z.string().optional(),
121
+ config: configSchema.optional(),
122
+ context: z.unknown().optional(),
123
+ metadata: z.record(z.unknown()).optional(),
124
+ /** Conflict policy when `assistant_id` already exists; defaults to `raise`. */
125
+ if_exists: z.enum(["raise", "do_nothing"]).optional()
126
+ }).passthrough();
127
+ var assistantUpdateSchema = z.object({
128
+ graph_id: z.string().min(1).optional(),
129
+ name: z.string().optional(),
130
+ description: z.string().optional(),
131
+ config: configSchema.optional(),
132
+ context: z.unknown().optional(),
133
+ metadata: z.record(z.unknown()).optional()
134
+ }).passthrough();
114
135
  var assistantSearchSchema = z.object({
115
136
  graph_id: z.string().optional(),
116
- limit: z.number().int().positive().optional(),
137
+ name: z.string().optional(),
138
+ metadata: z.record(z.unknown()).optional(),
139
+ limit: z.number().int().positive().max(1e3).optional(),
140
+ offset: z.number().int().nonnegative().optional(),
141
+ sort_by: z.enum(["assistant_id", "graph_id", "name", "created_at", "updated_at"]).optional(),
142
+ sort_order: z.enum(["asc", "desc"]).optional()
143
+ }).passthrough();
144
+ var assistantCountSchema = z.object({
145
+ graph_id: z.string().optional(),
146
+ name: z.string().optional(),
147
+ metadata: z.record(z.unknown()).optional()
148
+ }).passthrough();
149
+ var assistantVersionsSchema = z.object({
150
+ metadata: z.record(z.unknown()).optional(),
151
+ limit: z.number().int().positive().max(1e3).optional(),
117
152
  offset: z.number().int().nonnegative().optional()
118
153
  }).passthrough();
154
+ var assistantSetLatestSchema = z.object({
155
+ version: z.number().int().positive()
156
+ }).passthrough();
119
157
  var storePutSchema = z.object({
120
158
  namespace: z.array(z.string()).min(1),
121
159
  key: z.string().min(1),
@@ -150,6 +188,15 @@ function positiveIntQuery(value) {
150
188
  const parsed = Number.parseInt(raw, 10);
151
189
  return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
152
190
  }
191
+ function booleanQuery(value) {
192
+ const raw = queryValue(value);
193
+ if (raw === "true") return true;
194
+ if (raw === "false") return false;
195
+ return void 0;
196
+ }
197
+ function xrayQuery(value) {
198
+ return booleanQuery(value) ?? positiveIntQuery(value);
199
+ }
153
200
  function createProtocolHandlers(service) {
154
201
  const sse = (runId, frames) => ({
155
202
  kind: "sse",
@@ -160,8 +207,85 @@ function createProtocolHandlers(service) {
160
207
  return {
161
208
  // --- assistants ---------------------------------------------------------------------------
162
209
  getAssistant: async (req) => json(await service.assistants.get(requireParam(req.params, "assistant_id"))),
163
- searchAssistants: async (req) => json(await service.assistants.search(parse(assistantSearchSchema, req.body ?? {}))),
210
+ searchAssistants: async (req) => {
211
+ const body = parse(assistantSearchSchema, req.body ?? {});
212
+ return json(
213
+ await service.assistants.search({
214
+ graph_id: body.graph_id,
215
+ name: body.name,
216
+ metadata: body.metadata,
217
+ limit: body.limit,
218
+ offset: body.offset,
219
+ sortBy: body.sort_by,
220
+ sortOrder: body.sort_order
221
+ })
222
+ );
223
+ },
224
+ countAssistants: async (req) => {
225
+ const body = parse(assistantCountSchema, req.body ?? {});
226
+ return json(
227
+ await service.assistants.count({
228
+ graph_id: body.graph_id,
229
+ name: body.name,
230
+ metadata: body.metadata
231
+ })
232
+ );
233
+ },
164
234
  getAssistantSchemas: async (req) => json(await service.assistants.schemas(requireParam(req.params, "assistant_id"))),
235
+ createAssistant: async (req) => {
236
+ const body = parse(assistantCreateSchema, req.body);
237
+ return json(
238
+ await service.assistants.create({
239
+ graph_id: body.graph_id,
240
+ assistant_id: body.assistant_id,
241
+ name: body.name,
242
+ description: body.description,
243
+ config: body.config,
244
+ context: body.context,
245
+ metadata: body.metadata,
246
+ ifExists: body.if_exists
247
+ })
248
+ );
249
+ },
250
+ updateAssistant: async (req) => json(
251
+ await service.assistants.update(
252
+ requireParam(req.params, "assistant_id"),
253
+ parse(assistantUpdateSchema, req.body ?? {})
254
+ )
255
+ ),
256
+ deleteAssistant: async (req) => {
257
+ await service.assistants.delete(requireParam(req.params, "assistant_id"), {
258
+ deleteThreads: booleanQuery(req.query["delete_threads"]) ?? false
259
+ });
260
+ return empty();
261
+ },
262
+ listAssistantVersions: async (req) => {
263
+ const body = parse(assistantVersionsSchema, req.body ?? {});
264
+ return json(
265
+ await service.assistants.listVersions(requireParam(req.params, "assistant_id"), {
266
+ metadata: body.metadata,
267
+ limit: body.limit,
268
+ offset: body.offset
269
+ })
270
+ );
271
+ },
272
+ setAssistantLatestVersion: async (req) => {
273
+ const body = parse(assistantSetLatestSchema, req.body);
274
+ return json(
275
+ await service.assistants.setLatest(requireParam(req.params, "assistant_id"), body.version)
276
+ );
277
+ },
278
+ getAssistantGraph: async (req) => json(
279
+ await service.assistants.drawGraph(requireParam(req.params, "assistant_id"), {
280
+ xray: xrayQuery(req.query["xray"])
281
+ })
282
+ ),
283
+ getAssistantSubgraphs: async (req) => json(
284
+ await service.assistants.subgraphs(requireParam(req.params, "assistant_id"), {
285
+ namespace: queryValue(req.query["namespace"]) ?? req.params["namespace"],
286
+ recurse: booleanQuery(req.query["recurse"]) ?? false
287
+ })
288
+ ),
165
289
  // --- threads ------------------------------------------------------------------------------
166
290
  createThread: async (req) => json(await service.threads.create(parse(threadCreateSchema, req.body ?? {}))),
167
291
  getThread: async (req) => json(await service.threads.get(requireParam(req.params, "thread_id"))),
@@ -280,35 +404,133 @@ function createProtocolHandlers(service) {
280
404
  }
281
405
 
282
406
  // src/assistants/assistant-service.ts
283
- import { SkeinHttpError as SkeinHttpError2 } from "@skein-js/core";
284
- function createAssistantService(deps) {
407
+ import {
408
+ isSkeinHttpError,
409
+ SkeinHttpError as SkeinHttpError2
410
+ } from "@skein-js/core";
411
+ function factoryConfigurable(config) {
412
+ const configurable = config?.configurable;
413
+ return { configurable };
414
+ }
415
+ function createAssistantService(ctx, threads) {
416
+ const { deps } = ctx;
285
417
  const requireAssistant = async (assistantId) => {
286
418
  const assistant = await deps.store.assistants.get(assistantId);
287
419
  if (!assistant) throw SkeinHttpError2.notFound(`Assistant "${assistantId}" not found.`);
288
420
  return assistant;
289
421
  };
422
+ const requireGraph = (graphId) => {
423
+ if (!deps.graphs.ids.includes(graphId)) {
424
+ throw SkeinHttpError2.badRequest(`Unknown graph "${graphId}".`);
425
+ }
426
+ };
427
+ const deletableThreads = async (assistantId) => {
428
+ const owned = await deps.store.threads.search({ metadata: { assistant_id: assistantId } });
429
+ const engine = deps.auth;
430
+ if (!engine?.enabled || !ctx.authUser) return owned;
431
+ try {
432
+ const { filters } = await engine.authorize({
433
+ resource: "threads",
434
+ action: "delete",
435
+ value: { assistant_id: assistantId },
436
+ context: { user: ctx.authUser, scopes: ctx.authScopes ?? [] }
437
+ });
438
+ if (!filters) return owned;
439
+ return owned.filter((thread) => engine.matchesFilters(thread.metadata ?? void 0, filters));
440
+ } catch {
441
+ return [];
442
+ }
443
+ };
444
+ const compileGraph = async (assistant) => {
445
+ const resolved = await deps.graphs.load(assistant.graph_id);
446
+ return typeof resolved === "function" ? await resolved(factoryConfigurable(assistant.config)) : resolved;
447
+ };
290
448
  return {
291
449
  async registerGraphAssistants() {
292
450
  const registered = [];
293
451
  for (const graphId of deps.graphs.ids) {
294
452
  const existing = await deps.store.assistants.get(graphId);
295
- registered.push(
296
- existing ?? await deps.store.assistants.create({ graph_id: graphId, assistant_id: graphId })
297
- );
453
+ if (existing) {
454
+ registered.push(existing);
455
+ continue;
456
+ }
457
+ try {
458
+ registered.push(
459
+ await deps.store.assistants.create({ graph_id: graphId, assistant_id: graphId })
460
+ );
461
+ } catch (error) {
462
+ if (isSkeinHttpError(error) && error.status === 409) {
463
+ const now = await deps.store.assistants.get(graphId);
464
+ if (now) {
465
+ registered.push(now);
466
+ continue;
467
+ }
468
+ }
469
+ throw error;
470
+ }
298
471
  }
299
472
  return registered;
300
473
  },
301
474
  get: requireAssistant,
302
475
  list: () => deps.store.assistants.list(),
303
- async search(query) {
304
- const all = await deps.store.assistants.list();
305
- const filtered = query.graph_id ? all.filter((assistant) => assistant.graph_id === query.graph_id) : all;
306
- const offset = query.offset ?? 0;
307
- return filtered.slice(offset, query.limit === void 0 ? void 0 : offset + query.limit);
308
- },
476
+ search: (query) => deps.store.assistants.search(query),
477
+ count: (query) => deps.store.assistants.count(query),
309
478
  async schemas(assistantId) {
310
479
  const assistant = await requireAssistant(assistantId);
311
480
  return deps.graphs.schemas(assistant.graph_id);
481
+ },
482
+ async create({ ifExists, ...input }) {
483
+ requireGraph(input.graph_id);
484
+ try {
485
+ return await deps.store.assistants.create(input);
486
+ } catch (error) {
487
+ if (input.assistant_id !== void 0 && ifExists === "do_nothing" && isSkeinHttpError(error) && error.status === 409) {
488
+ const existing = await deps.store.assistants.get(input.assistant_id);
489
+ if (existing) return existing;
490
+ }
491
+ throw error;
492
+ }
493
+ },
494
+ update(assistantId, patch) {
495
+ if (patch.graph_id !== void 0) requireGraph(patch.graph_id);
496
+ return deps.store.assistants.update(assistantId, patch);
497
+ },
498
+ async delete(assistantId, options) {
499
+ await requireAssistant(assistantId);
500
+ if (options?.deleteThreads) {
501
+ const owned = await deletableThreads(assistantId);
502
+ await Promise.all(owned.map((thread) => threads.delete(thread.thread_id)));
503
+ }
504
+ await deps.store.assistants.delete(assistantId);
505
+ },
506
+ async listVersions(assistantId, query) {
507
+ await requireAssistant(assistantId);
508
+ return deps.store.assistants.listVersions(assistantId, query);
509
+ },
510
+ setLatest: (assistantId, version) => deps.store.assistants.setLatest(assistantId, version),
511
+ async drawGraph(assistantId, options) {
512
+ const assistant = await requireAssistant(assistantId);
513
+ const graph = await compileGraph(assistant);
514
+ const drawable = await graph.getGraphAsync({
515
+ ...assistant.config ?? {},
516
+ xray: options?.xray
517
+ });
518
+ return drawable.toJSON();
519
+ },
520
+ async subgraphs(assistantId, options) {
521
+ const assistant = await requireAssistant(assistantId);
522
+ const graph = await compileGraph(assistant);
523
+ const schemas = await deps.graphs.schemas(assistant.graph_id);
524
+ const rootGraphId = Object.keys(schemas).find((key) => !key.includes("|"));
525
+ const result = {};
526
+ for await (const [namespace] of graph.getSubgraphsAsync(
527
+ options?.namespace,
528
+ options?.recurse
529
+ )) {
530
+ const schema = schemas[`${rootGraphId}|${namespace}`] ?? (rootGraphId !== void 0 ? schemas[rootGraphId] : void 0);
531
+ if (schema !== void 0) result[namespace] = schema;
532
+ }
533
+ return result;
312
534
  }
313
535
  };
314
536
  }
@@ -324,11 +546,35 @@ var noopLogger = {
324
546
  error: () => {
325
547
  }
326
548
  };
549
+ var fetchWebhookDispatcher = async (url, payload) => {
550
+ let scheme;
551
+ try {
552
+ scheme = new URL(url).protocol;
553
+ } catch {
554
+ throw new Error(`Webhook URL "${url}" is not a valid absolute URL`);
555
+ }
556
+ if (scheme !== "http:" && scheme !== "https:") {
557
+ throw new Error(`Webhook URL scheme "${scheme}" is not allowed (only http/https)`);
558
+ }
559
+ const send = globalThis.fetch;
560
+ if (!send) {
561
+ throw new Error("global fetch is unavailable; inject a webhookDispatcher to deliver webhooks");
562
+ }
563
+ const response = await send(url, {
564
+ method: "POST",
565
+ headers: { "content-type": "application/json" },
566
+ body: JSON.stringify(payload)
567
+ });
568
+ if (!response.ok) {
569
+ throw new Error(`Webhook POST to ${url} failed with status ${response.status}`);
570
+ }
571
+ };
327
572
  function resolveDeps(deps) {
328
573
  return {
329
574
  ...deps,
330
575
  clock: deps.clock ?? (() => /* @__PURE__ */ new Date()),
331
- logger: deps.logger ?? noopLogger
576
+ logger: deps.logger ?? noopLogger,
577
+ webhookDispatcher: deps.webhookDispatcher ?? fetchWebhookDispatcher
332
578
  };
333
579
  }
334
580
 
@@ -388,7 +634,10 @@ function createContext(deps) {
388
634
  return {
389
635
  deps: resolveDeps(deps),
390
636
  control: new RunControlRegistry(),
391
- locks: new ThreadLocks()
637
+ locks: new ThreadLocks(),
638
+ executionLocks: new ThreadLocks(),
639
+ runBaseCheckpoints: /* @__PURE__ */ new Map(),
640
+ rollbackPlans: /* @__PURE__ */ new Map()
392
641
  };
393
642
  }
394
643
 
@@ -398,6 +647,75 @@ import {
398
647
  SkeinHttpError as SkeinHttpError5
399
648
  } from "@skein-js/core";
400
649
 
650
+ // src/threads/checkpoint-history.ts
651
+ import {
652
+ copyCheckpoint
653
+ } from "@langchain/langgraph";
654
+ async function replayCheckpoints(checkpointer, targetId, tuples) {
655
+ for (const tuple of tuples) {
656
+ const ns = tuple.config.configurable?.checkpoint_ns ?? "";
657
+ const parentId = tuple.parentConfig?.configurable?.checkpoint_id;
658
+ const putConfig = {
659
+ configurable: { thread_id: targetId, checkpoint_ns: ns, checkpoint_id: parentId }
660
+ };
661
+ await checkpointer.put(
662
+ putConfig,
663
+ copyCheckpoint(tuple.checkpoint),
664
+ tuple.metadata ?? {},
665
+ tuple.checkpoint.channel_versions
666
+ );
667
+ if (tuple.pendingWrites && tuple.pendingWrites.length > 0) {
668
+ const writeConfig = {
669
+ configurable: {
670
+ thread_id: targetId,
671
+ checkpoint_ns: ns,
672
+ checkpoint_id: tuple.checkpoint.id
673
+ }
674
+ };
675
+ const byTask = /* @__PURE__ */ new Map();
676
+ for (const [taskId, channel, value] of tuple.pendingWrites) {
677
+ const writes = byTask.get(taskId) ?? [];
678
+ writes.push([channel, value]);
679
+ byTask.set(taskId, writes);
680
+ }
681
+ for (const [taskId, writes] of byTask) {
682
+ await checkpointer.putWrites(writeConfig, writes, taskId);
683
+ }
684
+ }
685
+ }
686
+ }
687
+ async function listCheckpoints(checkpointer, threadId) {
688
+ const tuples = [];
689
+ for await (const tuple of checkpointer.list({ configurable: { thread_id: threadId } })) {
690
+ tuples.push(tuple);
691
+ }
692
+ return tuples;
693
+ }
694
+ async function copyCheckpointHistory(checkpointer, sourceId, targetId) {
695
+ const tuples = await listCheckpoints(checkpointer, sourceId);
696
+ await replayCheckpoints(checkpointer, targetId, tuples.reverse());
697
+ }
698
+ async function rollbackThreadCheckpointsTo(checkpointer, threadId, baseCheckpointId) {
699
+ if (baseCheckpointId === void 0) {
700
+ await checkpointer.deleteThread(threadId);
701
+ return;
702
+ }
703
+ const tuples = await listCheckpoints(checkpointer, threadId);
704
+ const byId = new Map(tuples.map((tuple) => [tuple.checkpoint.id, tuple]));
705
+ if (!byId.has(baseCheckpointId)) return;
706
+ const keep = [];
707
+ let cursor = baseCheckpointId;
708
+ const seen = /* @__PURE__ */ new Set();
709
+ while (cursor !== void 0 && byId.has(cursor) && !seen.has(cursor)) {
710
+ seen.add(cursor);
711
+ const tuple = byId.get(cursor);
712
+ keep.push(tuple);
713
+ cursor = tuple.parentConfig?.configurable?.checkpoint_id;
714
+ }
715
+ await checkpointer.deleteThread(threadId);
716
+ await replayCheckpoints(checkpointer, threadId, keep.reverse());
717
+ }
718
+
401
719
  // src/runs/run-engine.ts
402
720
  import {
403
721
  isTerminalRunStatus as isTerminalRunStatus2,
@@ -405,7 +723,7 @@ import {
405
723
  } from "@skein-js/core";
406
724
 
407
725
  // src/normalize-error.ts
408
- import { isSkeinHttpError, SkeinHttpError as SkeinHttpError3 } from "@skein-js/core";
726
+ import { isSkeinHttpError as isSkeinHttpError2, SkeinHttpError as SkeinHttpError3 } from "@skein-js/core";
409
727
  function serializeError(error) {
410
728
  if (error instanceof Error) {
411
729
  return { name: error.name, message: error.message };
@@ -435,6 +753,18 @@ function chunkToFrameBody(chunk) {
435
753
  function toRunFrame(seq, body) {
436
754
  return { seq, event: body.event, data: body.data };
437
755
  }
756
+ function streamEventToFrameBody(event, runId, graphModes) {
757
+ if (event.tags?.includes("langsmith:hidden")) return null;
758
+ if (event.event === "on_chain_stream" && event.run_id === runId) {
759
+ const chunk = event.data?.chunk;
760
+ if (Array.isArray(chunk) && chunk.length === 2 && isStreamMode(chunk[0])) {
761
+ const mode = chunk[0];
762
+ return graphModes.includes(mode) ? { event: mode, data: chunk[1] } : null;
763
+ }
764
+ return null;
765
+ }
766
+ return { event: "events", data: event };
767
+ }
438
768
 
439
769
  // src/store/skein-base-store.ts
440
770
  import {
@@ -601,7 +931,6 @@ function snapshotToThreadState(snapshot) {
601
931
  import { Command } from "@langchain/langgraph";
602
932
  function toGraphMode(mode) {
603
933
  if (mode === "messages-tuple") return "messages";
604
- if (mode === "events") return "updates";
605
934
  return mode;
606
935
  }
607
936
  function normalizeModes(mode) {
@@ -610,6 +939,12 @@ function normalizeModes(mode) {
610
939
  const deduped = [...new Set(mapped)];
611
940
  return deduped.length > 0 ? deduped : ["values"];
612
941
  }
942
+ function wantsEventsMode(mode) {
943
+ return normalizeModes(mode).includes("events");
944
+ }
945
+ function toGraphStreamModes(mode) {
946
+ return normalizeModes(mode).filter((m) => m !== "events");
947
+ }
613
948
  function toGraphInput(kwargs) {
614
949
  if (kwargs.command) {
615
950
  return new Command(kwargs.command);
@@ -662,7 +997,7 @@ function toGraphCallOptions(kwargs, threadId, signal) {
662
997
  kwargs.auth_user,
663
998
  kwargs.auth_scopes
664
999
  ),
665
- streamMode: normalizeModes(kwargs.stream_mode),
1000
+ streamMode: toGraphStreamModes(kwargs.stream_mode),
666
1001
  signal
667
1002
  };
668
1003
  if (kwargs.context !== void 0) options.context = kwargs.context;
@@ -701,7 +1036,10 @@ function extractToolActivity(data) {
701
1036
  if (Array.isArray(toolCalls)) {
702
1037
  for (const call of toolCalls) {
703
1038
  if (isRecord(call) && typeof call["name"] === "string" && call["name"].length > 0) {
704
- calls.push({ name: call["name"], id: typeof call["id"] === "string" ? call["id"] : void 0 });
1039
+ calls.push({
1040
+ name: call["name"],
1041
+ id: typeof call["id"] === "string" ? call["id"] : void 0
1042
+ });
705
1043
  }
706
1044
  }
707
1045
  }
@@ -740,6 +1078,11 @@ function describeInterrupts(snapshot) {
740
1078
  }
741
1079
 
742
1080
  // src/runs/run-engine.ts
1081
+ function abortedStatus(reason) {
1082
+ if (reason === "timeout") return "timeout";
1083
+ if (reason === "interrupt") return "interrupted";
1084
+ return "cancelled";
1085
+ }
743
1086
  async function resolveGraph(deps, graphId, kwargs) {
744
1087
  const resolved = await deps.graphs.load(graphId);
745
1088
  const graph = typeof resolved === "function" ? (
@@ -784,6 +1127,9 @@ async function executeRun(deps, exec) {
784
1127
  const runId = run.run_id;
785
1128
  const threadId = run.thread_id;
786
1129
  let seq = 0;
1130
+ let started = false;
1131
+ let outcome = { status: "error", values: {} };
1132
+ let webhookErrorMessage;
787
1133
  const startedAt = deps.clock().getTime();
788
1134
  const loggedTools = /* @__PURE__ */ new Set();
789
1135
  const logToolActivity = (data) => {
@@ -811,10 +1157,23 @@ async function executeRun(deps, exec) {
811
1157
  try {
812
1158
  const current = await deps.store.runs.get(runId);
813
1159
  if (current && isTerminalRunStatus2(current.status)) {
814
- return { status: current.status, values: {} };
1160
+ outcome = { status: current.status, values: {} };
1161
+ return outcome;
815
1162
  }
816
1163
  await deps.store.runs.setStatus(runId, "running");
1164
+ started = true;
817
1165
  await mirrorThreadStatus(deps, threadId, "busy");
1166
+ if (exec.recordBaseCheckpoint) {
1167
+ try {
1168
+ const tip = await deps.checkpointer.getTuple({ configurable: { thread_id: threadId } });
1169
+ exec.recordBaseCheckpoint(tip?.checkpoint.id);
1170
+ } catch (error) {
1171
+ deps.logger.warn(
1172
+ `run ${runId}: failed to read base checkpoint; a rollback of this run won't revert checkpoints`,
1173
+ error
1174
+ );
1175
+ }
1176
+ }
818
1177
  if (deps.logRunActivity) {
819
1178
  deps.logger.info(`run ${runId} started \xB7 assistant=${run.assistant_id} thread=${threadId}`);
820
1179
  }
@@ -825,20 +1184,36 @@ async function executeRun(deps, exec) {
825
1184
  const graph = await resolveGraph(deps, assistant.graph_id, kwargs);
826
1185
  const input = toGraphInput(kwargs);
827
1186
  const options = toGraphCallOptions(kwargs, threadId, control.signal);
828
- const stream = await graph.stream(input, options);
829
- for await (const chunk of stream) {
830
- seq += 1;
831
- const body = chunkToFrameBody(chunk);
832
- await deps.bus.publish(runId, toRunFrame(seq, body));
833
- if (deps.logRunActivity) logToolActivity(body.data);
1187
+ if (wantsEventsMode(kwargs.stream_mode)) {
1188
+ const graphModes = toGraphStreamModes(kwargs.stream_mode);
1189
+ const eventStream = graph.streamEvents(input, {
1190
+ ...options,
1191
+ version: "v2",
1192
+ runId
1193
+ });
1194
+ for await (const event of eventStream) {
1195
+ const body = streamEventToFrameBody(event, runId, graphModes);
1196
+ if (!body) continue;
1197
+ seq += 1;
1198
+ await deps.bus.publish(runId, toRunFrame(seq, body));
1199
+ if (deps.logRunActivity) logToolActivity(body.data);
1200
+ }
1201
+ } else {
1202
+ const stream = await graph.stream(input, options);
1203
+ for await (const chunk of stream) {
1204
+ seq += 1;
1205
+ const body = chunkToFrameBody(chunk);
1206
+ await deps.bus.publish(runId, toRunFrame(seq, body));
1207
+ if (deps.logRunActivity) logToolActivity(body.data);
1208
+ }
834
1209
  }
835
1210
  if (control.signal.aborted) {
836
- const timedOut = control.reason.current === "timeout";
837
- const finalStatus2 = await finalizeRun(deps, runId, timedOut ? "timeout" : "cancelled");
1211
+ const finalStatus2 = await finalizeRun(deps, runId, abortedStatus(control.reason.current));
838
1212
  if (finalStatus2 === "timeout") await mirrorThreadError(deps, threadId, "Run timed out.");
839
- else if (finalStatus2 === "cancelled") await mirrorThreadStatus(deps, threadId, "idle");
1213
+ else await mirrorThreadStatus(deps, threadId, "idle");
840
1214
  logFinished(finalStatus2);
841
- return { status: finalStatus2, values: {} };
1215
+ outcome = { status: finalStatus2, values: {} };
1216
+ return outcome;
842
1217
  }
843
1218
  const snapshot = await graph.getState({ configurable: { thread_id: threadId } });
844
1219
  const computed = runStatusForSnapshot(snapshot);
@@ -855,20 +1230,23 @@ async function executeRun(deps, exec) {
855
1230
  );
856
1231
  }
857
1232
  logFinished(finalStatus);
858
- return { status: finalStatus, values: snapshot.values };
1233
+ outcome = { status: finalStatus, values: snapshot.values };
1234
+ return outcome;
859
1235
  } catch (error) {
860
1236
  const reason = control.reason.current;
861
1237
  if (reason === "timeout") {
862
1238
  const finalStatus2 = await finalizeRun(deps, runId, "timeout");
863
1239
  if (finalStatus2 === "timeout") await mirrorThreadError(deps, threadId, "Run timed out.");
864
1240
  logFinished(finalStatus2);
865
- return { status: finalStatus2, values: {} };
1241
+ outcome = { status: finalStatus2, values: {} };
1242
+ return outcome;
866
1243
  }
867
- if (reason === "cancel" || control.signal.aborted) {
868
- const finalStatus2 = await finalizeRun(deps, runId, "cancelled");
869
- if (finalStatus2 === "cancelled") await mirrorThreadStatus(deps, threadId, "idle");
1244
+ if (reason === "cancel" || reason === "interrupt" || reason === "rollback" || control.signal.aborted) {
1245
+ const finalStatus2 = await finalizeRun(deps, runId, abortedStatus(reason));
1246
+ await mirrorThreadStatus(deps, threadId, "idle");
870
1247
  logFinished(finalStatus2);
871
- return { status: finalStatus2, values: {} };
1248
+ outcome = { status: finalStatus2, values: {} };
1249
+ return outcome;
872
1250
  }
873
1251
  const serialized = serializeError(error);
874
1252
  seq += 1;
@@ -877,10 +1255,71 @@ async function executeRun(deps, exec) {
877
1255
  if (finalStatus === "error") await mirrorThreadError(deps, threadId, serialized.message);
878
1256
  if (deps.logRunActivity) deps.logger.error(`run ${runId} error: ${serialized.message}`);
879
1257
  logFinished(finalStatus);
880
- return { status: finalStatus, values: {} };
1258
+ webhookErrorMessage = serialized.message;
1259
+ outcome = { status: finalStatus, values: {} };
1260
+ return outcome;
881
1261
  } finally {
882
1262
  if (timer !== void 0) clearTimeout(timer);
883
1263
  await deps.bus.close(runId);
1264
+ if (started && kwargs.webhook !== void 0) {
1265
+ const sentAt = deps.clock().toISOString();
1266
+ const payload = {
1267
+ ...run,
1268
+ status: outcome.status,
1269
+ values: outcome.values,
1270
+ run_started_at: new Date(startedAt).toISOString(),
1271
+ run_ended_at: sentAt,
1272
+ webhook_sent_at: sentAt,
1273
+ ...webhookErrorMessage !== void 0 ? { error: webhookErrorMessage } : {}
1274
+ };
1275
+ try {
1276
+ await deps.webhookDispatcher(kwargs.webhook, payload);
1277
+ } catch (error) {
1278
+ deps.logger.warn(`run ${runId}: webhook delivery to ${kwargs.webhook} failed`, error);
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ // src/runs/run-execution.ts
1285
+ async function startRunExecution(ctx, run, kwargs) {
1286
+ const { deps, control, executionLocks, runBaseCheckpoints, rollbackPlans } = ctx;
1287
+ const runId = run.run_id;
1288
+ const threadId = run.thread_id;
1289
+ const runControl = control.register(runId);
1290
+ try {
1291
+ return await executionLocks.run(threadId, async () => {
1292
+ const plan = rollbackPlans.get(runId);
1293
+ if (plan) {
1294
+ rollbackPlans.delete(runId);
1295
+ if (plan.revertToCheckpoint !== false) {
1296
+ try {
1297
+ await rollbackThreadCheckpointsTo(
1298
+ deps.checkpointer,
1299
+ threadId,
1300
+ plan.revertToCheckpoint.baseCheckpointId
1301
+ );
1302
+ } catch (error) {
1303
+ deps.logger.warn(`run ${runId}: rollback of displaced writes failed`, error);
1304
+ }
1305
+ }
1306
+ for (const displacedId of plan.displacedRunIds) {
1307
+ try {
1308
+ await deps.store.runs.delete(displacedId);
1309
+ } catch {
1310
+ }
1311
+ }
1312
+ }
1313
+ return await executeRun(deps, {
1314
+ run,
1315
+ kwargs,
1316
+ control: runControl,
1317
+ recordBaseCheckpoint: (base) => runBaseCheckpoints.set(runId, base)
1318
+ });
1319
+ });
1320
+ } finally {
1321
+ control.clear(runId);
1322
+ runBaseCheckpoints.delete(runId);
884
1323
  }
885
1324
  }
886
1325
 
@@ -894,6 +1333,7 @@ function toKwargs(input, authUser, authScopes) {
894
1333
  if (input.stream_mode !== void 0) kwargs.stream_mode = input.stream_mode;
895
1334
  if (input.interrupt_before !== void 0) kwargs.interrupt_before = input.interrupt_before;
896
1335
  if (input.interrupt_after !== void 0) kwargs.interrupt_after = input.interrupt_after;
1336
+ if (input.webhook !== void 0) kwargs.webhook = input.webhook;
897
1337
  if (authUser !== void 0) {
898
1338
  kwargs.auth_user = authUser;
899
1339
  if (authScopes !== void 0) kwargs.auth_scopes = authScopes;
@@ -901,7 +1341,7 @@ function toKwargs(input, authUser, authScopes) {
901
1341
  return kwargs;
902
1342
  }
903
1343
  function createRunService(ctx) {
904
- const { deps, control, locks, authUser, authScopes } = ctx;
1344
+ const { deps, control, locks, runBaseCheckpoints, authUser, authScopes } = ctx;
905
1345
  const requireThread = async (threadId) => {
906
1346
  const thread = await deps.store.threads.get(threadId);
907
1347
  if (!thread) throw SkeinHttpError5.notFound(`Thread "${threadId}" not found.`);
@@ -933,18 +1373,43 @@ function createRunService(ctx) {
933
1373
  const existing = await deps.store.threads.get(threadId);
934
1374
  return existing ?? await deps.store.threads.create({ thread_id: threadId });
935
1375
  };
1376
+ const cancelActiveRun = async (run, reason) => {
1377
+ const terminal = reason === "interrupt" ? "interrupted" : "cancelled";
1378
+ if (run.status === "pending") {
1379
+ await deps.store.runs.setStatus(run.run_id, terminal);
1380
+ await deps.bus.close(run.run_id);
1381
+ control.abort(run.run_id, reason);
1382
+ } else {
1383
+ await deps.store.runs.setStatus(run.run_id, terminal);
1384
+ control.abort(run.run_id, reason);
1385
+ }
1386
+ };
936
1387
  const createPendingRun = async (input, threadId, assistantId, kwargs) => {
937
1388
  return locks.run(threadId, async () => {
938
1389
  const strategy = input.multitask_strategy ?? "reject";
939
- if (await deps.store.runs.hasActiveRun(threadId)) {
940
- if (strategy !== "reject") {
941
- deps.logger.warn(`multitask_strategy "${strategy}" treated as reject (MVP).`);
1390
+ const active = await deps.store.runs.listActiveRuns(threadId);
1391
+ let rollbackPlan;
1392
+ if (active.length > 0) {
1393
+ if (strategy === "reject") {
1394
+ throw SkeinHttpError5.unprocessable(
1395
+ "Thread is already running a task. Wait for it to finish or choose a different multitask strategy.",
1396
+ { code: "thread_busy" }
1397
+ );
1398
+ }
1399
+ if (strategy === "interrupt") {
1400
+ for (const run of active) await cancelActiveRun(run, "interrupt");
1401
+ } else if (strategy === "rollback") {
1402
+ let revertToCheckpoint = false;
1403
+ for (const run of active) {
1404
+ if (runBaseCheckpoints.has(run.run_id)) {
1405
+ revertToCheckpoint = { baseCheckpointId: runBaseCheckpoints.get(run.run_id) };
1406
+ }
1407
+ }
1408
+ rollbackPlan = { revertToCheckpoint, displacedRunIds: active.map((run) => run.run_id) };
1409
+ for (const run of active) await cancelActiveRun(run, "rollback");
942
1410
  }
943
- throw SkeinHttpError5.conflict(`Thread "${threadId}" already has an active run.`, {
944
- code: "thread_busy"
945
- });
946
1411
  }
947
- return deps.store.runs.create({
1412
+ const created = await deps.store.runs.create({
948
1413
  thread_id: threadId,
949
1414
  assistant_id: assistantId,
950
1415
  status: "pending",
@@ -952,15 +1417,11 @@ function createRunService(ctx) {
952
1417
  multitask_strategy: strategy,
953
1418
  kwargs
954
1419
  });
1420
+ if (rollbackPlan) ctx.rollbackPlans.set(created.run_id, rollbackPlan);
1421
+ return created;
955
1422
  });
956
1423
  };
957
- const runInline = (run, kwargs) => {
958
- const runControl = control.register(run.run_id);
959
- const done = executeRun(deps, { run, kwargs, control: runControl }).finally(
960
- () => control.clear(run.run_id)
961
- );
962
- return done;
963
- };
1424
+ const runInline = (run, kwargs) => startRunExecution(ctx, run, kwargs);
964
1425
  return {
965
1426
  async createWait(input) {
966
1427
  const assistant = await requireAssistant(input.assistant_id);
@@ -1008,6 +1469,7 @@ function createRunService(ctx) {
1008
1469
  async cancel(runId) {
1009
1470
  const run = await deps.store.runs.get(runId);
1010
1471
  if (!run) throw SkeinHttpError5.notFound(`Run "${runId}" not found.`);
1472
+ ctx.rollbackPlans.delete(runId);
1011
1473
  if (isTerminalRunStatus3(run.status)) return run;
1012
1474
  if (run.status === "pending") {
1013
1475
  await deps.store.runs.setStatus(runId, "cancelled");
@@ -1026,6 +1488,7 @@ function createRunService(ctx) {
1026
1488
  throw SkeinHttpError5.notFound(`Run "${runId}" not found.`);
1027
1489
  }
1028
1490
  control.abort(runId, "cancel");
1491
+ ctx.rollbackPlans.delete(runId);
1029
1492
  await deps.store.runs.delete(runId);
1030
1493
  },
1031
1494
  async join(runId, afterSeq = 0) {
@@ -1064,50 +1527,10 @@ function createStoreService(deps) {
1064
1527
  }
1065
1528
 
1066
1529
  // src/threads/thread-service.ts
1067
- import {
1068
- copyCheckpoint
1069
- } from "@langchain/langgraph";
1070
1530
  import {
1071
1531
  isTerminalRunStatus as isTerminalRunStatus4,
1072
1532
  SkeinHttpError as SkeinHttpError7
1073
1533
  } from "@skein-js/core";
1074
- async function copyCheckpointHistory(checkpointer, sourceId, targetId) {
1075
- const tuples = [];
1076
- for await (const tuple of checkpointer.list({ configurable: { thread_id: sourceId } })) {
1077
- tuples.push(tuple);
1078
- }
1079
- for (const tuple of tuples.reverse()) {
1080
- const ns = tuple.config.configurable?.checkpoint_ns ?? "";
1081
- const parentId = tuple.parentConfig?.configurable?.checkpoint_id;
1082
- const putConfig = {
1083
- configurable: { thread_id: targetId, checkpoint_ns: ns, checkpoint_id: parentId }
1084
- };
1085
- await checkpointer.put(
1086
- putConfig,
1087
- copyCheckpoint(tuple.checkpoint),
1088
- tuple.metadata ?? {},
1089
- tuple.checkpoint.channel_versions
1090
- );
1091
- if (tuple.pendingWrites && tuple.pendingWrites.length > 0) {
1092
- const writeConfig = {
1093
- configurable: {
1094
- thread_id: targetId,
1095
- checkpoint_ns: ns,
1096
- checkpoint_id: tuple.checkpoint.id
1097
- }
1098
- };
1099
- const byTask = /* @__PURE__ */ new Map();
1100
- for (const [taskId, channel, value] of tuple.pendingWrites) {
1101
- const writes = byTask.get(taskId) ?? [];
1102
- writes.push([channel, value]);
1103
- byTask.set(taskId, writes);
1104
- }
1105
- for (const [taskId, writes] of byTask) {
1106
- await checkpointer.putWrites(writeConfig, writes, taskId);
1107
- }
1108
- }
1109
- }
1110
- }
1111
1534
  function emptyThreadState(threadId) {
1112
1535
  return {
1113
1536
  values: {},
@@ -1271,9 +1694,13 @@ function createThreadStreamService(ctx, runs) {
1271
1694
  // src/service.ts
1272
1695
  function buildProtocolService(ctx) {
1273
1696
  const runs = createRunService(ctx);
1697
+ const threads = createThreadService(ctx);
1274
1698
  return {
1275
- assistants: createAssistantService(ctx.deps),
1276
- threads: createThreadService(ctx),
1699
+ // The assistant service reuses the thread service for its `delete_threads` cascade (abort +
1700
+ // delete), and needs the full context (auth engine + caller) to scope that cascade to the
1701
+ // threads the caller may delete.
1702
+ assistants: createAssistantService(ctx, threads),
1703
+ threads,
1277
1704
  threadStream: createThreadStreamService(ctx, runs),
1278
1705
  runs,
1279
1706
  store: createStoreService(ctx.deps)
@@ -1386,7 +1813,15 @@ var ROUTE_AUTHZ = {
1386
1813
  // assistants
1387
1814
  getAssistant: { resource: "assistants", action: "read" },
1388
1815
  getAssistantSchemas: { resource: "assistants", action: "read" },
1816
+ getAssistantGraph: { resource: "assistants", action: "read" },
1817
+ getAssistantSubgraphs: { resource: "assistants", action: "read" },
1818
+ listAssistantVersions: { resource: "assistants", action: "read" },
1389
1819
  searchAssistants: { resource: "assistants", action: "search" },
1820
+ countAssistants: { resource: "assistants", action: "search" },
1821
+ createAssistant: { resource: "assistants", action: "create" },
1822
+ updateAssistant: { resource: "assistants", action: "update" },
1823
+ setAssistantLatestVersion: { resource: "assistants", action: "update" },
1824
+ deleteAssistant: { resource: "assistants", action: "delete" },
1390
1825
  // threads
1391
1826
  createThread: { resource: "threads", action: "create" },
1392
1827
  copyThread: { resource: "threads", action: "create" },
@@ -1501,17 +1936,18 @@ function createRunWorker(ctx, options = {}) {
1501
1936
  const inFlight = /* @__PURE__ */ new Set();
1502
1937
  const process = async (queued) => {
1503
1938
  const run = await deps.store.runs.get(queued.run_id);
1504
- if (!run || isTerminalRunStatus6(run.status)) return;
1939
+ if (!run || isTerminalRunStatus6(run.status)) {
1940
+ ctx.rollbackPlans.delete(queued.run_id);
1941
+ return;
1942
+ }
1505
1943
  const kwargs = await deps.store.runs.getKwargs(queued.run_id) ?? {};
1506
- const runControl = control.register(queued.run_id);
1507
1944
  inFlight.add(queued.run_id);
1508
1945
  const startedAt = Date.now();
1509
1946
  let status = "error";
1510
1947
  try {
1511
- status = (await executeRun(deps, { run, kwargs, control: runControl })).status;
1948
+ status = (await startRunExecution(ctx, run, kwargs)).status;
1512
1949
  } finally {
1513
1950
  logRunLifecycle(deps.logger, run, status, startedAt, Date.now());
1514
- control.clear(queued.run_id);
1515
1951
  inFlight.delete(queued.run_id);
1516
1952
  }
1517
1953
  };
@@ -1549,6 +1985,102 @@ function createProtocolRuntime(deps, options = {}) {
1549
1985
  worker: createRunWorker(context, options.worker)
1550
1986
  };
1551
1987
  }
1988
+
1989
+ // src/http/routes.ts
1990
+ var skeinRoutes = [
1991
+ // assistants — literals (search/count) before `:assistant_id`, and nested paths before the bare id.
1992
+ { method: "post", path: "/assistants/search", handler: "searchAssistants" },
1993
+ { method: "post", path: "/assistants/count", handler: "countAssistants" },
1994
+ { method: "post", path: "/assistants", handler: "createAssistant" },
1995
+ { method: "get", path: "/assistants/:assistant_id/schemas", handler: "getAssistantSchemas" },
1996
+ { method: "get", path: "/assistants/:assistant_id/graph", handler: "getAssistantGraph" },
1997
+ {
1998
+ method: "get",
1999
+ path: "/assistants/:assistant_id/subgraphs/:namespace",
2000
+ handler: "getAssistantSubgraphs"
2001
+ },
2002
+ { method: "get", path: "/assistants/:assistant_id/subgraphs", handler: "getAssistantSubgraphs" },
2003
+ { method: "post", path: "/assistants/:assistant_id/versions", handler: "listAssistantVersions" },
2004
+ {
2005
+ method: "post",
2006
+ path: "/assistants/:assistant_id/latest",
2007
+ handler: "setAssistantLatestVersion"
2008
+ },
2009
+ { method: "get", path: "/assistants/:assistant_id", handler: "getAssistant" },
2010
+ { method: "patch", path: "/assistants/:assistant_id", handler: "updateAssistant" },
2011
+ { method: "delete", path: "/assistants/:assistant_id", handler: "deleteAssistant" },
2012
+ // threads
2013
+ { method: "post", path: "/threads/search", handler: "listThreads" },
2014
+ { method: "post", path: "/threads", handler: "createThread" },
2015
+ { method: "post", path: "/threads/:thread_id/copy", handler: "copyThread" },
2016
+ { method: "get", path: "/threads/:thread_id", handler: "getThread" },
2017
+ { method: "patch", path: "/threads/:thread_id", handler: "patchThread" },
2018
+ { method: "delete", path: "/threads/:thread_id", handler: "deleteThread" },
2019
+ { method: "get", path: "/threads/:thread_id/state", handler: "getThreadState" },
2020
+ { method: "post", path: "/threads/:thread_id/history", handler: "getThreadHistory" },
2021
+ // runs — the stateless handlers are reused on the thread-scoped path with the id folded in
2022
+ { method: "post", path: "/runs/wait", handler: "createWaitRun" },
2023
+ { method: "post", path: "/runs/stream", handler: "createStreamRun" },
2024
+ {
2025
+ method: "post",
2026
+ path: "/threads/:thread_id/runs/wait",
2027
+ handler: "createWaitRun",
2028
+ foldThreadIdIntoBody: true
2029
+ },
2030
+ {
2031
+ method: "post",
2032
+ path: "/threads/:thread_id/runs/stream",
2033
+ handler: "createStreamRun",
2034
+ foldThreadIdIntoBody: true
2035
+ },
2036
+ { method: "post", path: "/threads/:thread_id/runs", handler: "createBackgroundRun" },
2037
+ { method: "get", path: "/threads/:thread_id/runs", handler: "listThreadRuns" },
2038
+ { method: "post", path: "/threads/:thread_id/runs/:run_id/cancel", handler: "cancelRun" },
2039
+ { method: "get", path: "/threads/:thread_id/runs/:run_id/stream", handler: "joinRunStream" },
2040
+ { method: "get", path: "/threads/:thread_id/runs/:run_id", handler: "getRun" },
2041
+ { method: "delete", path: "/threads/:thread_id/runs/:run_id", handler: "deleteRun" },
2042
+ { method: "get", path: "/runs/:run_id/stream", handler: "joinRunStream" },
2043
+ // thread streaming / commands
2044
+ { method: "post", path: "/threads/:thread_id/stream", handler: "postThreadStream" },
2045
+ { method: "get", path: "/threads/:thread_id/stream", handler: "getThreadStream" },
2046
+ { method: "post", path: "/threads/:thread_id/commands", handler: "postThreadCommands" },
2047
+ // store
2048
+ { method: "put", path: "/store/items", handler: "putStoreItem" },
2049
+ { method: "get", path: "/store/items", handler: "getStoreItem" },
2050
+ { method: "delete", path: "/store/items", handler: "deleteStoreItem" },
2051
+ { method: "post", path: "/store/items/search", handler: "searchStoreItems" },
2052
+ { method: "post", path: "/store/namespaces", handler: "listStoreNamespaces" }
2053
+ ];
2054
+ function foldThreadId(request) {
2055
+ const threadId = request.params["thread_id"];
2056
+ if (threadId === void 0) return request;
2057
+ const base = typeof request.body === "object" && request.body !== null && !Array.isArray(request.body) ? request.body : {};
2058
+ return { ...request, body: { ...base, thread_id: threadId } };
2059
+ }
2060
+ var compiledRoutes = skeinRoutes.map((binding) => ({
2061
+ binding,
2062
+ regex: new RegExp(`^${binding.path.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`)
2063
+ }));
2064
+ function matchSkeinRoute(method, pathname) {
2065
+ const wanted = method.toLowerCase();
2066
+ for (const { binding, regex } of compiledRoutes) {
2067
+ if (binding.method !== wanted) continue;
2068
+ const match = regex.exec(pathname);
2069
+ if (match) return { binding, params: decodeParams(match.groups) };
2070
+ }
2071
+ return void 0;
2072
+ }
2073
+ function decodeParams(groups) {
2074
+ const params = {};
2075
+ for (const [key, value] of Object.entries(groups ?? {})) {
2076
+ try {
2077
+ params[key] = decodeURIComponent(value);
2078
+ } catch {
2079
+ params[key] = value;
2080
+ }
2081
+ }
2082
+ return params;
2083
+ }
1552
2084
  export {
1553
2085
  SSE_HEADERS,
1554
2086
  SkeinBaseStore,
@@ -1560,6 +2092,9 @@ export {
1560
2092
  createRunWorker,
1561
2093
  encodeFrame,
1562
2094
  encodeTerminal,
2095
+ foldThreadId,
2096
+ matchSkeinRoute,
1563
2097
  parseAfterSeq,
2098
+ skeinRoutes,
1564
2099
  toSseEvents
1565
2100
  };
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@skein-js/agent-protocol",
3
- "version": "0.5.0",
3
+ "version": "0.6.3",
4
4
  "description": "Framework-agnostic Agent Protocol engine for LangGraph.js — run engine, handlers, and SSE, driven entirely by injected dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Maina Wycliffe <wmmaina7@gmail.com>",
7
- "homepage": "https://github.com/mainawycliffe/skein-js/tree/main/packages/agent-protocol#readme",
7
+ "homepage": "https://github.com/skein-js/skein-js/tree/main/packages/agent-protocol#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/mainawycliffe/skein-js.git",
10
+ "url": "git+https://github.com/skein-js/skein-js.git",
11
11
  "directory": "packages/agent-protocol"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/mainawycliffe/skein-js/issues"
14
+ "url": "https://github.com/skein-js/skein-js/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "typescript",
@@ -44,14 +44,14 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "zod": "^3.25.76",
47
- "@skein-js/core": "0.5.0"
47
+ "@skein-js/core": "0.6.3"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@langchain/langgraph": "^1.4.0",
51
51
  "@langchain/langgraph-sdk": "^1.9.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@skein-js/storage-memory": "0.5.0"
54
+ "@skein-js/storage-memory": "0.6.3"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public"