@loopingai/core 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -229,7 +229,6 @@ not drag in the A2A adapter, and the test harness cannot reach a production bund
229
229
  | `@loopingai/core/agent` | session, history, models + Workers AI, inference, budget, control tools |
230
230
  | `@loopingai/core/host` | `LoopingAgent` — the Durable Object body — and `PluginHost` |
231
231
  | `@loopingai/core/round` | the delegating round loop: `RoundAgentBase`, `runHandleTask`, `runTurn` |
232
- | `@loopingai/core/anthropic` | Claude as a second provider — _optional peer on `@anthropic-ai/sdk`_ |
233
232
  | `@loopingai/core/subtasks` | delegation types, decomposition, the `delegate` tool |
234
233
  | `@loopingai/core/subagent` | `RecipeSubagentBase`, resumable runs, fingerprinting, workspace |
235
234
  | `@loopingai/core/db` | `AgentDB`, `notify_tasks` + `subtasks` schema, migrations, `PluginStore` |
@@ -271,8 +270,9 @@ performs. A gateway pins the card's `kid` + `jku` on first registration
271
270
 
272
271
  ### Calling out, and knowing your own origin
273
272
 
274
- The same key proves this agent to services that are not the gateway — an inference
275
- proxy, another agent. `signCallerToken` mints the short-lived token for that: `iss` is
273
+ The same key proves this agent to services that are not the gateway — another agent,
274
+ or any service that verifies against the published JWKS. `signCallerToken` mints the
275
+ short-lived token for that: `iss` is
276
276
  this deployment's origin, `jku` is derived from it, and the audience is normalized to a
277
277
  bare origin because the far side compares it byte-for-byte.
278
278
 
@@ -499,7 +499,6 @@ await withDb("accepts a turn once", async (db) => {
499
499
  - **Bindings:** `AI`, one Durable Object, one Workflow
500
500
  - **Secrets:** `A2A_SIGNING_KEY`, `GATEWAY_ORIGINS`
501
501
  - **Peers, never bundled:** `agents`, `ai`, `workers-ai-provider`
502
- - **Optional peer:** `@anthropic-ai/sdk`, needed only by `@loopingai/core/anthropic`
503
502
 
504
503
  That last point is not stylistic: two copies of `agents` in one Worker breaks the
505
504
  `Session` / `SessionMessage` types and every `instanceof`. For local development
@@ -15,10 +15,10 @@
15
15
  * The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
16
16
  * restates a value the request already carries, and it has to be kept
17
17
  * byte-identical by hand with the origin allowlist on the far side, in every
18
- * environment, forever. Both siblings that tried it took it back out:
19
- * `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
20
- * and `looping-gateway` discovers its own origin from the first
21
- * signature-verified request rather than being told.
18
+ * environment, forever. Every sibling that tried it took it back out
19
+ * `looping-gateway` discovers its own origin from the first signature-verified
20
+ * request rather than being told, and a verifying Worker that once carried a
21
+ * configured audience replaced it with `url.origin`.
22
22
  *
23
23
  * ## Where the value comes from
24
24
  *
@@ -51,9 +51,9 @@
51
51
  * The cost of pinning is what an agent does not have: several identities. An
52
52
  * agent has one endpoint — the one its card advertises, the one a gateway calls
53
53
  * and a verifier allowlists — so there is nothing to follow. Note the asymmetry
54
- * with `looping-anthropic-proxy`, which derives its audience per request and
55
- * refuses to cache: a *verifier* must accept every hostname it answers on, while
56
- * a *signer* needs one stable identity.
54
+ * with a *verifier*, which derives the audience it expects per request and must
55
+ * not cache it: a verifier has to accept every hostname it answers on, while a
56
+ * signer needs one stable identity.
57
57
  *
58
58
  * Nothing is persisted, which is what keeps a pin from outliving its truth. An
59
59
  * isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
@@ -15,10 +15,10 @@
15
15
  * The obvious answer is a `SELF_ORIGIN` secret, and it is the wrong one. It
16
16
  * restates a value the request already carries, and it has to be kept
17
17
  * byte-identical by hand with the origin allowlist on the far side, in every
18
- * environment, forever. Both siblings that tried it took it back out:
19
- * `looping-anthropic-proxy` deleted `PROXY_AUDIENCE` in favour of `url.origin`,
20
- * and `looping-gateway` discovers its own origin from the first
21
- * signature-verified request rather than being told.
18
+ * environment, forever. Every sibling that tried it took it back out
19
+ * `looping-gateway` discovers its own origin from the first signature-verified
20
+ * request rather than being told, and a verifying Worker that once carried a
21
+ * configured audience replaced it with `url.origin`.
22
22
  *
23
23
  * ## Where the value comes from
24
24
  *
@@ -51,9 +51,9 @@
51
51
  * The cost of pinning is what an agent does not have: several identities. An
52
52
  * agent has one endpoint — the one its card advertises, the one a gateway calls
53
53
  * and a verifier allowlists — so there is nothing to follow. Note the asymmetry
54
- * with `looping-anthropic-proxy`, which derives its audience per request and
55
- * refuses to cache: a *verifier* must accept every hostname it answers on, while
56
- * a *signer* needs one stable identity.
54
+ * with a *verifier*, which derives the audience it expects per request and must
55
+ * not cache it: a verifier has to accept every hostname it answers on, while a
56
+ * signer needs one stable identity.
57
57
  *
58
58
  * Nothing is persisted, which is what keeps a pin from outliving its truth. An
59
59
  * isolate is fresh on every `wrangler deploy` and recycles on its own, so a moved
@@ -6,7 +6,7 @@
6
6
  * any particular one exists. `nonRecoverableKind` in
7
7
  * {@link file://./inference.ts inference.ts} keys on this file, so a provider
8
8
  * written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
9
- * gets the same handling as `agent/anthropic` with no change to core.
9
+ * gets the same handling as core's own, with no change to core.
10
10
  *
11
11
  * ## Why a third classification was needed at all
12
12
  *
@@ -24,26 +24,26 @@
24
24
  /**
25
25
  * Who refused the request, when a `401` came back.
26
26
  *
27
- * There can be up to three authorities on the path, each with its own
28
- * credential: the AI Gateway (`cf-aig-authorization`), an optional intermediary
29
- * a deployment puts between the gateway and the provider (`"proxy"`), and the
30
- * model provider itself (`Authorization`). They fail with the same status code
31
- * and completely different remedies, so a rejection that does not say which one
32
- * it was sends an operator to rotate the wrong secret — which is exactly what
33
- * happened before this existed.
34
- *
35
- * `"proxy"` is the odd one out: it is generally *not* a secret to rotate. An
36
- * intermediary that mints its caller credential per request fails for reasons
37
- * upstream of any stored secret — configuration drift, a rotated signing key,
38
- * clock skew — so the remedy is to look, not to rotate. Core recognises no
39
- * particular intermediary; a deployment that has one supplies its own classifier
40
- * (see `AnthropicModelDeps.classifyAuthFailure`).
27
+ * Two authorities sit on the path, each with its own credential: the AI Gateway
28
+ * (`cf-aig-authorization`) and the model provider itself (`Authorization`). They
29
+ * fail with the same status code and have completely different remedies, so a
30
+ * rejection that does not say which one it was sends an operator to rotate the
31
+ * wrong secret which is exactly what happened before this existed.
41
32
  *
42
33
  * `"unknown"` is a real answer and the default. Guessing `"provider"` for an
43
34
  * unrecognised body is how the misdiagnosis happens; saying "one of these, here
44
35
  * is how to check each" is worse copy and better information.
36
+ *
37
+ * A third arm, `"proxy"`, named an optional intermediary between the two — the
38
+ * shape where a deployment terminates the gateway's request at its own Worker to
39
+ * attach a credential. It was removed in 0.8.0 with the deployment that had one.
40
+ * If you build that topology again, the honest classification for its refusals
41
+ * is `"unknown"` until you widen this union, because the remedy genuinely
42
+ * differs: an intermediary minting its caller credential per request fails for
43
+ * reasons upstream of any stored secret, so the fix is to look rather than to
44
+ * rotate.
45
45
  */
46
- export type CredentialRejectedBy = "provider" | "gateway" | "proxy" | "unknown";
46
+ export type CredentialRejectedBy = "provider" | "gateway" | "unknown";
47
47
  /**
48
48
  * A credential on the path to the model was rejected (HTTP 401 / 403).
49
49
  *
@@ -6,7 +6,7 @@
6
6
  * any particular one exists. `nonRecoverableKind` in
7
7
  * {@link file://./inference.ts inference.ts} keys on this file, so a provider
8
8
  * written outside core — the thing `ModelRuntimeFactory` exists to make cheap —
9
- * gets the same handling as `agent/anthropic` with no change to core.
9
+ * gets the same handling as core's own, with no change to core.
10
10
  *
11
11
  * ## Why a third classification was needed at all
12
12
  *
@@ -49,14 +49,16 @@ export declare function isTransientAiError(err: unknown): boolean;
49
49
  * - `gateway-credential` — the gateway *in front of* the provider rejected the
50
50
  * request, which the provider therefore never saw. Rotate the gateway's token
51
51
  * instead; the model credential is very likely fine.
52
- * - `proxy-credential` — an intermediary between the gateway and the provider
53
- * rejected the caller. Notably **not** a token to rotate: the credential it
54
- * refused is minted per request, so the fault is upstream of the secret —
55
- * configuration drift, a rotated signing key, or clock skew.
56
52
  * - `unknown-credential` — a `401`/`403` matching none of the shapes. Says so,
57
53
  * rather than picking one and sending an operator to rotate a working secret.
54
+ *
55
+ * A fourth, `proxy-credential`, was removed in 0.8.0 along with
56
+ * {@link file://./errors.ts CredentialRejectedBy}'s `"proxy"` arm. Adding a kind
57
+ * back is a breaking change for every consumer, because the `Record` they map it
58
+ * with is total — which is the property that makes a new kind impossible to
59
+ * ignore, and the reason to remove one rather than leave it unreachable.
58
60
  */
59
- export type NonRecoverableKind = "credential" | "gateway-credential" | "proxy-credential" | "unknown-credential";
61
+ export type NonRecoverableKind = "credential" | "gateway-credential" | "unknown-credential";
60
62
  /**
61
63
  * Why a round ended with no answer — one terminal status, two situations.
62
64
  *
@@ -80,8 +80,6 @@ export function nonRecoverableKind(err) {
80
80
  return "credential";
81
81
  case "gateway":
82
82
  return "gateway-credential";
83
- case "proxy":
84
- return "proxy-credential";
85
83
  // Includes an error that crossed a realm boundary carrying no `source` at
86
84
  // all: `isInstance` is structural, so that is reachable, and "unknown" is
87
85
  // the honest reading of it.
@@ -10,11 +10,17 @@ import type { ModelConfig } from "../config.js";
10
10
  * more than a `LanguageModel` from `ai`. It never learns which provider produced
11
11
  * it.
12
12
  *
13
- * The implementations are siblings under `agent/`, one directory each:
14
- * {@link file://./workers-ai/index.ts `./workers-ai`} is core's default, and
15
- * {@link file://./anthropic/index.ts `./anthropic`} is Claude behind an optional
16
- * peer dependency. A third provider is a third directory exporting one
17
- * {@link ModelRuntimeFactory}; nothing here has to change to admit it.
13
+ * Core ships exactly one implementation
14
+ * {@link file://./workers-ai/index.ts `./workers-ai`} as a sibling directory
15
+ * under `agent/`. A second provider is a second directory exporting one
16
+ * {@link ModelRuntimeFactory}, and nothing here has to change to admit it;
17
+ * a consumer can equally supply one from outside the package, which is the
18
+ * point of the seam.
19
+ *
20
+ * There was a `./anthropic` sibling until 0.8.0 — a hand-written Messages API
21
+ * adapter behind an optional peer dependency. It was removed with the only
22
+ * deployment that used it. Nothing about this contract changed when it went,
23
+ * which is the strongest thing that can be said for the contract.
18
24
  *
19
25
  * Which is why this file has no runtime imports at all. The Workers AI factory
20
26
  * used to live in it, and a contract that ships one implementation inline reads
@@ -1,23 +1,23 @@
1
1
  /**
2
2
  * Workers AI — core's default model provider.
3
3
  *
4
- * A sibling of {@link file://../anthropic/index.ts `./anthropic`}, and like it a
5
- * provider rather than a capability: it ships no tools, no prompt copy and no
4
+ * A provider rather than a capability: it ships no tools, no prompt copy and no
6
5
  * policy; it satisfies {@link file://../model.ts ModelRuntime} and stops.
7
6
  *
8
- * It differs from that sibling in two ways, both deliberate:
7
+ * Two things about its shape are worth stating, because both look like
8
+ * omissions:
9
9
  *
10
10
  * **No `LanguageModel` adapter.** `workers-ai-provider` ships one, so this
11
- * directory is a factory and nothing else. The Anthropic side hand-builds the
12
- * adapter the prompt mapping, the cache control, the error taxonomy — which is
13
- * why it is five files and this is one.
11
+ * directory is a factory and nothing else one file rather than the five a
12
+ * hand-written adapter costs (a prompt mapping, a cache-control policy, an
13
+ * error taxonomy). That is what makes it the cheap default.
14
14
  *
15
- * **No package subpath.** `/anthropic` has one because `@anthropic-ai/sdk` is an
16
- * *optional* peer and an agent on Workers AI must not pay for a provider it
17
- * never calls. That argument does not apply here: `workers-ai-provider` is a
18
- * required peer, and this is the default every un-overridden seam reaches
19
- * through `LoopingAgent` itself, so it is in every consumer's module graph
20
- * already. It is exported from `@loopingai/core/agent` one symbol, one import
21
- * path.
15
+ * **No package subpath.** `workers-ai-provider` is a *required* peer and this is
16
+ * the default every un-overridden seam reaches through `LoopingAgent` itself, so
17
+ * it is in every consumer's module graph already and a subpath would buy
18
+ * nothing. It is exported from `@loopingai/core/agent` one symbol, one import
19
+ * path. A provider behind an *optional* peer would want its own subpath instead,
20
+ * so that an agent never calling it does not pay for it; `./anthropic` was one
21
+ * until 0.8.0.
22
22
  */
23
23
  export { createWorkersAIModelRuntime, workersAIModels, type WorkersAIRuntimeDeps } from "./runtime.js";
@@ -1,23 +1,23 @@
1
1
  /**
2
2
  * Workers AI — core's default model provider.
3
3
  *
4
- * A sibling of {@link file://../anthropic/index.ts `./anthropic`}, and like it a
5
- * provider rather than a capability: it ships no tools, no prompt copy and no
4
+ * A provider rather than a capability: it ships no tools, no prompt copy and no
6
5
  * policy; it satisfies {@link file://../model.ts ModelRuntime} and stops.
7
6
  *
8
- * It differs from that sibling in two ways, both deliberate:
7
+ * Two things about its shape are worth stating, because both look like
8
+ * omissions:
9
9
  *
10
10
  * **No `LanguageModel` adapter.** `workers-ai-provider` ships one, so this
11
- * directory is a factory and nothing else. The Anthropic side hand-builds the
12
- * adapter the prompt mapping, the cache control, the error taxonomy — which is
13
- * why it is five files and this is one.
11
+ * directory is a factory and nothing else one file rather than the five a
12
+ * hand-written adapter costs (a prompt mapping, a cache-control policy, an
13
+ * error taxonomy). That is what makes it the cheap default.
14
14
  *
15
- * **No package subpath.** `/anthropic` has one because `@anthropic-ai/sdk` is an
16
- * *optional* peer and an agent on Workers AI must not pay for a provider it
17
- * never calls. That argument does not apply here: `workers-ai-provider` is a
18
- * required peer, and this is the default every un-overridden seam reaches
19
- * through `LoopingAgent` itself, so it is in every consumer's module graph
20
- * already. It is exported from `@loopingai/core/agent` one symbol, one import
21
- * path.
15
+ * **No package subpath.** `workers-ai-provider` is a *required* peer and this is
16
+ * the default every un-overridden seam reaches through `LoopingAgent` itself, so
17
+ * it is in every consumer's module graph already and a subpath would buy
18
+ * nothing. It is exported from `@loopingai/core/agent` one symbol, one import
19
+ * path. A provider behind an *optional* peer would want its own subpath instead,
20
+ * so that an agent never calling it does not pay for it; `./anthropic` was one
21
+ * until 0.8.0.
22
22
  */
23
23
  export { createWorkersAIModelRuntime, workersAIModels } from "./runtime.js";
package/dist/config.d.ts CHANGED
@@ -27,21 +27,6 @@ export interface ModelConfig {
27
27
  fallbackChatModelId: string;
28
28
  /** AI Gateway slug; `"default"` auto-provisions on first request. */
29
29
  aiGatewayId: string;
30
- /**
31
- * The segment after the gateway slug in `.../{gateway}/{provider}` — either a
32
- * provider-native path like `"anthropic"` or a
33
- * [custom provider](https://developers.cloudflare.com/ai-gateway/configuration/custom-providers/)
34
- * slug. Core never reads it; a `ModelRuntime` that builds its own base URL
35
- * does.
36
- *
37
- * Config rather than a literal at that call site because the parent agent and
38
- * its subagent facet resolve their config independently, and a value that
39
- * drifts between them routes delegated subtasks through a different provider
40
- * than the round that delegated them — silently, since both satisfy
41
- * `ModelRuntime`. Which path to pick, and why, belongs with the agent that
42
- * picks it.
43
- */
44
- aiGatewayProvider: string;
45
30
  /**
46
31
  * Output-token ceiling for every chat call. Left unset, the binding applies a
47
32
  * per-model default which a reasoning model spends on `reasoning_content`
@@ -71,9 +56,9 @@ export interface ModelConfig {
71
56
  * The AI SDK does the waiting, and it does it properly:
72
57
  * `retryWithExponentialBackoffRespectingRetryHeaders` honours `retry-after-ms`
73
58
  * and `retry-after` and falls back to exponential backoff. It only fires for
74
- * an `APICallError` carrying `isRetryable`, which is why
75
- * {@link file://./agent/anthropic/language-model.ts} maps provider errors into
76
- * that shape rather than rethrowing them raw.
59
+ * an `APICallError` carrying `isRetryable`, so a provider written outside core
60
+ * has to map its errors into that shape rather than rethrowing them raw, or
61
+ * none of this fires for it.
77
62
  *
78
63
  * Bounded at 4 by `resolveConfig`. The retries happen *inside*
79
64
  * `step.do("turn:<round>")`, and the SDK caps a single honoured `retry-after`
package/dist/config.js CHANGED
@@ -32,10 +32,6 @@ export const DEFAULT_CORE_CONFIG = {
32
32
  // Not a model: an AI Gateway slug, and `"default"` is Cloudflare's own
33
33
  // auto-provision behaviour rather than a choice core is making for anyone.
34
34
  aiGatewayId: "default",
35
- // The provider-native Anthropic endpoint, which is what a deployment with
36
- // no custom provider registered has. Agents that must own the credential
37
- // themselves override this with their `custom-*` slug.
38
- aiGatewayProvider: "anthropic",
39
35
  maxOutputTokens: 16_384,
40
36
  reasoningEffort: "medium",
41
37
  // Two, which is the AI SDK's own default and enough to ride out the kind of
@@ -118,12 +118,11 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
118
118
  * a trap that only shows up as a performance bug. This is called once.
119
119
  *
120
120
  * `ModelRuntime` is the whole contract: return anything satisfying it and
121
- * every loop in core keeps working unchanged. Core ships two implementations,
122
- * one directory each — {@link file://../agent/workers-ai/index.ts
123
- * `agent/workers-ai`} (the default below) and `@loopingai/core/anthropic`
124
- * and a third provider is a third directory exporting one
125
- * {@link file://../agent/model.ts ModelRuntimeFactory}, not a change to
126
- * anything on this path.
121
+ * every loop in core keeps working unchanged. Core ships one implementation,
122
+ * {@link file://../agent/workers-ai/index.ts `agent/workers-ai`} (the default
123
+ * below); a second provider is one more
124
+ * {@link file://../agent/model.ts ModelRuntimeFactory}, defined here or in the
125
+ * consumer, not a change to anything on this path.
127
126
  *
128
127
  * Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
129
128
  * that this signature matches
@@ -125,12 +125,11 @@ export class LoopingAgent extends Agent {
125
125
  * a trap that only shows up as a performance bug. This is called once.
126
126
  *
127
127
  * `ModelRuntime` is the whole contract: return anything satisfying it and
128
- * every loop in core keeps working unchanged. Core ships two implementations,
129
- * one directory each — {@link file://../agent/workers-ai/index.ts
130
- * `agent/workers-ai`} (the default below) and `@loopingai/core/anthropic`
131
- * and a third provider is a third directory exporting one
132
- * {@link file://../agent/model.ts ModelRuntimeFactory}, not a change to
133
- * anything on this path.
128
+ * every loop in core keeps working unchanged. Core ships one implementation,
129
+ * {@link file://../agent/workers-ai/index.ts `agent/workers-ai`} (the default
130
+ * below); a second provider is one more
131
+ * {@link file://../agent/model.ts ModelRuntimeFactory}, defined here or in the
132
+ * consumer, not a change to anything on this path.
134
133
  *
135
134
  * Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
136
135
  * that this signature matches
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
3
+ *
4
+ * **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
5
+ * `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
6
+ * Neither depends on the other's reason for existing, and both are useful to a
7
+ * plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
8
+ * are subpaths and not part of the agent machinery.
9
+ *
10
+ * **Mechanism only.** Nothing here knows what a job *does*: no command, no
11
+ * container, no filesystem, no vendor library. A consumer supplies the handle
12
+ * and the meaning; this supplies the four rules that are wrong in the same way
13
+ * every time — arming before the work starts, one job at a time, a drain that
14
+ * can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
15
+ *
16
+ * Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
17
+ * with its own lifecycle, its own guarded writes and its own table — and two
18
+ * unrelated meanings in one namespace is a cost paid forever by every reader.
19
+ */
20
+ export { isRearmable, isRunning, type DoneJob, type FailedJob, type IdleJob, type JobState, type RunningJob, type SkippedJob } from "./state.js";
21
+ export { JobLifecycle, type JobContext, type JobHandle, type JobLifecycleOptions, type JobResult } from "./lifecycle.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
3
+ *
4
+ * **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
5
+ * `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
6
+ * Neither depends on the other's reason for existing, and both are useful to a
7
+ * plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
8
+ * are subpaths and not part of the agent machinery.
9
+ *
10
+ * **Mechanism only.** Nothing here knows what a job *does*: no command, no
11
+ * container, no filesystem, no vendor library. A consumer supplies the handle
12
+ * and the meaning; this supplies the four rules that are wrong in the same way
13
+ * every time — arming before the work starts, one job at a time, a drain that
14
+ * can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
15
+ *
16
+ * Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
17
+ * with its own lifecycle, its own guarded writes and its own table — and two
18
+ * unrelated meanings in one namespace is a cost paid forever by every reader.
19
+ */
20
+ export { isRearmable, isRunning } from "./state.js";
21
+ export { JobLifecycle } from "./lifecycle.js";
@@ -0,0 +1,176 @@
1
+ import type { WakeMap } from "../alarm/index.js";
2
+ import { type JobState, type RunningJob } from "./state.js";
3
+ /**
4
+ * The choreography around a long job a Durable Object owns through its alarm.
5
+ *
6
+ * The job itself — what command, where, and what its output means — belongs to
7
+ * the owner. What lives here is the part that is the same every time and is
8
+ * wrong in the same four ways every time:
9
+ *
10
+ * 1. **Arming writes `running` before anything runs.** The alarm has not fired
11
+ * yet, and a `done` record in that window lets a gated caller through against
12
+ * a workspace that is not ready. Writing `running` first also makes arming
13
+ * self-limiting: the next call sees it and stops.
14
+ * 2. **One job at a time**, guarded by a read that goes *through* the staleness
15
+ * bound — so a `running` record left by a dead isolate resolves rather than
16
+ * blocking every retry forever.
17
+ * 3. **A drain can outlive the job it watched.** `ctx.waitUntil` keeps running
18
+ * after the RPC returns, and a late drain writing its verdict over a record
19
+ * describing a *live* job is silent corruption. {@link generation} is the
20
+ * marker that makes it harmless.
21
+ * 4. **Nobody may be draining at all.** A watch intent re-attaches to a job
22
+ * whose isolate went away mid-flight.
23
+ *
24
+ * What is deliberately *not* here is the drain loop. Two real consumers want
25
+ * different ones — an install runs to completion under `waitUntil` and writes a
26
+ * single verdict; a coding-agent run is drained in bounded windows and reports
27
+ * partial progress between them. They share the four rules above and nothing
28
+ * below them, so the loop stays with the owner.
29
+ *
30
+ * ## Storage keys
31
+ *
32
+ * Derived from {@link JobLifecycleOptions.id} so one object can own several
33
+ * jobs. For `id: "install"` they come out as `install`, `install:armed`,
34
+ * `install:last-armed`, `install:context`, and the wake intents `install-run`
35
+ * and `install-watch` — the exact keys the predecessor wrote by hand, which is
36
+ * why adopting this needs no storage migration.
37
+ */
38
+ /** What a job's result looks like to the lifecycle. Deliberately minimal. */
39
+ export interface JobResult {
40
+ exitCode: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }
44
+ /** A running command, reduced to what the lifecycle needs of it. */
45
+ export interface JobHandle {
46
+ result(): Promise<JobResult>;
47
+ [Symbol.dispose](): void;
48
+ }
49
+ /**
50
+ * The per-job record naming what is running and *which* run it is.
51
+ *
52
+ * `startedAt` is the generation marker, so it is the one required field: a drain
53
+ * compares the stamp it captured against the stamp on disk, and a mismatch means
54
+ * it has been superseded and has nothing useful left to say.
55
+ */
56
+ export interface JobContext {
57
+ startedAt: number;
58
+ }
59
+ export interface JobLifecycleOptions {
60
+ /** Namespaces every key and intent. Also the state record's own key. */
61
+ id: string;
62
+ storage: DurableObjectStorage;
63
+ wake: WakeMap;
64
+ /**
65
+ * How long a `running` record may stand before it is presumed dead.
66
+ *
67
+ * Measured from `startedAt` and compared against the job's own timeout plus
68
+ * this, never against this alone — the point is to outlast a job that is
69
+ * merely slow, and only then to declare one that is gone.
70
+ */
71
+ staleMs?: number;
72
+ /** How often the watch intent re-checks a job nobody is draining. */
73
+ watchMs?: number;
74
+ /**
75
+ * The floor between two arming attempts.
76
+ *
77
+ * Without it a job that cannot start re-arms on every call into the object.
78
+ */
79
+ armCooldownMs?: number;
80
+ }
81
+ export declare class JobLifecycle<TExtra extends object = Record<never, never>, TContext extends JobContext = JobContext> {
82
+ #private;
83
+ /** `install` — the state record. */
84
+ readonly stateKey: string;
85
+ /** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
86
+ readonly armedKey: string;
87
+ /** `install:last-armed` — the cooldown floor. */
88
+ readonly lastArmedKey: string;
89
+ /** `install:context` — where the generation marker lives. */
90
+ readonly contextKey: string;
91
+ /** `install-run` — the intent that *runs* a job. */
92
+ readonly runIntent: string;
93
+ /** `install-watch` — the intent that re-attaches to one nobody is draining. */
94
+ readonly watchIntent: string;
95
+ constructor(options: JobLifecycleOptions);
96
+ /** The raw record, with no staleness repair. `idle` when nothing is written. */
97
+ read(): Promise<JobState<TExtra>>;
98
+ write(state: JobState<TExtra>): Promise<void>;
99
+ context(): Promise<TContext | undefined>;
100
+ /**
101
+ * Record which run this is, **before** spawning.
102
+ *
103
+ * The order is the whole point: a drain captures `startedAt` after the spawn,
104
+ * so a context written afterwards would let two runs share a generation.
105
+ */
106
+ putContext(context: TContext): Promise<void>;
107
+ /**
108
+ * Hand a cold job to the alarm, if one is not already pending.
109
+ *
110
+ * Returns the stamp it armed with, or `undefined` when it declined — the
111
+ * caller needs the stamp because it is what the alarm must present to
112
+ * {@link claim} to get past the single-flight guard.
113
+ *
114
+ * An arming caller must **not** own the run. The predecessor handed one to
115
+ * `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
116
+ * drain was disposed underneath it mid-command. An alarm invocation belongs to
117
+ * the object rather than to any request, so nothing it awaits can be cut short.
118
+ */
119
+ arm(placeholder: Omit<RunningJob<TExtra>, "state" | "startedAt">): Promise<number | undefined>;
120
+ /** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
121
+ armedAt(): Promise<number | undefined>;
122
+ clearArmed(): Promise<void>;
123
+ /**
124
+ * Decide whether a new run may start.
125
+ *
126
+ * `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
127
+ * alarm's placeholder *is* a `running` record for a job that has not started,
128
+ * so the alarm has to pass its own guard — and only its own. Matching the
129
+ * exact stamp it wrote is what stops this becoming "take over any running
130
+ * job", which is the displacement bug the guard exists to prevent: three
131
+ * callers spawning under one exec id in fifty seconds, each displacing the
132
+ * last, every displaced drain still attached and still writing verdicts.
133
+ *
134
+ * Applies the staleness bound **itself**, rather than trusting the caller to
135
+ * have repaired the record first. An earlier draft took an
136
+ * "already-repaired" state and said so in prose, which enforced nothing: the
137
+ * repaired and raw types are identical, so a caller passing a raw read got a
138
+ * `running` record that could never be claimed and a job wedged forever.
139
+ * `timeoutMs` is the job's own budget; see {@link isStale}.
140
+ */
141
+ claim(state: JobState<TExtra>, timeoutMs: number, takeOverArmedAt?: number): {
142
+ ok: true;
143
+ } | {
144
+ ok: false;
145
+ current: RunningJob<TExtra>;
146
+ };
147
+ /**
148
+ * Whether a `running` record has stood long enough to be presumed dead.
149
+ *
150
+ * `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
151
+ * job that is merely slow is never declared gone.
152
+ */
153
+ isStale(state: RunningJob<TExtra>, timeoutMs: number, now?: number): boolean;
154
+ /** Arm the watchdog that re-attaches to a job nobody is draining. */
155
+ armWatch(now?: number): Promise<void>;
156
+ /**
157
+ * Disarm the watchdog.
158
+ *
159
+ * Never call this from a superseded drain: the watchdog belongs to whichever
160
+ * run owns the record *now*, and clearing it there disarms the one recovery
161
+ * path the live run has.
162
+ */
163
+ clearWatch(): Promise<void>;
164
+ /**
165
+ * A predicate a drain calls before every write, to ask whether it still owns
166
+ * the record.
167
+ *
168
+ * Captures the stamp once, at drain start, and compares it against disk each
169
+ * time. The closure also latches, so a drain can ask afterwards whether it was
170
+ * superseded — which is what decides if it may touch the watchdog.
171
+ */
172
+ generation(startedAt: number): {
173
+ stillMine: () => Promise<boolean>;
174
+ superseded: () => boolean;
175
+ };
176
+ }