@nylorun/runtime 0.40.0-beta → 0.41.0-beta

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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/adapters/streams/s2.js +9 -2
  3. package/dist/api/components.d.ts +131 -0
  4. package/dist/api/components.js +3 -1
  5. package/dist/api/http/app.js +2 -0
  6. package/dist/api/http/routes/runs.d.ts +8 -0
  7. package/dist/api/http/routes/runs.js +30 -0
  8. package/dist/api/http/routes/triggers.d.ts +2 -1
  9. package/dist/api/http/routes/triggers.js +32 -4
  10. package/dist/api/openapi.js +3 -1
  11. package/dist/gates/policy.d.ts +6 -4
  12. package/dist/gates/policy.js +10 -2
  13. package/dist/harness-api/record.js +51 -6
  14. package/dist/harness-api/start.js +15 -9
  15. package/dist/harness-api/tool-journal.js +3 -0
  16. package/dist/management-openapi.json +11 -6
  17. package/dist/openapi.json +506 -12
  18. package/dist/store/postgres/drizzle/0043_agent_triggers_origin.sql +11 -0
  19. package/dist/store/postgres/drizzle/0044_agent_triggers_origin_backfill.sql +8 -0
  20. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  21. package/dist/store/postgres/schema.d.ts +151 -4
  22. package/dist/store/postgres/schema.js +25 -4
  23. package/dist/store/postgres/store.js +20 -2
  24. package/dist/store/types.d.ts +10 -0
  25. package/dist/streams/types.d.ts +16 -1
  26. package/dist/streams/types.js +8 -1
  27. package/dist/tenant/advance.js +7 -0
  28. package/dist/tenant/agent-triggers.d.ts +60 -0
  29. package/dist/tenant/agent-triggers.js +179 -0
  30. package/dist/tenant/agents.d.ts +1 -1
  31. package/dist/tenant/agents.js +5 -1
  32. package/dist/tenant/limits.d.ts +26 -7
  33. package/dist/tenant/limits.js +39 -6
  34. package/dist/tenant/runs-stream.d.ts +51 -0
  35. package/dist/tenant/runs-stream.js +164 -0
  36. package/dist/tenant/sweep.js +3 -0
  37. package/dist/tenant/triggers.d.ts +31 -5
  38. package/dist/tenant/triggers.js +168 -48
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +3 -3
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The `triggers` tool (D83): an agent whose version declares `harness.engine.triggers` lists,
3
+ * creates and deletes its own triggers. The Runtime serves each call as a `runtime` effect
4
+ * (operation `triggers`), in the call's own transaction, keyed by the effect id, so a replayed turn
5
+ * changes nothing twice (`harness-api/record.ts`). The Tenant's tool policy decides it first (a
6
+ * rule of `kind: "runtime"`, `tool: "triggers"`), so an `ask` rule can hold a create for a person.
7
+ *
8
+ * A trigger an agent makes is a row of `agent_triggers` with origin `agent`: its definition (a
9
+ * `Trigger`, as a manifest entry holds it) and title on the row, with the session and turn that
10
+ * made it. It fires through `fireTimer` and `deliverRun` like a manifest's (`triggers.ts`), and an
11
+ * upgrade never touches it.
12
+ *
13
+ * - **Where it runs.** `session: "this"` runs it in the calling session (a follow-up); `"own"` in
14
+ * a session named from the calling one, `<session>_t_<id>`, opened at its first run and kept.
15
+ * - **Scope.** A session lists and deletes the triggers made from it, and a trigger's own session
16
+ * may list and delete it too. Creating with an id the session's trigger has replaces it; an id
17
+ * another session's trigger, a manifest's or a wake holds is `tool.conflict`.
18
+ * - **Runs can't multiply.** A turn a trigger or a booked wake started may list and delete, never
19
+ * create.
20
+ * - **Checks.** Every create runs the checks a manifest save runs (`triggerIssues`): cron and
21
+ * zone, the minimum interval, `at` in the future, the prompt's length; and the count of the
22
+ * Agent's agent-made triggers. Each against the limits in force: the manifest's
23
+ * `runtime.limits.triggers` within the Tenant's (`triggerLimitsInForce`).
24
+ * - **Arming.** No I/O in the transaction: the call returns the rows to arm, which the record arms
25
+ * after its commit; the sweep's reconcile arms any it missed.
26
+ */
27
+ import { TriggerSchema, TriggerToolInputSchema, triggerKindOf, } from "@nylorun/core/contracts";
28
+ import { liveEnvironment } from "./agents.js";
29
+ import { triggerLimitsInForce } from "./limits.js";
30
+ import { readRuntimeLimits } from "./runtime-limits.js";
31
+ import { nextFireAt, originOf, triggerIssues } from "./triggers.js";
32
+ const DELAY_UNITS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
33
+ const failed = (code, message) => ({ outcome: { kind: "failed", code, message }, arm: [] });
34
+ const completed = (output, arm = []) => ({
35
+ outcome: { kind: "completed", output },
36
+ arm,
37
+ });
38
+ /** The session an agent-made trigger runs in: the calling one, or its own, `<session>_t_<id>`. */
39
+ export function triggerSessionOf(callingSessionId, id, session) {
40
+ return session === "this" ? callingSessionId : `${callingSessionId}_t_${id}`;
41
+ }
42
+ /** Whether session `sessionId` may see and delete agent-made trigger `row`: it made it, or the trigger runs there. */
43
+ function reaches(row, sessionId) {
44
+ if (originOf(row) !== "agent")
45
+ return false;
46
+ return row.createdSessionId === sessionId || row.definition?.session === sessionId;
47
+ }
48
+ /** Whether the version session `s` runs declares `harness.engine.triggers`. */
49
+ async function declaresTriggers(t, s) {
50
+ if (!s.definition)
51
+ return false;
52
+ const body = (await t.agentManifest(s.definition.name, s.definition.version))?.body;
53
+ return body?.harness?.engine?.triggers === true;
54
+ }
55
+ /**
56
+ * Why session `s` cannot make `triggers` call `request` at all, before the Tenant's tool policy
57
+ * is asked (off means off, ADR-100): its version does not declare `engine.triggers`
58
+ * (`tool.unavailable`), or an agent used as a tool made it (`tool.forbidden`). Undefined when it can.
59
+ */
60
+ export async function triggersRefusal(t, s, request) {
61
+ if (!(await declaresTriggers(t, s)))
62
+ return { kind: "failed", code: "tool.unavailable", message: "This session's agent does not set up triggers (engine.triggers)." };
63
+ if (request.agent !== undefined)
64
+ return {
65
+ kind: "failed",
66
+ code: "tool.forbidden",
67
+ message: "Triggers are the root agent's: an agent used as a tool can't list, create or delete them.",
68
+ };
69
+ return undefined;
70
+ }
71
+ /**
72
+ * Serves one `triggers` call of session `s`'s active turn in `t` (see the module comment). The
73
+ * call's `agent` is the agent used as a tool that made it, absent for the root agent.
74
+ */
75
+ export async function serveTriggersTool(t, s, request) {
76
+ const refused = await triggersRefusal(t, s, request);
77
+ if (refused)
78
+ return { outcome: refused, arm: [] };
79
+ const parsed = TriggerToolInputSchema.safeParse(request.input);
80
+ if (!parsed.success) {
81
+ const issue = parsed.error.issues[0];
82
+ const at = issue?.path.length ? `${issue.path.join(".")}: ` : "";
83
+ return failed("tool.invalid-input", `${at}${issue?.message ?? "Not a triggers call"}`);
84
+ }
85
+ const input = parsed.data;
86
+ if (input.command === "list") {
87
+ const rows = (await t.agentTriggers(s.agentId)).filter((row) => reaches(row, s.id));
88
+ return completed({
89
+ triggers: rows.map((row) => {
90
+ const trigger = row.definition;
91
+ return {
92
+ id: row.triggerId,
93
+ title: row.title,
94
+ status: row.status,
95
+ ...(trigger.schedule === undefined ? {} : { schedule: trigger.schedule, timezone: trigger.timezone ?? "UTC" }),
96
+ ...(trigger.at === undefined ? {} : { at: trigger.at }),
97
+ session: trigger.session,
98
+ nextRunAt: row.status === "active" ? row.nextFireAt : null,
99
+ prompt: trigger.prompt,
100
+ };
101
+ }),
102
+ });
103
+ }
104
+ if (input.command === "delete") {
105
+ const row = await t.agentTrigger(s.agentId, input.id, { lock: true });
106
+ if (!row || !reaches(row, s.id))
107
+ return failed("tool.not-found", `This session has no trigger ${input.id}: list shows the ones it has.`);
108
+ await t.deleteAgentTrigger(s.agentId, input.id);
109
+ await t.agentEvent(s.agentId, "agent.trigger_deleted", { triggerId: input.id, sessionId: s.id });
110
+ return completed({ id: input.id, deleted: true });
111
+ }
112
+ // create: never from a turn a trigger or a booked wake started.
113
+ if (s.turnCause !== undefined)
114
+ return failed("tool.forbidden", "A run can't create triggers: this turn was started by a trigger. It may list and delete them.");
115
+ const now = new Date();
116
+ const stamp = now.toISOString();
117
+ const session = triggerSessionOf(s.id, input.id, input.session);
118
+ const delay = input.in === undefined ? undefined : /^([1-9][0-9]*)(m|h|d)$/.exec(input.in);
119
+ const checked = TriggerSchema.safeParse({
120
+ ...(input.schedule === undefined ? {} : { schedule: input.schedule }),
121
+ ...(input.timezone === undefined ? {} : { timezone: input.timezone }),
122
+ ...(input.at === undefined ? {} : { at: input.at }),
123
+ ...(delay ? { at: new Date(now.getTime() + Number(delay[1]) * DELAY_UNITS[delay[2]]).toISOString() } : {}),
124
+ session,
125
+ prompt: input.prompt,
126
+ });
127
+ if (!checked.success)
128
+ return failed("tool.invalid-input", checked.error.issues[0]?.path[0] === "session"
129
+ ? `session: own would run it in session ${session}, longer than a trigger's session may be: use a shorter id.`
130
+ : (checked.error.issues[0]?.message ?? "Not a trigger"));
131
+ const definition = checked.data;
132
+ const existing = await t.agentTrigger(s.agentId, input.id, { lock: true });
133
+ if (existing && (originOf(existing) !== "agent" || existing.createdSessionId !== s.id))
134
+ return failed("tool.conflict", `The id ${input.id} is taken by another trigger of this Agent: choose another id.`);
135
+ const runtime = await readRuntimeLimits(t);
136
+ const agent = await liveEnvironment(t, s.agentId);
137
+ const inForce = triggerLimitsInForce(runtime, agent?.applied?.limits);
138
+ if (!existing) {
139
+ const made = (await t.agentTriggers(s.agentId)).filter((row) => originOf(row) === "agent").length;
140
+ if (made >= inForce.count)
141
+ return failed("tool.limit", `This Agent has ${made} triggers, the most it can have. Delete one to add another.`);
142
+ }
143
+ const issues = triggerIssues({ [input.id]: definition }, { ...runtime, triggersPerAgent: inForce.count, triggerMinIntervalMs: inForce.minIntervalMs }, now, (id) => id);
144
+ if (issues.length > 0)
145
+ return failed("tool.invalid-input", issues.join(" "));
146
+ const next = nextFireAt(s.agentId, input.id, definition, now, runtime);
147
+ const row = {
148
+ agentId: s.agentId,
149
+ triggerId: input.id,
150
+ kind: triggerKindOf(definition),
151
+ status: next === null ? "completed" : "active",
152
+ pausedReason: null,
153
+ generation: (existing?.generation ?? -1) + 1,
154
+ nextFireAt: next?.toISOString() ?? null,
155
+ lastFireAt: existing?.lastFireAt ?? null,
156
+ endpointId: null,
157
+ sealedSecret: null,
158
+ sessionId: session,
159
+ note: null,
160
+ effectId: request.effectId,
161
+ origin: "agent",
162
+ title: input.title ?? null,
163
+ definition,
164
+ createdSessionId: s.id,
165
+ createdTurnId: s.activeTurnId,
166
+ createdAt: existing?.createdAt ?? stamp,
167
+ updatedAt: stamp,
168
+ };
169
+ await t.putAgentTrigger(row);
170
+ await t.agentEvent(s.agentId, existing ? "agent.trigger_updated" : "agent.trigger_created", {
171
+ triggerId: input.id,
172
+ kind: definition.schedule === undefined ? "at" : "schedule",
173
+ sessionId: s.id,
174
+ session,
175
+ ...(row.title === null ? {} : { title: row.title }),
176
+ nextRunAt: row.nextFireAt,
177
+ });
178
+ return completed({ id: input.id, status: row.status, nextRunAt: row.nextFireAt, session }, [row]);
179
+ }
@@ -196,7 +196,7 @@ export declare function agentEventsOf(ctx: TenantContext, id: string, scope: Aut
196
196
  agentId: string;
197
197
  seq: number;
198
198
  time: string;
199
- type: "agent.attached" | "agent.created" | "agent.deleted" | "agent.detached" | "agent.egress" | "agent.failed" | "agent.forked" | "agent.lost" | "agent.memory_updated" | "agent.outdated" | "agent.relaunched" | "agent.reset" | "agent.run" | "agent.running" | "agent.start_failed" | "agent.suspended" | "agent.trigger_paused" | "agent.trigger_resumed" | "agent.trigger_secret_rotated" | "agent.version_changed";
199
+ type: "agent.attached" | "agent.created" | "agent.deleted" | "agent.detached" | "agent.egress" | "agent.failed" | "agent.forked" | "agent.lost" | "agent.memory_updated" | "agent.outdated" | "agent.relaunched" | "agent.reset" | "agent.run" | "agent.running" | "agent.start_failed" | "agent.suspended" | "agent.trigger_created" | "agent.trigger_deleted" | "agent.trigger_paused" | "agent.trigger_resumed" | "agent.trigger_secret_rotated" | "agent.trigger_updated" | "agent.version_changed";
200
200
  payload: Record<string, unknown>;
201
201
  }[];
202
202
  }>;
@@ -194,7 +194,7 @@ async function viewOf(ctx, t, environment, owner) {
194
194
  : {}),
195
195
  },
196
196
  runtime: environment.applied,
197
- limits: limitsInForce(await readTenantLimits(t), environment.applied.limits),
197
+ limits: storageView(limitsInForce(await readTenantLimits(t), environment.applied.limits)),
198
198
  sessions: visible.map((session) => ({
199
199
  id: session.id,
200
200
  ownerUserId: session.ownerUserId,
@@ -1273,3 +1273,7 @@ export function sandboxRouteDeps(ctx) {
1273
1273
  session: (id) => ctx.store.tx((t) => sessionOf(t, id)),
1274
1274
  };
1275
1275
  }
1276
+ /** The limits an Agent's view shows: its storage's, without its triggers'. */
1277
+ function storageView({ triggers: _, ...storage }) {
1278
+ return storage;
1279
+ }
@@ -1,10 +1,12 @@
1
1
  /**
2
- * What artifacts, skills and disk versions may hold: the Tenant's limits (`artifacts.config`,
3
- * `skills.config` here, and the Runtime limits `diskVersion*` of `limits.config`), and an
4
- * Environment's, which its manifest sets (`limits`). A limit the manifest leaves out is the
5
- * Tenant's as it is when the limit is checked, and none is ever above the Tenant's: a manifest
6
- * asking for more is refused when the Environment is made, and a Tenant that lowers a limit
7
- * later lowers it for every Environment.
2
+ * What artifacts, skills and disk versions may hold, and how many triggers an agent may make and
3
+ * how often they run (D83): the Tenant's limits (`artifacts.config`, `skills.config` here, and the
4
+ * Runtime limits `diskVersion*`, `triggersPerAgent`, `triggerMinIntervalMs` and
5
+ * `triggerRunsPerHour` of `limits.config`), and an Environment's, which its manifest sets
6
+ * (`limits`). A limit the manifest leaves out is the Tenant's as it is when the limit is checked,
7
+ * and none is ever looser than the Tenant's (a trigger's minimum interval is never shorter): a
8
+ * manifest asking for more is refused when the Environment is made, and a Tenant that lowers a
9
+ * limit later lowers it for every Environment. Lowering a trigger limit deletes nothing.
8
10
  *
9
11
  * Also the shape a manifest and an Environment's labels must fit, which the Tenant's Runtime
10
12
  * limits set (`manifestShapeIssues`, `labelIssues`), checked when they are put rather than by
@@ -21,12 +23,29 @@ export interface DiskLimits {
21
23
  readonly versionBytes: number;
22
24
  readonly versionEntries: number;
23
25
  }
24
- /** The limits an Environment's artifacts, skills and disk versions are held to. */
26
+ /** How many triggers an Agent's agents may make, and how often its triggers run (D83). */
27
+ export interface TriggerLimits {
28
+ /** The most triggers its agents may have made (`triggers` tool, origin `agent`). */
29
+ readonly count: number;
30
+ /** The shortest gap between two runs of one schedule, and the soonest an `at` or `in` may be. */
31
+ readonly minIntervalMs: number;
32
+ /** The most runs its triggers start or queue in any hour. */
33
+ readonly runsPerHour: number;
34
+ }
35
+ /** The limits an Environment's artifacts, skills and disk versions are held to, and its triggers. */
25
36
  export interface StorageLimits {
26
37
  readonly artifacts: ArtifactLimits;
27
38
  readonly skills: SkillLimits;
28
39
  readonly disk: DiskLimits;
40
+ readonly triggers: TriggerLimits;
29
41
  }
42
+ /** The Tenant's trigger limits: its Runtime limits `triggersPerAgent`, `triggerMinIntervalMs`, `triggerRunsPerHour`. */
43
+ export declare function tenantTriggerLimits(runtime: RuntimeLimits): TriggerLimits;
44
+ /**
45
+ * The trigger limits in force for an Environment whose manifest sets `set` (`limits.triggers`):
46
+ * the lower count and hourly runs, and the longer minimum interval, of its own and the Tenant's.
47
+ */
48
+ export declare function triggerLimitsInForce(runtime: RuntimeLimits, set: EnvironmentLimits | undefined): TriggerLimits;
30
49
  interface SettingsReader {
31
50
  getSetting(key: string): Promise<string | undefined>;
32
51
  }
@@ -1,10 +1,12 @@
1
1
  /**
2
- * What artifacts, skills and disk versions may hold: the Tenant's limits (`artifacts.config`,
3
- * `skills.config` here, and the Runtime limits `diskVersion*` of `limits.config`), and an
4
- * Environment's, which its manifest sets (`limits`). A limit the manifest leaves out is the
5
- * Tenant's as it is when the limit is checked, and none is ever above the Tenant's: a manifest
6
- * asking for more is refused when the Environment is made, and a Tenant that lowers a limit
7
- * later lowers it for every Environment.
2
+ * What artifacts, skills and disk versions may hold, and how many triggers an agent may make and
3
+ * how often they run (D83): the Tenant's limits (`artifacts.config`, `skills.config` here, and the
4
+ * Runtime limits `diskVersion*`, `triggersPerAgent`, `triggerMinIntervalMs` and
5
+ * `triggerRunsPerHour` of `limits.config`), and an Environment's, which its manifest sets
6
+ * (`limits`). A limit the manifest leaves out is the Tenant's as it is when the limit is checked,
7
+ * and none is ever looser than the Tenant's (a trigger's minimum interval is never shorter): a
8
+ * manifest asking for more is refused when the Environment is made, and a Tenant that lowers a
9
+ * limit later lowers it for every Environment. Lowering a trigger limit deletes nothing.
8
10
  *
9
11
  * Also the shape a manifest and an Environment's labels must fit, which the Tenant's Runtime
10
12
  * limits set (`manifestShapeIssues`, `labelIssues`), checked when they are put rather than by
@@ -15,6 +17,23 @@ import { parseSandboxDuration } from "@nylorun/core/define";
15
17
  import { readArtifactLimits } from "../artifacts/config.js";
16
18
  import { readRuntimeLimits } from "./runtime-limits.js";
17
19
  export const SKILLS_CONFIG_SETTING = "skills.config";
20
+ /** The Tenant's trigger limits: its Runtime limits `triggersPerAgent`, `triggerMinIntervalMs`, `triggerRunsPerHour`. */
21
+ export function tenantTriggerLimits(runtime) {
22
+ return { count: runtime.triggersPerAgent, minIntervalMs: runtime.triggerMinIntervalMs, runsPerHour: runtime.triggerRunsPerHour };
23
+ }
24
+ /**
25
+ * The trigger limits in force for an Environment whose manifest sets `set` (`limits.triggers`):
26
+ * the lower count and hourly runs, and the longer minimum interval, of its own and the Tenant's.
27
+ */
28
+ export function triggerLimitsInForce(runtime, set) {
29
+ const tenant = tenantTriggerLimits(runtime);
30
+ const asked = set?.triggers;
31
+ return {
32
+ count: Math.min(asked?.count ?? tenant.count, tenant.count),
33
+ minIntervalMs: Math.max(asked?.minIntervalMs ?? tenant.minIntervalMs, tenant.minIntervalMs),
34
+ runsPerHour: Math.min(asked?.runsPerHour ?? tenant.runsPerHour, tenant.runsPerHour),
35
+ };
36
+ }
18
37
  /** The skill limits in force: the Tenant's, with the default for what it leaves unset. */
19
38
  export function effectiveSkillLimits(config) {
20
39
  return { totalBytes: config.limits?.totalBytes ?? SKILL_TOTAL_BYTES_DEFAULT };
@@ -39,6 +58,7 @@ export async function readTenantLimits(t) {
39
58
  artifacts: await readArtifactLimits(t),
40
59
  skills: await readSkillLimits(t),
41
60
  disk: { versionBytes: runtime.diskVersionBytes, versionEntries: runtime.diskVersionEntries },
61
+ triggers: tenantTriggerLimits(runtime),
42
62
  };
43
63
  }
44
64
  /** The limits in force for an Environment whose manifest sets `set`: each within the Tenant's. */
@@ -54,6 +74,11 @@ export function limitsInForce(tenant, set) {
54
74
  versionBytes: within(set?.disk?.versionBytes, tenant.disk.versionBytes),
55
75
  versionEntries: within(set?.disk?.versionEntries, tenant.disk.versionEntries),
56
76
  },
77
+ triggers: {
78
+ count: within(set?.triggers?.count, tenant.triggers.count),
79
+ minIntervalMs: Math.max(set?.triggers?.minIntervalMs ?? tenant.triggers.minIntervalMs, tenant.triggers.minIntervalMs),
80
+ runsPerHour: within(set?.triggers?.runsPerHour, tenant.triggers.runsPerHour),
81
+ },
57
82
  };
58
83
  }
59
84
  /** Why a manifest's `limits` cannot be made: each limit above the Tenant's. */
@@ -68,6 +93,14 @@ export function limitIssues(set, tenant) {
68
93
  check("skills.totalBytes", set?.skills?.totalBytes, tenant.skills.totalBytes);
69
94
  check("disk.versionBytes", set?.disk?.versionBytes, tenant.disk.versionBytes);
70
95
  check("disk.versionEntries", set?.disk?.versionEntries, tenant.disk.versionEntries, "entries");
96
+ // Trigger limits (D83): the Tenant's Runtime limits, set with PUT /v1/tenant/limits.
97
+ const triggers = set?.triggers;
98
+ if (triggers?.count !== undefined && triggers.count > tenant.triggers.count)
99
+ issues.push(`limits.triggers.count asks for ${triggers.count} triggers; this Tenant allows at most ${tenant.triggers.count} (the Runtime limit triggersPerAgent).`);
100
+ if (triggers?.runsPerHour !== undefined && triggers.runsPerHour > tenant.triggers.runsPerHour)
101
+ issues.push(`limits.triggers.runsPerHour asks for ${triggers.runsPerHour} runs an hour; this Tenant allows at most ${tenant.triggers.runsPerHour} (the Runtime limit triggerRunsPerHour).`);
102
+ if (triggers?.minIntervalMs !== undefined && triggers.minIntervalMs < tenant.triggers.minIntervalMs)
103
+ issues.push(`limits.triggers.minIntervalMs asks for runs ${triggers.minIntervalMs} ms apart; this Tenant allows no less than ${tenant.triggers.minIntervalMs} ms (the Runtime limit triggerMinIntervalMs).`);
71
104
  return issues;
72
105
  }
73
106
  /**
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The Tenant's runs stream (D83): every run of every Agent's triggers, in the `runs` stream of the
3
+ * Tenant's current basin (Durable Streams, `streams/types.ts`), kept 7 days, and served at
4
+ * `GET /v1/runs/stream` as server-sent events from a cursor.
5
+ *
6
+ * **The outbox.** A run is a `trigger_runs` row, written in its trigger's transaction; nothing is
7
+ * appended there (no I/O in a transaction). After the commit, the code that made the run appends
8
+ * it (`appendRuns`) and marks the row (`streamed_at`). A crash between the two leaves the row
9
+ * unmarked, and the Tenant sweep appends every run still unmarked once it is
10
+ * `RUNS_STREAM_GRACE_MS` old, oldest first (`streamMissedRuns`), so no run is lost. A run can
11
+ * therefore arrive twice (appended, then the mark lost); a reader drops a run id it has seen. The
12
+ * session stream relay is not involved: its replication filter and heads are keyed by session.
13
+ *
14
+ * **Basins.** The stream lives in the Tenant's current basin generation: a Tenant reset's new
15
+ * basin starts it at 0 again. The cursor names the generation, so one from an older basin is
16
+ * answered `410 cursor_expired`. Without S2 (tests, a local Host) the in-memory streams hold it,
17
+ * and a restart loses what was unread.
18
+ *
19
+ * Nothing inside the Runtime reads it or waits on it (D48): it serves API listeners only.
20
+ */
21
+ import type { IncomingMessage, ServerResponse } from "node:http";
22
+ import type { TenantRun } from "@nylorun/core/contracts";
23
+ import type { TriggerRunRow } from "../store/types.js";
24
+ import type { TenantContext } from "./context.js";
25
+ /** How old an unmarked run is before the sweep appends it: the code that made it had its chance. */
26
+ export declare const RUNS_STREAM_GRACE_MS = 2000;
27
+ /** A run as the runs stream carries it. */
28
+ export declare function tenantRunOf(row: TriggerRunRow): TenantRun;
29
+ /**
30
+ * Appends `runs` (made in a transaction that committed) to the Tenant's runs stream, then marks
31
+ * them. Never throws: a run it cannot append stays unmarked, and the sweep appends it later.
32
+ */
33
+ export declare function appendRuns(ctx: TenantContext, runs: readonly TriggerRunRow[]): Promise<void>;
34
+ /**
35
+ * The Tenant sweep's step: appends the runs still unmarked `RUNS_STREAM_GRACE_MS` after they
36
+ * fired, oldest first, a batch at a time.
37
+ */
38
+ export declare function streamMissedRuns(ctx: TenantContext): Promise<void>;
39
+ /** The cursor after record `seq` of the runs stream in basin generation `generation`. */
40
+ export declare function runsCursor(generation: number, seq: number): string;
41
+ /**
42
+ * The first sequence to read for `cursor` in the Tenant's current generation: 0 without one;
43
+ * `400` for one that is not a runs cursor; `410 cursor_expired` for one from an older basin.
44
+ */
45
+ export declare function runsStartSeq(cursor: string | undefined, generation: number): number;
46
+ /**
47
+ * `GET /v1/runs/stream`: the Tenant's runs from the cursor, as `event: run` frames (`id:` the
48
+ * cursor, `data:` `{cursor, run}`), following the stream until the client leaves, the Tenant moves
49
+ * to a new basin (the stream ends: resume without a cursor) or it closes.
50
+ */
51
+ export declare function streamRuns(ctx: TenantContext, request: IncomingMessage, response: ServerResponse, cursor: string | undefined): Promise<void>;
@@ -0,0 +1,164 @@
1
+ import { basinOf } from "../streams/basin.js";
2
+ import { RUNS_STREAM, RUNS_STREAM_RETENTION_SECS } from "../streams/types.js";
3
+ import { sleep } from "../retry.js";
4
+ import { hostTuning } from "../tuning.js";
5
+ import { fail } from "./http.js";
6
+ import { openSse, send } from "./session-streams.js";
7
+ /** How old an unmarked run is before the sweep appends it: the code that made it had its chance. */
8
+ export const RUNS_STREAM_GRACE_MS = 2_000;
9
+ /** A run as the runs stream carries it. */
10
+ export function tenantRunOf(row) {
11
+ return {
12
+ runId: row.id,
13
+ agentId: row.agentId,
14
+ triggerId: row.triggerId,
15
+ kind: row.kind,
16
+ status: row.status,
17
+ reason: row.reason,
18
+ sessionId: row.sessionId,
19
+ turnId: row.turnId,
20
+ queuedMessageId: row.queuedMessageId,
21
+ dueAt: row.dueAt,
22
+ firedAt: row.firedAt,
23
+ late: row.late,
24
+ origin: row.kind === "wake" ? "wake" : row.origin,
25
+ title: row.title,
26
+ };
27
+ }
28
+ /** The basins whose runs stream this process has ensured (its retention set), by streams. */
29
+ const ensured = new WeakMap();
30
+ /**
31
+ * Appends `runs` (made in a transaction that committed) to the Tenant's runs stream, then marks
32
+ * them. Never throws: a run it cannot append stays unmarked, and the sweep appends it later.
33
+ */
34
+ export async function appendRuns(ctx, runs) {
35
+ const fresh = runs.filter((run) => run.streamedAt === null);
36
+ if (fresh.length === 0)
37
+ return;
38
+ // A Tenant without Durable Streams wired (a test's bare context) appends nothing.
39
+ const streams = ctx.sessionStreams?.wiring?.streams;
40
+ if (!streams)
41
+ return;
42
+ const basin = basinOf(ctx.config.tenantId, ctx.sessionStreams.generation);
43
+ try {
44
+ await ensureRunsStream(streams, basin).catch((error) =>
45
+ // Not fatal: the stream keeps the basin's default retention, and the next append tries again.
46
+ ctx.config.logger.warn("runs stream retention not set; appending anyway", {
47
+ message: error instanceof Error ? error.message : String(error),
48
+ }));
49
+ const result = await streams.append(basin, RUNS_STREAM, fresh.map(tenantRunOf));
50
+ if (result.status !== "ok")
51
+ return;
52
+ const at = new Date().toISOString();
53
+ await ctx.store.tx((t) => t.markTriggerRunsStreamed(fresh.map((run) => run.id), at));
54
+ for (const run of fresh)
55
+ run.streamedAt = at;
56
+ }
57
+ catch (error) {
58
+ ctx.config.logger.warn("runs stream append failed; the sweep appends it later", {
59
+ runs: fresh.length,
60
+ message: error instanceof Error ? error.message : String(error),
61
+ });
62
+ }
63
+ }
64
+ /** Sets the runs stream's retention once per basin and process; a failure leaves the basin's default. */
65
+ async function ensureRunsStream(streams, basin) {
66
+ if (!streams.ensureStream)
67
+ return;
68
+ let done = ensured.get(streams);
69
+ if (!done)
70
+ ensured.set(streams, (done = new Set()));
71
+ if (done.has(basin))
72
+ return;
73
+ await streams.ensureStream(basin, RUNS_STREAM, { retentionSecs: RUNS_STREAM_RETENTION_SECS });
74
+ done.add(basin);
75
+ }
76
+ /**
77
+ * The Tenant sweep's step: appends the runs still unmarked `RUNS_STREAM_GRACE_MS` after they
78
+ * fired, oldest first, a batch at a time.
79
+ */
80
+ export async function streamMissedRuns(ctx) {
81
+ if (!ctx.sessionStreams?.wiring)
82
+ return;
83
+ const { batchSize } = hostTuning().sweep;
84
+ for (let pass = 0; pass < 10; pass++) {
85
+ const firedBefore = new Date(Date.now() - RUNS_STREAM_GRACE_MS).toISOString();
86
+ const rows = await ctx.store.tx((t) => t.unstreamedTriggerRuns({ firedBefore, limit: batchSize }));
87
+ if (rows.length === 0)
88
+ return;
89
+ await appendRuns(ctx, rows);
90
+ // Not marked: the streams failed, and the next pass tries again.
91
+ if (rows.some((row) => row.streamedAt === null) || rows.length < batchSize)
92
+ return;
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // GET /v1/runs/stream
97
+ const CURSOR_PREFIX = `${RUNS_STREAM}:`;
98
+ /** The cursor after record `seq` of the runs stream in basin generation `generation`. */
99
+ export function runsCursor(generation, seq) {
100
+ return Buffer.from(`${CURSOR_PREFIX}${generation}:${seq}`).toString("base64url");
101
+ }
102
+ /**
103
+ * The first sequence to read for `cursor` in the Tenant's current generation: 0 without one;
104
+ * `400` for one that is not a runs cursor; `410 cursor_expired` for one from an older basin.
105
+ */
106
+ export function runsStartSeq(cursor, generation) {
107
+ if (cursor === undefined || cursor === "")
108
+ return 0;
109
+ const text = Buffer.from(cursor, "base64url").toString("utf8");
110
+ const match = /^runs:(\d+):(\d+)$/.exec(text);
111
+ if (!match)
112
+ return fail(400, "cursor is not a cursor of the runs stream", { code: "invalid_cursor" });
113
+ const from = Number(match[1]);
114
+ if (from !== generation)
115
+ return fail(410, "The cursor is from the Tenant's runs stream before its reset: read the stream again without a cursor.", {
116
+ code: "cursor_expired",
117
+ });
118
+ return Number(match[2]) + 1;
119
+ }
120
+ /**
121
+ * `GET /v1/runs/stream`: the Tenant's runs from the cursor, as `event: run` frames (`id:` the
122
+ * cursor, `data:` `{cursor, run}`), following the stream until the client leaves, the Tenant moves
123
+ * to a new basin (the stream ends: resume without a cursor) or it closes.
124
+ */
125
+ export async function streamRuns(ctx, request, response, cursor) {
126
+ const streams = ctx.sessionStreams.wiring?.streams ?? fail(503, "The runs stream is unavailable");
127
+ const generation = ctx.sessionStreams.generation;
128
+ let next = runsStartSeq(cursor, generation);
129
+ const basin = basinOf(ctx.config.tenantId, generation);
130
+ const stop = new AbortController();
131
+ openSse(request, response, () => stop.abort());
132
+ void (async () => {
133
+ const { minMs, maxMs } = hostTuning().streams.retry;
134
+ let delay = minMs;
135
+ while (!stop.signal.aborted && ctx.sessionStreams.generation === generation) {
136
+ try {
137
+ for await (const record of streams.read(basin, RUNS_STREAM, next, { signal: stop.signal })) {
138
+ if (stop.signal.aborted)
139
+ return;
140
+ if (record.seq < next)
141
+ continue;
142
+ next = record.seq + 1;
143
+ delay = minMs;
144
+ const at = runsCursor(generation, record.seq);
145
+ send(response, `id: ${at}\nevent: run\ndata: ${JSON.stringify({ cursor: at, run: record.body })}\n\n`);
146
+ }
147
+ if (stop.signal.aborted)
148
+ return;
149
+ break;
150
+ }
151
+ catch (error) {
152
+ if (stop.signal.aborted)
153
+ return;
154
+ ctx.config.logger.warn("runs stream read failed; retrying", {
155
+ message: error instanceof Error ? error.message : String(error),
156
+ });
157
+ }
158
+ await sleep(delay, stop.signal);
159
+ delay = Math.min(delay * 2, maxMs);
160
+ }
161
+ if (!response.writableEnded)
162
+ response.end();
163
+ })();
164
+ }
@@ -52,6 +52,7 @@
52
52
  */
53
53
  import { sweepUnusedAgents } from "./agents.js";
54
54
  import { reconcileTriggers } from "./triggers.js";
55
+ import { streamMissedRuns } from "./runs-stream.js";
55
56
  import { sweepPendingSeeds } from "../artifacts/disk.js";
56
57
  import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-host.js";
57
58
  import { hostTuning } from "../tuning.js";
@@ -129,6 +130,8 @@ export async function sweep(ctx) {
129
130
  // Triggers (track T4): every active one armed on this process's first pass, overdue ones after;
130
131
  // runs past their retention deleted.
131
132
  ["triggers", () => reconcileTriggers(ctx, first)],
133
+ // The Tenant runs stream's outbox (D83): runs not appended after their commit.
134
+ ["runs-stream", () => streamMissedRuns(ctx)],
132
135
  // Seeds of new volumes whose engine is connected and no process copies (R2d step 3).
133
136
  ["seeds", () => sweepPendingSeeds(ctx)],
134
137
  // A harness elsewhere closes its own idle MCP connections.
@@ -28,7 +28,17 @@ export declare function nextFireAt(agentId: string, triggerId: string, trigger:
28
28
  * refuses, two runs closer than `triggerMinIntervalMs` over the next `triggers.intervalCheckFires`
29
29
  * fires, an `at` that is not in the future, a prompt past `triggerPromptChars`.
30
30
  */
31
- export declare function triggerIssues(triggers: Readonly<Record<string, Trigger>> | undefined, limits: RuntimeLimits, now?: Date): string[];
31
+ export declare function triggerIssues(triggers: Readonly<Record<string, Trigger>> | undefined, limits: RuntimeLimits, now?: Date,
32
+ /** How an issue names trigger `id`: its place in the manifest by default. */
33
+ label?: (id: string) => string): string[];
34
+ /** Where a row's definition comes from (D83): its origin, or `wake` for a booked wake written before origins. */
35
+ export declare function originOf(row: Pick<AgentTriggerRow, "origin" | "kind">): AgentTriggerRow["origin"];
36
+ /**
37
+ * The definition trigger row `row` fires with (D83): a booked wake's, from its session and note;
38
+ * an agent-made trigger's own, from the row; a manifest trigger's, the entry of the version the
39
+ * Agent runs (`manifest`, read once by the caller). Undefined when it has none.
40
+ */
41
+ export declare function definitionOf(row: AgentTriggerRow, manifest: Readonly<Record<string, Trigger>>): Trigger | undefined;
32
42
  /**
33
43
  * Writes the state of a new Agent's triggers (D74): every trigger of the version it is made from
34
44
  * starts active, with its next time. Arm them after the commit (`armAgentTriggers`).
@@ -38,8 +48,11 @@ export declare function createAgentTriggers(t: Tx, agent: Environment, now?: Dat
38
48
  * After Agent `agent` (locked) moved from manifest version `from` to the version it now runs (track
39
49
  * T3, D73), in the move's transaction: its triggers merge by id. One the new version adds arrives
40
50
  * paused (`new`), one it drops loses its state (its runs stay), and one whose timing changed gets a
41
- * new generation and next time; a changed prompt or session applies at the next fire. The sweep
42
- * arms what changed (`reconcileTriggers`).
51
+ * new generation and next time; a changed prompt or session applies at the next fire. Only rows of
52
+ * origin `manifest` merge (D83): an agent's and a booked wake stay as they are, but for an agent's
53
+ * paused `undeclared`, which resumes on a version that declares `harness.engine.triggers`. A
54
+ * manifest trigger whose id an agent's trigger holds is not added. The sweep arms what changed
55
+ * (`reconcileTriggers`).
43
56
  */
44
57
  export declare function mergeAgentTriggers(t: Tx, agent: Environment, from: number, now?: Date): Promise<void>;
45
58
  /** Arms one trigger's timer for its next time, or the next step of a wait past `TIMER_MAX_MS`. */
@@ -53,6 +66,8 @@ export declare function armAgentTriggers(ctx: TenantContext, agentId: string): P
53
66
  export declare function reconcileTriggers(ctx: TenantContext, first: boolean): Promise<void>;
54
67
  /** `DurableExecution.timer` fired for key `key` (`TenantWorker.fire`). */
55
68
  export declare function fireTimer(ctx: TenantContext, key: string): Promise<void>;
69
+ /** Why an agent's trigger is paused: the version its Agent runs lacks `harness.engine.triggers` (D83). */
70
+ export declare const UNDECLARED = "undeclared";
56
71
  /** What makes a run: its cause's key, when it was due, whether late. */
57
72
  interface RunCause {
58
73
  runKey: string;
@@ -71,10 +86,15 @@ export interface WebhookPayload {
71
86
  * Runs trigger `triggerId` of `agent` (locked, with the trigger's row) in `t`: see the module
72
87
  * comment. Returns the run; a cause already run returns that run.
73
88
  */
74
- export declare function deliverRun(ctx: TenantContext, t: Tx, agent: Environment, triggerId: string, trigger: Trigger, row: Pick<AgentTriggerRow, "kind">, cause: RunCause): Promise<TriggerRunRow>;
89
+ export declare function deliverRun(ctx: TenantContext, t: Tx, agent: Environment, triggerId: string, trigger: Trigger, row: Pick<AgentTriggerRow, "kind"> & Partial<Pick<AgentTriggerRow, "origin" | "title" | "createdSessionId">>, cause: RunCause): Promise<TriggerRunRow>;
75
90
  /** A run as the API shows it. */
76
91
  export declare function runView(row: TriggerRunRow): TriggerRun;
77
- /** `GET /v1/agents/{id}/triggers`. */
92
+ /** One trigger as the API shows it: its definition and the Agent's state of it. */
93
+ export declare function triggerView(row: AgentTriggerRow, trigger: Trigger, origin?: string): TriggerView;
94
+ /**
95
+ * `GET /v1/agents/{id}/triggers`: the manifest's triggers the Agent holds, and the triggers its
96
+ * agents made (D83); never a session's booked wake.
97
+ */
78
98
  export declare function listTriggers(ctx: TenantContext, agentId: string, origin?: string): Promise<{
79
99
  triggers: TriggerView[];
80
100
  }>;
@@ -82,6 +102,12 @@ export declare function listTriggers(ctx: TenantContext, agentId: string, origin
82
102
  export declare function setTriggerStatus(ctx: TenantContext, agentId: string, triggerId: string, action: "pause" | "resume", origin?: string): Promise<{
83
103
  triggers: TriggerView[];
84
104
  }>;
105
+ /**
106
+ * `DELETE /v1/agents/{id}/triggers/{triggerId}` (D83): deletes a trigger one of the Agent's agents
107
+ * made (origin `agent`), with `agent.trigger_deleted`. A manifest's is the manifest's to drop
108
+ * (`409 trigger_from_manifest`); its runs stay.
109
+ */
110
+ export declare function deleteTrigger(ctx: TenantContext, agentId: string, triggerId: string, principal?: string): Promise<void>;
85
111
  /**
86
112
  * `POST .../triggers/{triggerId}/run`: runs it now, whatever its status, keyed by the request's
87
113
  * `Idempotency-Key`, so a retried request makes one run.