@nylorun/runtime 0.40.0-beta → 0.42.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.
- package/CHANGELOG.md +30 -0
- package/dist/adapters/streams/s2.js +9 -2
- package/dist/api/components.d.ts +131 -0
- package/dist/api/components.js +3 -1
- package/dist/api/http/app.js +2 -0
- package/dist/api/http/routes/runs.d.ts +8 -0
- package/dist/api/http/routes/runs.js +30 -0
- package/dist/api/http/routes/triggers.d.ts +2 -1
- package/dist/api/http/routes/triggers.js +32 -4
- package/dist/api/openapi.js +3 -1
- package/dist/gates/policy.d.ts +6 -4
- package/dist/gates/policy.js +10 -2
- package/dist/harness-api/record.js +51 -6
- package/dist/harness-api/start.js +15 -9
- package/dist/harness-api/tool-journal.js +3 -0
- package/dist/management-openapi.json +11 -6
- package/dist/openapi.json +550 -12
- package/dist/store/postgres/drizzle/0043_agent_triggers_origin.sql +11 -0
- package/dist/store/postgres/drizzle/0044_agent_triggers_origin_backfill.sql +8 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +151 -4
- package/dist/store/postgres/schema.js +25 -4
- package/dist/store/postgres/store.js +20 -2
- package/dist/store/types.d.ts +10 -0
- package/dist/streams/types.d.ts +16 -1
- package/dist/streams/types.js +8 -1
- package/dist/tenant/advance.js +7 -0
- package/dist/tenant/agent-triggers.d.ts +60 -0
- package/dist/tenant/agent-triggers.js +196 -0
- package/dist/tenant/agents.d.ts +1 -1
- package/dist/tenant/agents.js +5 -1
- package/dist/tenant/limits.d.ts +26 -7
- package/dist/tenant/limits.js +39 -6
- package/dist/tenant/runs-stream.d.ts +51 -0
- package/dist/tenant/runs-stream.js +164 -0
- package/dist/tenant/sweep.js +3 -0
- package/dist/tenant/triggers.d.ts +36 -5
- package/dist/tenant/triggers.js +183 -48
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,196 @@
|
|
|
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 the Runtime's `nylorun-triggers` plugin (ADR-106). */
|
|
49
|
+
async function declaresTriggersPlugin(t, s) {
|
|
50
|
+
if (!s.definition)
|
|
51
|
+
return false;
|
|
52
|
+
const body = (await t.agentManifest(s.definition.name, s.definition.version))?.body;
|
|
53
|
+
const plugins = body?.harness?.engine?.plugins;
|
|
54
|
+
return Array.isArray(plugins) && plugins.some((plugin) => plugin?.name === "nylorun-triggers");
|
|
55
|
+
}
|
|
56
|
+
/** Whether the version session `s` runs declares `harness.engine.triggers`. */
|
|
57
|
+
async function declaresTriggers(t, s) {
|
|
58
|
+
if (!s.definition)
|
|
59
|
+
return false;
|
|
60
|
+
const body = (await t.agentManifest(s.definition.name, s.definition.version))?.body;
|
|
61
|
+
return body?.harness?.engine?.triggers === true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Why session `s` cannot make `triggers` call `request` at all, before the Tenant's tool policy
|
|
65
|
+
* is asked (off means off, ADR-100): its version does not declare `engine.triggers`
|
|
66
|
+
* (`tool.unavailable`), or an agent used as a tool made it (`tool.forbidden`). Undefined when it can.
|
|
67
|
+
*/
|
|
68
|
+
export async function triggersRefusal(t, s, request) {
|
|
69
|
+
if (!(await declaresTriggers(t, s)))
|
|
70
|
+
return { kind: "failed", code: "tool.unavailable", message: "This session's agent does not set up triggers (engine.triggers)." };
|
|
71
|
+
if (request.agent !== undefined)
|
|
72
|
+
return {
|
|
73
|
+
kind: "failed",
|
|
74
|
+
code: "tool.forbidden",
|
|
75
|
+
message: "Triggers are the root agent's: an agent used as a tool can't list, create or delete them.",
|
|
76
|
+
};
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Serves one `triggers` call of session `s`'s active turn in `t` (see the module comment). The
|
|
81
|
+
* call's `agent` is the agent used as a tool that made it, absent for the root agent.
|
|
82
|
+
*/
|
|
83
|
+
export async function serveTriggersTool(t, s, request) {
|
|
84
|
+
const refused = await triggersRefusal(t, s, request);
|
|
85
|
+
if (refused)
|
|
86
|
+
return { outcome: refused, arm: [] };
|
|
87
|
+
const parsed = TriggerToolInputSchema.safeParse(request.input);
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
const issue = parsed.error.issues[0];
|
|
90
|
+
const at = issue?.path.length ? `${issue.path.join(".")}: ` : "";
|
|
91
|
+
return failed("tool.invalid-input", `${at}${issue?.message ?? "Not a triggers call"}`);
|
|
92
|
+
}
|
|
93
|
+
const input = parsed.data;
|
|
94
|
+
if (input.command === "list") {
|
|
95
|
+
const rows = (await t.agentTriggers(s.agentId)).filter((row) => reaches(row, s.id));
|
|
96
|
+
return completed({
|
|
97
|
+
now: new Date().toISOString(),
|
|
98
|
+
triggers: rows.map((row) => {
|
|
99
|
+
const trigger = row.definition;
|
|
100
|
+
return {
|
|
101
|
+
id: row.triggerId,
|
|
102
|
+
title: row.title,
|
|
103
|
+
status: row.status,
|
|
104
|
+
...(trigger.schedule === undefined ? {} : { schedule: trigger.schedule, timezone: trigger.timezone ?? "UTC" }),
|
|
105
|
+
...(trigger.at === undefined ? {} : { at: trigger.at }),
|
|
106
|
+
session: trigger.session,
|
|
107
|
+
nextRunAt: row.status === "active" ? row.nextFireAt : null,
|
|
108
|
+
prompt: trigger.prompt,
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (input.command === "delete") {
|
|
114
|
+
const row = await t.agentTrigger(s.agentId, input.id, { lock: true });
|
|
115
|
+
if (!row || !reaches(row, s.id))
|
|
116
|
+
return failed("tool.not-found", `This session has no trigger ${input.id}: list shows the ones it has.`);
|
|
117
|
+
await t.deleteAgentTrigger(s.agentId, input.id);
|
|
118
|
+
await t.agentEvent(s.agentId, "agent.trigger_deleted", { triggerId: input.id, sessionId: s.id });
|
|
119
|
+
return completed({ id: input.id, deleted: true });
|
|
120
|
+
}
|
|
121
|
+
// create: never from a turn a trigger or a booked wake started. Under the `nylorun-triggers`
|
|
122
|
+
// plugin (ADR-106) such a turn may still set a one-off follow-up in its own session, as a woken
|
|
123
|
+
// turn books the next wake: never a schedule, never a session of its own.
|
|
124
|
+
if (s.turnCause !== undefined) {
|
|
125
|
+
const plugin = await declaresTriggersPlugin(t, s);
|
|
126
|
+
const followUp = plugin && input.schedule === undefined && input.session === "this";
|
|
127
|
+
if (!followUp)
|
|
128
|
+
return failed("tool.forbidden", plugin
|
|
129
|
+
? "A run can't create triggers other than a one-off follow-up here (in or at, with session: this): this turn was started by a trigger. It may list and delete them."
|
|
130
|
+
: "A run can't create triggers: this turn was started by a trigger. It may list and delete them.");
|
|
131
|
+
}
|
|
132
|
+
const now = new Date();
|
|
133
|
+
const stamp = now.toISOString();
|
|
134
|
+
const session = triggerSessionOf(s.id, input.id, input.session);
|
|
135
|
+
const delay = input.in === undefined ? undefined : /^([1-9][0-9]*)(m|h|d)$/.exec(input.in);
|
|
136
|
+
const checked = TriggerSchema.safeParse({
|
|
137
|
+
...(input.schedule === undefined ? {} : { schedule: input.schedule }),
|
|
138
|
+
...(input.timezone === undefined ? {} : { timezone: input.timezone }),
|
|
139
|
+
...(input.at === undefined ? {} : { at: input.at }),
|
|
140
|
+
...(delay ? { at: new Date(now.getTime() + Number(delay[1]) * DELAY_UNITS[delay[2]]).toISOString() } : {}),
|
|
141
|
+
session,
|
|
142
|
+
prompt: input.prompt,
|
|
143
|
+
});
|
|
144
|
+
if (!checked.success)
|
|
145
|
+
return failed("tool.invalid-input", checked.error.issues[0]?.path[0] === "session"
|
|
146
|
+
? `session: own would run it in session ${session}, longer than a trigger's session may be: use a shorter id.`
|
|
147
|
+
: (checked.error.issues[0]?.message ?? "Not a trigger"));
|
|
148
|
+
const definition = checked.data;
|
|
149
|
+
const existing = await t.agentTrigger(s.agentId, input.id, { lock: true });
|
|
150
|
+
if (existing && (originOf(existing) !== "agent" || existing.createdSessionId !== s.id))
|
|
151
|
+
return failed("tool.conflict", `The id ${input.id} is taken by another trigger of this Agent: choose another id.`);
|
|
152
|
+
const runtime = await readRuntimeLimits(t);
|
|
153
|
+
const agent = await liveEnvironment(t, s.agentId);
|
|
154
|
+
const inForce = triggerLimitsInForce(runtime, agent?.applied?.limits);
|
|
155
|
+
if (!existing) {
|
|
156
|
+
const made = (await t.agentTriggers(s.agentId)).filter((row) => originOf(row) === "agent").length;
|
|
157
|
+
if (made >= inForce.count)
|
|
158
|
+
return failed("tool.limit", `This Agent has ${made} triggers, the most it can have. Delete one to add another.`);
|
|
159
|
+
}
|
|
160
|
+
const issues = triggerIssues({ [input.id]: definition }, { ...runtime, triggersPerAgent: inForce.count, triggerMinIntervalMs: inForce.minIntervalMs }, now, (id) => id);
|
|
161
|
+
if (issues.length > 0)
|
|
162
|
+
return failed("tool.invalid-input", issues.join(" "));
|
|
163
|
+
const next = nextFireAt(s.agentId, input.id, definition, now, runtime);
|
|
164
|
+
const row = {
|
|
165
|
+
agentId: s.agentId,
|
|
166
|
+
triggerId: input.id,
|
|
167
|
+
kind: triggerKindOf(definition),
|
|
168
|
+
status: next === null ? "completed" : "active",
|
|
169
|
+
pausedReason: null,
|
|
170
|
+
generation: (existing?.generation ?? -1) + 1,
|
|
171
|
+
nextFireAt: next?.toISOString() ?? null,
|
|
172
|
+
lastFireAt: existing?.lastFireAt ?? null,
|
|
173
|
+
endpointId: null,
|
|
174
|
+
sealedSecret: null,
|
|
175
|
+
sessionId: session,
|
|
176
|
+
note: null,
|
|
177
|
+
effectId: request.effectId,
|
|
178
|
+
origin: "agent",
|
|
179
|
+
title: input.title ?? null,
|
|
180
|
+
definition,
|
|
181
|
+
createdSessionId: s.id,
|
|
182
|
+
createdTurnId: s.activeTurnId,
|
|
183
|
+
createdAt: existing?.createdAt ?? stamp,
|
|
184
|
+
updatedAt: stamp,
|
|
185
|
+
};
|
|
186
|
+
await t.putAgentTrigger(row);
|
|
187
|
+
await t.agentEvent(s.agentId, existing ? "agent.trigger_updated" : "agent.trigger_created", {
|
|
188
|
+
triggerId: input.id,
|
|
189
|
+
kind: definition.schedule === undefined ? "at" : "schedule",
|
|
190
|
+
sessionId: s.id,
|
|
191
|
+
session,
|
|
192
|
+
...(row.title === null ? {} : { title: row.title }),
|
|
193
|
+
nextRunAt: row.nextFireAt,
|
|
194
|
+
});
|
|
195
|
+
return completed({ id: input.id, status: row.status, nextRunAt: row.nextFireAt, session, now: stamp }, [row]);
|
|
196
|
+
}
|
package/dist/tenant/agents.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/tenant/agents.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tenant/limits.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What artifacts, skills and disk versions may hold
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/tenant/limits.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What artifacts, skills and disk versions may hold
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
+
}
|
package/dist/tenant/sweep.js
CHANGED
|
@@ -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.
|