a2a-exposed 0.8.38 → 0.8.40
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/lib/deploy.mjs +2 -2
- package/lib/hosted.mjs +31 -2
- package/lib/main.mjs +17 -3
- package/lib/status.mjs +9 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +3 -0
- package/worker/migrations/0016_wake_busy.sql +3 -0
- package/worker/src/index.ts +155 -21
- package/worker/src/migrations.ts +2 -0
- package/worker/src/tenancy.ts +5 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +9 -0
- package/worker/test/migrations.test.ts +20 -4
- package/worker/test/wake-latch.test.ts +182 -0
- package/worker/test/wake.test.ts +12 -1
package/lib/deploy.mjs
CHANGED
|
@@ -51,7 +51,7 @@ const DEPLOY_KEYS = [
|
|
|
51
51
|
"A2A_AGENT_NAME", "A2A_AGENT_DESCRIPTION", "A2A_AGENT_VERSION", "A2A_AGENT_SKILLS",
|
|
52
52
|
"A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
|
|
53
53
|
"WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
|
|
54
|
-
"WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
|
|
54
|
+
"WAKE_DEBOUNCE_SECONDS", "WAKE_BUSY_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
|
|
55
55
|
"A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS", "A2A_MCP",
|
|
56
56
|
"A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
|
|
57
57
|
"APPROVAL_OIDC_ISSUER", "APPROVAL_OIDC_CLIENT_ID", "APPROVAL_OIDC_ALLOWED_SUBJECTS", "APPROVAL_METHODS",
|
|
@@ -63,7 +63,7 @@ const FLAG_KEYS = {
|
|
|
63
63
|
"agent-name": "A2A_AGENT_NAME", "agent-description": "A2A_AGENT_DESCRIPTION", "agent-skills": "A2A_AGENT_SKILLS",
|
|
64
64
|
"provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
|
|
65
65
|
preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
|
|
66
|
-
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
66
|
+
"body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", busy: "WAKE_BUSY_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
|
|
67
67
|
"pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS", mcp: "A2A_MCP",
|
|
68
68
|
upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
|
|
69
69
|
"manage-url": "MANAGE_URL", "event-url": "EVENT_URL",
|
package/lib/hosted.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import * as C from "./config.mjs";
|
|
|
4
4
|
import { controlOrigin, deviceLogin } from "./account.mjs";
|
|
5
5
|
import { CliError, upstreamUrlProblem } from "./a2a.mjs";
|
|
6
6
|
|
|
7
|
-
export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test"];
|
|
7
|
+
export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test", "upstream:write", "upstream:test"];
|
|
8
8
|
const SETUP_UNSUPPORTED = "This service does not expose scoped hosted setup capabilities. Check the --control-url service origin. Use the service dashboard to create an agent and configure Wake settings, or hosted MCP create_agent/set_wake with human confirmation and secret entry. Do not run init or deploy to repair a hosted address; no setup approval was requested.";
|
|
9
9
|
const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
|
|
10
10
|
const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
|
|
@@ -62,6 +62,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
|
|
|
62
62
|
const code = known.includes(data.error) ? data.error : `http_${response.status}`;
|
|
63
63
|
const next = code === "taken" || code === "reserved_name" ? "Choose another --name using hosted names --name NAME --json before creating."
|
|
64
64
|
: code === "limit" ? "The account is at its agent limit. Read hosted capacity --json and review account plans."
|
|
65
|
+
: code === "conflict" && path.includes("/upstream") ? "Upstream settings changed. Run hosted upstream preview --agent NAME --json, then repeat with its explicit --version."
|
|
65
66
|
: code === "conflict" ? "Wake settings changed. Run wake preview --json, then test the draft again."
|
|
66
67
|
: code === "invalid_reference" ? "The wake reference was already used, expired or does not match this draft. Run wake prepare again with the draft inputs and a new --reference-file."
|
|
67
68
|
: code === "invalid_grant" || code === "insufficient_scope" ? NEXT
|
|
@@ -74,7 +75,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
|
|
|
74
75
|
export async function capabilities(o = {}) {
|
|
75
76
|
const data = await request(originFor(o), "/api/v1/setup/capabilities");
|
|
76
77
|
if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", SETUP_UNSUPPORTED);
|
|
77
|
-
return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
|
|
78
|
+
return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), upstream: data.upstream?.configuration === true && data.upstream?.verify === true, preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export async function login(o = {}) {
|
|
@@ -283,6 +284,34 @@ export async function wake(sub, o = {}) {
|
|
|
283
284
|
return { ok, code, saved: result.saved === true, next: ok ? sub === "set" ? "Check the receiver for the test wake." : "The draft passed. Run wake set with the same inputs to test and save." : `Saved settings were not changed. ${recovery[code] || "Check the receiver, credentials and draft, then test again."}` };
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
/** Hosted upstream configuration uses only scoped setup or this agent's owner credential. */
|
|
288
|
+
export async function upstream(sub, o = {}) {
|
|
289
|
+
if (!["preview", "set", "clear", "verify"].includes(sub)) fail("usage", "Use hosted upstream preview|set|clear|verify --agent NAME --json. Select NAME with hosted list --json and scopes with hosted capabilities --json.");
|
|
290
|
+
const allowed = new Set(["agent", "json", "control-url", ...(sub === "preview" ? [] : ["version"]), ...(sub === "set" ? ["url", "upstream-token-file", "upstream-token-env", "access-client-id-file", "access-client-id-env", "access-client-secret-file", "access-client-secret-env"] : [])]);
|
|
291
|
+
if (Object.keys(o).some(key => !allowed.has(key))) fail("usage", "Use only the options for hosted upstream preview|set|clear|verify shown in --help; secret inputs belong only to set.");
|
|
292
|
+
if (sub !== "preview" && (!/^(0|[1-9][0-9]*)$/.test(String(o.version ?? "")) || !Number.isSafeInteger(Number(o.version)))) fail("version", "Pass --version N from hosted upstream preview --agent NAME --json; no version is selected automatically.");
|
|
293
|
+
let body = { version: Number(o.version) };
|
|
294
|
+
if (sub === "set") {
|
|
295
|
+
const url = String(o.url || "");
|
|
296
|
+
if (!url || upstreamUrlProblem(url)) fail("url", "Pass --url with a public HTTPS A2A address; use hosted upstream clear to remove it.");
|
|
297
|
+
body = { ...body, url, token: secret(o, "upstream-token", "UPSTREAM_TOKEN"), accessClientId: secret(o, "access-client-id", "UPSTREAM_ACCESS_CLIENT_ID"), accessClientSecret: secret(o, "access-client-secret", "UPSTREAM_ACCESS_CLIENT_SECRET") };
|
|
298
|
+
if (Boolean(body.accessClientId) !== Boolean(body.accessClientSecret)) fail("access", "Supply both Access credentials through protected files or named environment variables. A new address requires both.");
|
|
299
|
+
}
|
|
300
|
+
const { origin, token, name } = wakeCredential(o, true);
|
|
301
|
+
if (!(await capabilities({ "control-url": origin })).upstream) fail("unsupported", "This service does not expose upstream configuration. Run hosted capabilities --json before selecting an operation.");
|
|
302
|
+
const path = `/api/v1/agents/${encodeURIComponent(name)}/upstream`;
|
|
303
|
+
const current = await request(origin, path, { token });
|
|
304
|
+
if (!Number.isSafeInteger(current.version) || typeof current.configured !== "boolean") fail("invalid_response", "No valid upstream version was returned. Run hosted upstream preview --agent NAME --json before retrying.");
|
|
305
|
+
if (sub === "preview") return { ok: true, code: "upstream", version: current.version, configured: current.configured, next: "Review this version, then pass --version N to set, clear or verify. Addresses and credentials are never printed." };
|
|
306
|
+
|
|
307
|
+
if (Number(o.version) !== current.version) fail("conflict", "Upstream settings changed. Run hosted upstream preview --agent NAME --json, review the version and repeat the explicit operation.");
|
|
308
|
+
|
|
309
|
+
const result = await request(origin, sub === "verify" ? `${path}/verify` : path, { token, method: sub === "verify" ? "POST" : sub === "clear" ? "DELETE" : "PUT", body });
|
|
310
|
+
const reasons = ["reachable", "access_credentials_missing", "access_rejected", "upstream_auth_missing", "upstream_auth_rejected", "tunnel_down", "upstream_unavailable", "network", "unexpected_response", "misconfigured"];
|
|
311
|
+
const ok = result.ok === true && (sub === "verify" ? result.reason === "reachable" : sub === "clear" ? result.removed === true : result.saved === true);
|
|
312
|
+
return { ok, code: sub === "verify" ? reasons.includes(result.reason) ? result.reason : "unexpected_response" : ok ? sub === "clear" ? "removed" : "saved" : "failed", ...(Number.isSafeInteger(result.version) ? { version: result.version } : {}), next: sub === "verify" ? ok ? "The saved upstream is reachable. You may pair and send a message." : "Check the connection layer named by code, then verify again. Keep Access and agent authentication enabled." : "Run hosted upstream preview --json, then verify with its explicit version before pairing." };
|
|
313
|
+
}
|
|
314
|
+
|
|
286
315
|
export async function output(run, o = {}) {
|
|
287
316
|
try {
|
|
288
317
|
const result = await run();
|
package/lib/main.mjs
CHANGED
|
@@ -36,9 +36,19 @@ Hosted setup:
|
|
|
36
36
|
check one name anonymously before device approval; never lists other agents
|
|
37
37
|
hosted capacity --json read account room with agents:read (or agents:create); reserves nothing
|
|
38
38
|
hosted list --json list hosted agents with agents:read or agents:create before choosing an agent
|
|
39
|
+
hosted upstream preview|set|clear|verify --agent NAME --json
|
|
40
|
+
list scopes with hosted capabilities and agents with hosted list first
|
|
41
|
+
preview returns version; set/clear/verify require --version N
|
|
42
|
+
set takes --url URL and --upstream-token-file FILE|--upstream-token-env VAR,
|
|
43
|
+
--access-client-id-file FILE|--access-client-id-env VAR and
|
|
44
|
+
--access-client-secret-file FILE|--access-client-secret-env VAR
|
|
45
|
+
files require mode 600; defaults UPSTREAM_TOKEN/UPSTREAM_ACCESS_CLIENT_ID/
|
|
46
|
+
UPSTREAM_ACCESS_CLIENT_SECRET; never pass secret values as arguments
|
|
39
47
|
hosted create --name NAME --json
|
|
40
48
|
create and save the owner credential in this config directory;
|
|
41
49
|
use a separate --config-dir for each agent
|
|
50
|
+
manual fallback: human creates the address in a signed-in dashboard session,
|
|
51
|
+
then saves A2A_BASE_URL + A2A_OWNER_TOKEN in protected config or routine secrets
|
|
42
52
|
wake preview|test|set --agent NAME [--url URL] [--preset P] --json
|
|
43
53
|
hosted settings: read, test a draft, or test then save it
|
|
44
54
|
secrets: --key-file, --key-env, --key-stdin, --hmac-secret-file, --hmac-secret-env,
|
|
@@ -66,7 +76,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
66
76
|
init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
|
|
67
77
|
[--agent-name N] [--agent-description D] [--agent-skills JSON]
|
|
68
78
|
[--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
|
|
69
|
-
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
79
|
+
[--account-id ID] [--cf-profile NAME] [--cli-command CMD] [--debounce S] [--busy S] [--max-per-hour N]
|
|
70
80
|
[--pairing-approval human|agent|off] [--pbkdf2-iterations N] [--workers-logs on|off] [--mcp on|off] [--cron]
|
|
71
81
|
[--manage-url URL] [--event-url URL] [--event-secret-stdin]
|
|
72
82
|
[--rotate-owner-token]
|
|
@@ -131,12 +141,15 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
131
141
|
<config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
|
|
132
142
|
deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
|
|
133
143
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
134
|
-
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
144
|
+
[--cli-command CMD] [--debounce S] [--busy S] [--max-per-hour N]
|
|
135
145
|
self-hosted: save wake settings, upload wake secrets from env, and redeploy
|
|
136
146
|
hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
|
|
137
147
|
hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
|
|
138
148
|
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
139
149
|
the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
|
|
150
|
+
--busy S seconds a conversation stays busy after a sent wake.
|
|
151
|
+
0 disables it. Unset uses the preset default:
|
|
152
|
+
300 for claude-code, 180 for the others
|
|
140
153
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
141
154
|
which defaults to 25 when this is unset
|
|
142
155
|
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
@@ -397,7 +410,7 @@ const deployOpts = {
|
|
|
397
410
|
"workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
|
|
398
411
|
};
|
|
399
412
|
const SPEC = {
|
|
400
|
-
hosted: { name: S, json: B, agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
|
|
413
|
+
hosted: { url: S, version: S, "upstream-token-file": S, "upstream-token-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S, name: S, json: B, agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
|
|
401
414
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
|
|
402
415
|
wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, clear: B, version: S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
|
|
403
416
|
inbox: { context: S, from: S, all: B, json: B },
|
|
@@ -523,6 +536,7 @@ export async function main(argv) {
|
|
|
523
536
|
throw new CliError(`unknown peer action ${sub} (announce|forward)`);
|
|
524
537
|
}
|
|
525
538
|
case "hosted": {
|
|
539
|
+
if (p[0] === "upstream" && p.length === 2) return hosted.output(() => hosted.upstream(p[1], o), o);
|
|
526
540
|
if (p.length === 1 && ["capabilities", "list", "create", "names", "capacity"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
|
|
527
541
|
if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) return linkOutput(linkUsage, o);
|
|
528
542
|
return linkOutput(() => (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") }), o);
|
package/lib/status.mjs
CHANGED
|
@@ -198,7 +198,12 @@ export function wakeLine(s) {
|
|
|
198
198
|
? "no webhook configured; choose delivery after checking hosted route compatibility"
|
|
199
199
|
: "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
|
|
200
200
|
const auth = [w.hasKey && "bearer/API key", w.hasHmacSecret && "HMAC signature", w.hasAccessServiceToken && "Access service token"].filter(Boolean);
|
|
201
|
-
|
|
201
|
+
const timing = [
|
|
202
|
+
Number.isFinite(Number(w.debounceSeconds)) ? `debounce ${w.debounceSeconds}s` : "",
|
|
203
|
+
Number.isFinite(Number(w.busySeconds)) ? `busy ${w.busySeconds}s` : "",
|
|
204
|
+
].filter(Boolean);
|
|
205
|
+
const timingText = timing.length ? `; ${timing.join(", ")}` : "";
|
|
206
|
+
return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""})${timingText}; auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
|
|
202
207
|
}
|
|
203
208
|
|
|
204
209
|
// ---------------------------------------------------------------- proxy mode: upstream diagnosis
|
|
@@ -487,7 +492,9 @@ export async function collect(net = {}) {
|
|
|
487
492
|
const p = await owner("GET", "/owner/wake/preview");
|
|
488
493
|
s.ownerApi = { ok: true };
|
|
489
494
|
s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
|
|
490
|
-
hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null
|
|
495
|
+
hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null,
|
|
496
|
+
...(Number.isFinite(Number(p.debounceSeconds)) ? { debounceSeconds: Number(p.debounceSeconds) } : {}),
|
|
497
|
+
...(Number.isFinite(Number(p.busySeconds)) ? { busySeconds: Number(p.busySeconds) } : {}) };
|
|
491
498
|
} catch (e) {
|
|
492
499
|
s.ownerApi = { ok: false, error: e.message };
|
|
493
500
|
if (s.deployment === "hosted" && /HTTP (404|405)\b/.test(e.message)) {
|
package/package.json
CHANGED
|
@@ -35,7 +35,10 @@ const plain: Record<string, string> = {
|
|
|
35
35
|
WAKE_BODY_TEMPLATE: v("WAKE_BODY_TEMPLATE"), // generic preset JSON template
|
|
36
36
|
WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx -y a2a-exposed@latest")
|
|
37
37
|
WAKE_DEBOUNCE_SECONDS: v("WAKE_DEBOUNCE_SECONDS"),
|
|
38
|
+
WAKE_BUSY_SECONDS: v("WAKE_BUSY_SECONDS"),
|
|
38
39
|
WAKE_MAX_PER_HOUR: v("WAKE_MAX_PER_HOUR"),
|
|
40
|
+
// Visible at runtime so a busy catch-up is held only when this minute cron will flush it.
|
|
41
|
+
ENABLE_CRON: v("A2A_ENABLE_CRON") === "1" ? "1" : "",
|
|
39
42
|
MAX_BODY: v("A2A_MAX_BODY"),
|
|
40
43
|
RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
|
|
41
44
|
RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
|
package/worker/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { VERSION } from "./version.ts";
|
|
8
8
|
import * as A from "./a2a.ts";
|
|
9
9
|
import * as F from "./facade.ts";
|
|
10
|
-
import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
10
|
+
import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultBusySeconds, defaultDebounceSeconds, defaultMaxPerHour, type WakeConfig, type WakeEvent } from "./wake.ts";
|
|
11
11
|
import { fetchCheckedPeer, fetchCheckedWake } from "./wake-target.ts";
|
|
12
12
|
import { withDeadline } from "./deadline.ts";
|
|
13
13
|
import * as Addr from "./address.ts";
|
|
@@ -174,6 +174,50 @@ function wakeConfig(ctx: TenantContext): WakeConfig {
|
|
|
174
174
|
|
|
175
175
|
const debounceMs = (ctx: TenantContext) => Number(ctx.WAKE_DEBOUNCE_SECONDS || defaultDebounceSeconds(preset(ctx))) * 1000;
|
|
176
176
|
|
|
177
|
+
/** Kinds that share a conversation busy window. Pairing, address, and test wakes send on their own. */
|
|
178
|
+
const MESSAGE_WAKE_KINDS = new Set(["inbound", "outbound_update"]);
|
|
179
|
+
|
|
180
|
+
function messageWake(kind: string): boolean {
|
|
181
|
+
return MESSAGE_WAKE_KINDS.has(kind);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function wakeHooked(ctx: TenantContext): boolean {
|
|
185
|
+
return !!(ctx.WAKE_WEBHOOK_URL || "").trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Busy cool-down after a sent message wake. Empty uses the preset default.
|
|
190
|
+
* An explicit 0 disables it. A non-numeric value uses the preset default.
|
|
191
|
+
*/
|
|
192
|
+
function busyMs(ctx: TenantContext): number {
|
|
193
|
+
const raw = (ctx.WAKE_BUSY_SECONDS ?? "").trim();
|
|
194
|
+
const n = raw === "" ? defaultBusySeconds(preset(ctx)) : Number(raw);
|
|
195
|
+
if (!Number.isFinite(n) || n < 0) return defaultBusySeconds(preset(ctx)) * 1000;
|
|
196
|
+
return Math.floor(n) * 1000;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Due time of a pending wake: the busy deadline when the row is held, otherwise last send plus the debounce. */
|
|
200
|
+
const WAKE_DUE_SQL = `CASE
|
|
201
|
+
WHEN json_extract(pending_json, '$.busy') = 1 AND busy_until_ms IS NOT NULL THEN busy_until_ms
|
|
202
|
+
ELSE last_sent_ms + ?
|
|
203
|
+
END`;
|
|
204
|
+
|
|
205
|
+
/** A catch-up past the in-request wait needs the hosted alarm or the self-host minute cron. */
|
|
206
|
+
function busyScheduled(ctx: TenantContext, ectx: object): boolean {
|
|
207
|
+
return wakeWriteHooks.has(ectx) || ctx.ENABLE_CRON === "1";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function busyActive(ctx: TenantContext, ectx: object, row: Json | null, now: number): boolean {
|
|
211
|
+
if (!busyScheduled(ctx, ectx) || busyMs(ctx) <= 0) return false;
|
|
212
|
+
const until = Number(row?.busy_until_ms);
|
|
213
|
+
return Number.isFinite(until) && until > now;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Seconds `wake preview` reports. 0 when the cool-down is disabled or nothing will flush a catch-up. */
|
|
217
|
+
function busySecondsReported(ctx: TenantContext, ectx: object): number {
|
|
218
|
+
return busyScheduled(ctx, ectx) ? busyMs(ctx) / 1000 : 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
177
221
|
/**
|
|
178
222
|
* Hourly wake cap. 0 means unlimited, including an explicit "0".
|
|
179
223
|
* An empty or unset value uses the preset default (unlimited, or 25 for Claude Code).
|
|
@@ -208,13 +252,21 @@ async function noteWakeWrite(ectx: object): Promise<void> {
|
|
|
208
252
|
}
|
|
209
253
|
|
|
210
254
|
async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json): Promise<{ status: number | null; info: string }> {
|
|
211
|
-
const
|
|
255
|
+
const catchUp = !!payload.busy;
|
|
256
|
+
const delivered = { ...payload };
|
|
257
|
+
delete delivered.busy;
|
|
258
|
+
const ev: WakeEvent = { ...delivered, publicUrl: ctx.PUBLIC_URL };
|
|
212
259
|
let req;
|
|
213
260
|
try { req = await renderWake(wakeConfig(ctx), ev); } catch (e) {
|
|
214
261
|
log("wake_render_failed", { preset: preset(ctx), error: String(e).slice(0, 200) });
|
|
262
|
+
await clearBusy(ctx, payload);
|
|
215
263
|
return { status: null, info: "render failed: " + String(e).slice(0, 200) };
|
|
216
264
|
}
|
|
217
|
-
if (!req) {
|
|
265
|
+
if (!req) {
|
|
266
|
+
log("wake_skipped", { contextId: ev.contextId, reason: "WAKE_WEBHOOK_URL unset" });
|
|
267
|
+
await clearBusy(ctx, payload);
|
|
268
|
+
return { status: null, info: "WAKE_WEBHOOK_URL unset" };
|
|
269
|
+
}
|
|
218
270
|
try {
|
|
219
271
|
let r: Response;
|
|
220
272
|
if (ctx.gates.wakeTargetPolicy === "public-https") {
|
|
@@ -224,6 +276,7 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
224
276
|
});
|
|
225
277
|
if ("refused" in checked) {
|
|
226
278
|
log("wake_refused", { preset: preset(ctx), contextId: ev.contextId, reason: checked.refused });
|
|
279
|
+
await clearBusy(ctx, payload);
|
|
227
280
|
return { status: null, info: `refused: ${checked.refused}` };
|
|
228
281
|
}
|
|
229
282
|
r = checked.response;
|
|
@@ -234,11 +287,17 @@ async function sendWake(ctx: TenantContext, ectx: ExecutionContext, payload: Jso
|
|
|
234
287
|
const hint = r.ok ? "" : cloudflareErrorHint(r.status, (await r.text().catch(() => "")).slice(0, 4096), {
|
|
235
288
|
tunnel: !!(ctx.WAKE_ACCESS_CLIENT_ID && ctx.WAKE_ACCESS_CLIENT_SECRET) || wakeUrlIsTunnelHost(req.url),
|
|
236
289
|
});
|
|
237
|
-
log("wake_sent", { preset: preset(ctx), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(hint ? { hint } : {}) });
|
|
238
|
-
if (r.
|
|
290
|
+
log("wake_sent", { preset: preset(ctx), contextId: ev.contextId, taskId: ev.taskId, status: r.status, ...(catchUp ? { catchUp: true } : {}), ...(hint ? { hint } : {}) });
|
|
291
|
+
if (r.ok) await armBusy(ctx, ectx, delivered, Date.now());
|
|
292
|
+
else if (r.status === 429 || r.status === 503) {
|
|
293
|
+
// Retry-After owns the next POST. Drop the busy window so the retry is not held behind it.
|
|
294
|
+
await clearBusy(ctx, payload);
|
|
295
|
+
await requeueWake(ctx, ectx, payload, r.headers.get("retry-after"));
|
|
296
|
+
} else await clearBusy(ctx, payload);
|
|
239
297
|
return { status: r.status, info: `HTTP ${r.status}${hint ? ` (${hint})` : ""}` };
|
|
240
298
|
} catch (e) {
|
|
241
299
|
log("wake_failed", { preset: preset(ctx), contextId: ev.contextId, error: String(e).slice(0, 200) });
|
|
300
|
+
await clearBusy(ctx, payload);
|
|
242
301
|
return { status: null, info: String(e).slice(0, 200) };
|
|
243
302
|
}
|
|
244
303
|
}
|
|
@@ -264,6 +323,52 @@ async function loadWake(ctx: TenantContext, contextId: string, peer: string): Pr
|
|
|
264
323
|
return ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ''").bind(contextId).first();
|
|
265
324
|
}
|
|
266
325
|
|
|
326
|
+
/** The busy window starts at this send and does not move when later events are absorbed. */
|
|
327
|
+
async function armBusy(ctx: TenantContext, ectx: object, payload: Json, now: number): Promise<void> {
|
|
328
|
+
const wait = busyMs(ctx);
|
|
329
|
+
if (wait <= 0 || !busyScheduled(ctx, ectx) || !messageWake(String(payload?.kind || "")) || !wakeHooked(ctx)) return;
|
|
330
|
+
const from = typeof payload.from === "string" ? payload.from : "";
|
|
331
|
+
const contextId = typeof payload.contextId === "string" ? payload.contextId : "";
|
|
332
|
+
const row = await loadWake(ctx, contextId, from);
|
|
333
|
+
if (!row) return;
|
|
334
|
+
const peer = typeof row.peer === "string" ? row.peer : "";
|
|
335
|
+
await ctx.DB.prepare("UPDATE wakes SET busy_until_ms = ? WHERE context_id = ? AND peer = ?")
|
|
336
|
+
.bind(now + wait, contextId, peer).run();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A send did not stick. Drop the busy window and leave any absorbed tasks as an ordinary pending wake.
|
|
340
|
+
* The pending text is replaced only when it still matches the row just read, and a lost race retries. */
|
|
341
|
+
async function clearBusy(ctx: TenantContext, payload: Json): Promise<void> {
|
|
342
|
+
if (!messageWake(String(payload?.kind || ""))) return;
|
|
343
|
+
const from = typeof payload.from === "string" ? payload.from : "";
|
|
344
|
+
const contextId = typeof payload.contextId === "string" ? payload.contextId : "";
|
|
345
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
346
|
+
const row = await loadWake(ctx, contextId, from);
|
|
347
|
+
if (!row) return;
|
|
348
|
+
const peer = typeof row.peer === "string" ? row.peer : "";
|
|
349
|
+
const original = typeof row.pending_json === "string" ? row.pending_json : null;
|
|
350
|
+
let next = original;
|
|
351
|
+
if (typeof next === "string") {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(next);
|
|
354
|
+
if (parsed && typeof parsed === "object" && parsed.busy) {
|
|
355
|
+
delete parsed.busy;
|
|
356
|
+
next = JSON.stringify(parsed);
|
|
357
|
+
}
|
|
358
|
+
} catch { /* keep the stored text */ }
|
|
359
|
+
}
|
|
360
|
+
if (row.busy_until_ms == null && next === original) return;
|
|
361
|
+
const args: unknown[] = [next, contextId, peer];
|
|
362
|
+
const pendingMatch = original == null ? "pending_json IS NULL" : "pending_json = ?";
|
|
363
|
+
if (original != null) args.push(original);
|
|
364
|
+
const res = await ctx.DB.prepare(
|
|
365
|
+
`UPDATE wakes SET busy_until_ms = NULL, pending_json = ? WHERE context_id = ? AND peer = ? AND ${pendingMatch}`,
|
|
366
|
+
).bind(...args).run();
|
|
367
|
+
if (changed(res) === 1) return;
|
|
368
|
+
}
|
|
369
|
+
log("wake_busy_clear_failed", { contextId });
|
|
370
|
+
}
|
|
371
|
+
|
|
267
372
|
async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload: Json, retryAfter: string | null) {
|
|
268
373
|
if (payload.kind !== "inbound") return;
|
|
269
374
|
const attempts = (Number(payload.attempts) || 0) + 1;
|
|
@@ -275,14 +380,17 @@ async function requeueWake(ctx: TenantContext, ectx: ExecutionContext, payload:
|
|
|
275
380
|
const cur: Json = await loadWake(ctx, contextId, peer);
|
|
276
381
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
277
382
|
const merged = { ...payload, attempts, taskIds: [...new Set([...(prev?.taskIds || []), ...(payload.taskIds || [])])] };
|
|
383
|
+
delete merged.busy;
|
|
278
384
|
// flushWake and nextCronAt wait until last_sent_ms + debounce. Store Retry-After minus that debounce so the
|
|
279
385
|
// deadline is Retry-After itself, including when the debounce is longer than Retry-After. Do not keep the
|
|
280
386
|
// previous last_sent_ms: that clock is the send we just made, and keeping it would wait out the debounce.
|
|
387
|
+
// A retry is not a busy wake: the cool-down stays clear until a 2xx.
|
|
281
388
|
const due = now + waitMs - debounceMs(ctx);
|
|
282
389
|
const rowPeer = cur && (cur.peer === peer || cur.peer === "" || cur.peer == null) ? (cur.peer ?? "") : peer;
|
|
283
390
|
await ctx.DB.prepare(`INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?, ?)
|
|
284
391
|
ON CONFLICT(context_id, peer) DO UPDATE SET pending_json = excluded.pending_json, last_sent_ms = excluded.last_sent_ms,
|
|
285
|
-
pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms)
|
|
392
|
+
pending_since_ms = COALESCE(wakes.pending_since_ms, excluded.pending_since_ms),
|
|
393
|
+
busy_until_ms = NULL`).bind(contextId, rowPeer === "" ? peer : rowPeer, due, JSON.stringify(merged), now).run();
|
|
286
394
|
log("wake_requeued", { contextId, retryInS: Math.round(waitMs / 1000), attempt: attempts });
|
|
287
395
|
// The row exists now. A hosted alarm has to be set from this turn: the request that started sendWake may already have returned.
|
|
288
396
|
await noteWakeWrite(ectx);
|
|
@@ -303,25 +411,42 @@ async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: strin
|
|
|
303
411
|
} else row = null;
|
|
304
412
|
}
|
|
305
413
|
const peer = row?.peer ?? from;
|
|
414
|
+
const suppress = wakeHooked(ctx) && messageWake(kind) && busyActive(ctx, ectx, row, now);
|
|
306
415
|
let claimed = false;
|
|
307
416
|
if (!row) {
|
|
308
417
|
const r = await ctx.DB.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms) VALUES (?, ?, ?) ON CONFLICT DO NOTHING").bind(contextId, peer, now).run();
|
|
309
418
|
claimed = r.meta.changes === 1;
|
|
310
|
-
} else if (!row.pending_json && now - row.last_sent_ms >= deb) {
|
|
419
|
+
} else if (!suppress && !row.pending_json && now - row.last_sent_ms >= deb) {
|
|
311
420
|
const r = await ctx.DB.prepare("UPDATE wakes SET last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NULL")
|
|
312
421
|
.bind(now, contextId, peer, row.last_sent_ms).run();
|
|
313
422
|
claimed = r.meta.changes === 1;
|
|
314
423
|
}
|
|
315
|
-
if (claimed && (await wakeBudgetOk(ctx))) {
|
|
316
|
-
|
|
424
|
+
if (claimed && (await wakeBudgetOk(ctx))) {
|
|
425
|
+
// Cover the in-flight POST. sendWake refreshes the busy window on 2xx and clears it otherwise.
|
|
426
|
+
if (messageWake(kind) && wakeHooked(ctx)) await armBusy(ctx, ectx, payload, now);
|
|
427
|
+
ectx.waitUntil(sendWake(ctx, ectx, payload));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// Debounce window, hourly cap, or a busy conversation: merge into the pending wake.
|
|
431
|
+
// Absorbing does not move busy_until_ms. The deadline stays the successful send plus the cool-down.
|
|
317
432
|
const cur: Json = await loadWake(ctx, contextId, from);
|
|
318
433
|
const curPeer = cur?.peer ?? peer;
|
|
319
434
|
const prev = cur?.pending_json ? JSON.parse(cur.pending_json) : null;
|
|
435
|
+
const sentAt = Number(cur?.last_sent_ms) || 0;
|
|
436
|
+
const hold = wakeHooked(ctx) && messageWake(kind) && busyActive(ctx, ectx, cur, now) && !prev?.attempts && now - sentAt >= deb;
|
|
437
|
+
const keeping = !!(prev?.busy && busyActive(ctx, ectx, cur, now) && !prev?.attempts);
|
|
320
438
|
payload.taskIds = [...new Set([...(prev?.taskIds || []), taskId])];
|
|
439
|
+
if (hold || keeping) payload.busy = 1;
|
|
321
440
|
await ctx.DB.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = COALESCE(pending_since_ms, ?) WHERE context_id = ? AND peer = ?")
|
|
322
441
|
.bind(JSON.stringify(payload), now, contextId, curPeer).run();
|
|
323
442
|
// The pending row exists now. A hosted alarm is set in this turn, including when the delay is too long for waitUntil.
|
|
324
443
|
await noteWakeWrite(ectx);
|
|
444
|
+
// A busy hold is taken only when an alarm or the minute cron will flush the catch-up.
|
|
445
|
+
// waitUntil cannot cover that window, so there is no in-request timer on this path.
|
|
446
|
+
if (payload.busy) {
|
|
447
|
+
log("wake_absorbed", { contextId, kind, tasks: payload.taskIds.length });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
325
450
|
const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
|
|
326
451
|
// waitUntil can outlive the response by ~30s. Longer windows are flushed by a later request, the optional
|
|
327
452
|
// single-inbox cron, or the registered alarm (set above).
|
|
@@ -332,7 +457,10 @@ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId:
|
|
|
332
457
|
const row: Json = await ctx.DB.prepare("SELECT * FROM wakes WHERE context_id = ? AND peer = ?").bind(contextId, peer).first();
|
|
333
458
|
if (!row || !row.pending_json) return;
|
|
334
459
|
const now = Date.now();
|
|
335
|
-
|
|
460
|
+
const payload = JSON.parse(row.pending_json);
|
|
461
|
+
// A busy wake waits for its cool-down. A burst still waits out the short debounce.
|
|
462
|
+
if (payload?.busy && Number(row.busy_until_ms) > now) return;
|
|
463
|
+
if (!payload?.busy && now - row.last_sent_ms < debounceMs(ctx)) return;
|
|
336
464
|
if (maxPerHour(ctx)) {
|
|
337
465
|
const hour = Math.floor(now / 3600000);
|
|
338
466
|
const b: Json = await ctx.DB.prepare("SELECT count FROM wake_budget WHERE hour = ?").bind(hour).first();
|
|
@@ -341,13 +469,16 @@ async function flushWake(ctx: TenantContext, ectx: ExecutionContext, contextId:
|
|
|
341
469
|
const r = await ctx.DB.prepare(
|
|
342
470
|
"UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL, last_sent_ms = ? WHERE context_id = ? AND peer = ? AND last_sent_ms = ? AND pending_json IS NOT NULL",
|
|
343
471
|
).bind(now, contextId, peer, row.last_sent_ms).run();
|
|
344
|
-
if (r.meta.changes === 1 && (await wakeBudgetOk(ctx)))
|
|
472
|
+
if (r.meta.changes === 1 && (await wakeBudgetOk(ctx))) {
|
|
473
|
+
if (messageWake(String(payload?.kind || "")) && wakeHooked(ctx)) await armBusy(ctx, ectx, payload, now);
|
|
474
|
+
await sendWake(ctx, ectx, payload);
|
|
475
|
+
}
|
|
345
476
|
}
|
|
346
477
|
|
|
347
|
-
/** Flush every pending wake whose debounce
|
|
478
|
+
/** Flush every pending wake whose debounce or busy cool-down has passed (called opportunistically and from cron). */
|
|
348
479
|
async function flushDue(ctx: TenantContext, ectx: ExecutionContext) {
|
|
349
|
-
const rows = await ctx.DB.prepare(
|
|
350
|
-
.bind(Date.now()
|
|
480
|
+
const rows = await ctx.DB.prepare(`SELECT context_id, peer FROM wakes WHERE pending_json IS NOT NULL AND (${WAKE_DUE_SQL}) <= ? LIMIT 20`)
|
|
481
|
+
.bind(debounceMs(ctx), Date.now()).all();
|
|
351
482
|
for (const r of rows.results as Json[]) await flushWake(ctx, ectx, r.context_id, typeof r.peer === "string" ? r.peer : "");
|
|
352
483
|
}
|
|
353
484
|
|
|
@@ -366,8 +497,9 @@ export async function cronFlush(ctx: TenantContext, ectx: ExecutionContext): Pro
|
|
|
366
497
|
]);
|
|
367
498
|
}
|
|
368
499
|
|
|
369
|
-
async function minMs(ctx: TenantContext, sql: string, shift: (t: number) => number): Promise<number | null> {
|
|
370
|
-
const
|
|
500
|
+
async function minMs(ctx: TenantContext, sql: string, shift: (t: number) => number, args: unknown[] = []): Promise<number | null> {
|
|
501
|
+
const q = ctx.DB.prepare(sql);
|
|
502
|
+
const row = await (args.length ? q.bind(...args) : q).first<{ t: number | null }>();
|
|
371
503
|
if (!row || row.t == null) return null;
|
|
372
504
|
const t = Number(row.t);
|
|
373
505
|
return Number.isFinite(t) ? shift(t) : null;
|
|
@@ -380,7 +512,7 @@ async function minMs(ctx: TenantContext, sql: string, shift: (t: number) => numb
|
|
|
380
512
|
export async function nextCronAt(ctx: TenantContext, now = Date.now()): Promise<number | null> {
|
|
381
513
|
const times: number[] = [];
|
|
382
514
|
const deb = debounceMs(ctx);
|
|
383
|
-
const wake = await minMs(ctx,
|
|
515
|
+
const wake = await minMs(ctx, `SELECT MIN(${WAKE_DUE_SQL}) AS t FROM wakes WHERE pending_json IS NOT NULL`, (t) => t, [deb]);
|
|
384
516
|
if (wake != null) {
|
|
385
517
|
const cap = maxPerHour(ctx);
|
|
386
518
|
if (cap && wake <= now) {
|
|
@@ -1840,8 +1972,9 @@ async function ackOutboundReplies(ctx: TenantContext, snaps: ReplySnap[]): Promi
|
|
|
1840
1972
|
if (acked.length) await ackPendingWakes(ctx, acked, "outbound_update");
|
|
1841
1973
|
}
|
|
1842
1974
|
|
|
1843
|
-
/** Drop these task ids from the
|
|
1844
|
-
* outbound update that reuses the task id, stays pending. A wake that still names other tasks stays pending.
|
|
1975
|
+
/** Drop these task ids from the pending wake for that conversation. A wake for another conversation, or an
|
|
1976
|
+
* outbound update that reuses the task id, stays pending. A wake that still names other tasks stays pending.
|
|
1977
|
+
* This does not end the busy cool-down: a task that arrives after this read still waits for one catch-up. */
|
|
1845
1978
|
async function ackPendingWakes(ctx: TenantContext, seen: { taskId: string; contextId: string }[], kind = "inbound"): Promise<void> {
|
|
1846
1979
|
const byContext = new Map<string, Set<string>>();
|
|
1847
1980
|
for (const row of seen) {
|
|
@@ -1960,7 +2093,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1960
2093
|
});
|
|
1961
2094
|
}
|
|
1962
2095
|
}
|
|
1963
|
-
// Clear a pending wake only for tasks in this page.
|
|
2096
|
+
// Clear a pending wake only for tasks in this page. The busy cool-down stays until it expires.
|
|
2097
|
+
// Ids the caller did not get stay pending,
|
|
1964
2098
|
// including when one wake names tasks on this page and tasks on a later page.
|
|
1965
2099
|
// A reply is marked seen only when its stored update still equals the snapshot in this response.
|
|
1966
2100
|
// HEAD does not mark the inbox seen. The hosted owner RPC defers both clears until it has
|
|
@@ -2275,7 +2409,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
2275
2409
|
if (preset(ctx) === "openbot" && !ctx.WAKE_HMAC_SECRET?.trim())
|
|
2276
2410
|
return json({ error: "OpenBot requires WAKE_HMAC_SECRET from the routine settings" }, 400);
|
|
2277
2411
|
const req = await renderWake(wakeConfig(ctx), ev, { requestId: "preview" });
|
|
2278
|
-
return json({ preset: preset(ctx), configured: !!req, debounceSeconds: debounceMs(ctx) / 1000, maxPerHour: maxPerHour(ctx),
|
|
2412
|
+
return json({ preset: preset(ctx), configured: !!req, debounceSeconds: debounceMs(ctx) / 1000, busySeconds: busySecondsReported(ctx, ectx), maxPerHour: maxPerHour(ctx),
|
|
2279
2413
|
hasKey: !!ctx.WAKE_WEBHOOK_KEY, hasHmacSecret: !!ctx.WAKE_HMAC_SECRET,
|
|
2280
2414
|
hasAccessServiceToken: !!(ctx.WAKE_ACCESS_CLIENT_ID && ctx.WAKE_ACCESS_CLIENT_SECRET),
|
|
2281
2415
|
fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
|
package/worker/src/migrations.ts
CHANGED
|
@@ -15,6 +15,7 @@ import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
|
|
|
15
15
|
import m0013 from "../migrations/0013_reply_delivery.sql?raw";
|
|
16
16
|
import m0014 from "../migrations/0014_peer_address.sql?raw";
|
|
17
17
|
import m0015 from "../migrations/0015_outbound_source.sql?raw";
|
|
18
|
+
import m0016 from "../migrations/0016_wake_busy.sql?raw";
|
|
18
19
|
import type { Migration } from "./storage.ts";
|
|
19
20
|
|
|
20
21
|
export const MIGRATIONS: Migration[] = [
|
|
@@ -33,4 +34,5 @@ export const MIGRATIONS: Migration[] = [
|
|
|
33
34
|
{ name: "0013_reply_delivery.sql", sql: m0013 },
|
|
34
35
|
{ name: "0014_peer_address.sql", sql: m0014 },
|
|
35
36
|
{ name: "0015_outbound_source.sql", sql: m0015 },
|
|
37
|
+
{ name: "0016_wake_busy.sql", sql: m0016 },
|
|
36
38
|
];
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -35,7 +35,9 @@ export const CONFIG_KEYS = [
|
|
|
35
35
|
"PROVIDER_ORGANIZATION", "PROVIDER_URL", "DOCUMENTATION_URL",
|
|
36
36
|
"WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET",
|
|
37
37
|
"WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
|
|
38
|
-
"WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR",
|
|
38
|
+
"WAKE_DEBOUNCE_SECONDS", "WAKE_BUSY_SECONDS", "WAKE_MAX_PER_HOUR",
|
|
39
|
+
/** "1" when this self-host deploy has the minute cron. Hosted flushes from the inbox alarm instead. */
|
|
40
|
+
"ENABLE_CRON",
|
|
39
41
|
"MAX_BODY", "RATE_PER_MIN",
|
|
40
42
|
"PAIRING_APPROVAL", "PBKDF2_ITERATIONS",
|
|
41
43
|
"UPSTREAM_URL", "UPSTREAM_CARD_URL", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
|
|
@@ -376,7 +378,9 @@ export class TenantContext implements ConfigFields {
|
|
|
376
378
|
readonly WAKE_BODY_TEMPLATE?: string;
|
|
377
379
|
readonly WAKE_CLI_COMMAND?: string;
|
|
378
380
|
readonly WAKE_DEBOUNCE_SECONDS?: string;
|
|
381
|
+
readonly WAKE_BUSY_SECONDS?: string;
|
|
379
382
|
readonly WAKE_MAX_PER_HOUR?: string;
|
|
383
|
+
readonly ENABLE_CRON?: string;
|
|
380
384
|
readonly MAX_BODY?: string;
|
|
381
385
|
readonly RATE_PER_MIN?: string;
|
|
382
386
|
readonly PAIRING_APPROVAL?: string;
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.40";
|
package/worker/src/wake.ts
CHANGED
|
@@ -55,6 +55,15 @@ export function defaultDebounceSeconds(preset: string): number {
|
|
|
55
55
|
return preset === "claude-code" ? 20 : 10;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Default busy cool-down per preset (seconds), used when WAKE_BUSY_SECONDS is unset.
|
|
60
|
+
* These are starting points: a sent message wake holds further webhooks for this long, then one catch-up goes out.
|
|
61
|
+
* Claude Code is longer because each fire is a new session. An explicit 0 disables the cool-down.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultBusySeconds(preset: string): number {
|
|
64
|
+
return preset === "claude-code" ? 300 : 180;
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
68
|
* Default hourly wake cap per preset. 0 means unlimited. Claude Code routines allow 30 fires/hour,
|
|
60
69
|
* so that preset defaults to 25 only when WAKE_MAX_PER_HOUR is unset. An explicit 0 stays unlimited.
|
|
@@ -62,6 +62,7 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
62
62
|
assert.ok(columns(db, "peers").includes("identity_key"));
|
|
63
63
|
assert.ok(columns(db, "peers").includes("card_url"));
|
|
64
64
|
assert.ok(columns(db, "peers").includes("token_enc"));
|
|
65
|
+
assert.ok(columns(db, "wakes").includes("busy_until_ms"));
|
|
65
66
|
assert.ok(columns(db, "device_requests").includes("identity_key"));
|
|
66
67
|
assert.ok(columns(db, "device_requests").includes("replaces_label"));
|
|
67
68
|
assert.ok(columns(db, "device_requests").includes("replaces_hash"));
|
|
@@ -71,15 +72,15 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
71
72
|
test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
|
|
72
73
|
const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
|
|
73
74
|
db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
|
|
74
|
-
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
75
|
+
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql", "0016_wake_busy.sql"]);
|
|
75
76
|
assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
|
|
79
80
|
const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
|
|
80
81
|
db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
|
|
81
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
82
|
-
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
82
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql", "0016_wake_busy.sql"]);
|
|
83
|
+
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql", "0016_wake_busy.sql"]);
|
|
83
84
|
assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
|
|
84
85
|
});
|
|
85
86
|
|
|
@@ -111,7 +112,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
|
|
|
111
112
|
assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
|
|
112
113
|
assert.equal(wakes.length, 0);
|
|
113
114
|
|
|
114
|
-
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
115
|
+
assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql", "0016_wake_busy.sql"]);
|
|
115
116
|
assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
|
|
116
117
|
assert.deepEqual(applyMigrations(db), []);
|
|
117
118
|
|
|
@@ -174,6 +175,21 @@ test("0012 lets each peer own the same context id and keeps the first row", () =
|
|
|
174
175
|
assert.deepEqual(peers.map((r) => r.peer), ["ada", "bob"]);
|
|
175
176
|
});
|
|
176
177
|
|
|
178
|
+
test("0016 adds the wake busy column and keeps an existing wake", () => {
|
|
179
|
+
const prior = [...FILES].sort(byNumber).filter((f) => f !== "0016_wake_busy.sql");
|
|
180
|
+
const db = existingDb(prior.map(sql), prior);
|
|
181
|
+
const pending = JSON.stringify({ contextId: "ctx-y", from: "ada", taskId: "t1", taskIds: ["t1"], kind: "inbound", preview: "hi" });
|
|
182
|
+
db.prepare("INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES ('ctx-y', 'ada', 5, ?, 5)").run(pending);
|
|
183
|
+
assert.deepEqual(applyMigrations(db), ["0016_wake_busy.sql"]);
|
|
184
|
+
const row = db.prepare("SELECT pending_json, last_sent_ms, busy_until_ms FROM wakes WHERE context_id = 'ctx-y'").get() as {
|
|
185
|
+
pending_json: string; last_sent_ms: number; busy_until_ms: number | null;
|
|
186
|
+
};
|
|
187
|
+
assert.equal(row.pending_json, pending);
|
|
188
|
+
assert.equal(row.last_sent_ms, 5);
|
|
189
|
+
assert.equal(row.busy_until_ms, null);
|
|
190
|
+
assert.ok(columns(db, "wakes").includes("busy_until_ms"));
|
|
191
|
+
});
|
|
192
|
+
|
|
177
193
|
test("0014 adds address columns and keeps an existing peer token", () => {
|
|
178
194
|
const prior = [...FILES].sort(byNumber).filter((f) => f !== "0014_peer_address.sql");
|
|
179
195
|
const db = existingDb(prior.map(sql), prior);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// A sent message wake stays busy. Later events wait for one catch-up. An inbox read does not end that window.
|
|
2
|
+
// Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker, { nextCronAt } from "../src/index.ts";
|
|
6
|
+
import { d1 } from "./d1.ts";
|
|
7
|
+
import { resolveTenant } from "../src/tenancy.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = "https://agent.example.com";
|
|
10
|
+
const realNow = Date.now;
|
|
11
|
+
|
|
12
|
+
function envFor(over: Record<string, string> = {}) {
|
|
13
|
+
const env: Record<string, unknown> = {
|
|
14
|
+
DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret",
|
|
15
|
+
WAKE_PRESET: "generic", WAKE_WEBHOOK_URL: "https://hook.example.net/wake", WAKE_WEBHOOK_KEY: "k",
|
|
16
|
+
WAKE_DEBOUNCE_SECONDS: "10", WAKE_BUSY_SECONDS: "120", ENABLE_CRON: "1", ...over,
|
|
17
|
+
};
|
|
18
|
+
return env;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("busy cool-down, debounce, preset defaults, and an explicit 0", async (t) => {
|
|
22
|
+
let offset = 0;
|
|
23
|
+
Date.now = () => realNow() + offset;
|
|
24
|
+
const events: string[] = [];
|
|
25
|
+
const origLog = console.log;
|
|
26
|
+
console.log = (...args: unknown[]) => { events.push(String(args[0])); origLog(...args); };
|
|
27
|
+
const sent: any[] = [];
|
|
28
|
+
const origFetch = globalThis.fetch;
|
|
29
|
+
globalThis.fetch = (async (_url: unknown, init: any) => {
|
|
30
|
+
if (init?.body) sent.push(JSON.parse(String(init.body)));
|
|
31
|
+
return new Response("ok", { status: 200 });
|
|
32
|
+
}) as typeof fetch;
|
|
33
|
+
t.after(() => { globalThis.fetch = origFetch; Date.now = realNow; console.log = origLog; });
|
|
34
|
+
|
|
35
|
+
const call = async (env: any, method: string, path: string, json?: unknown, auth = "Bearer owner-secret") => {
|
|
36
|
+
const pending: Promise<unknown>[] = [];
|
|
37
|
+
const res = await worker.fetch(new Request(BASE + path, {
|
|
38
|
+
method,
|
|
39
|
+
headers: { "content-type": "application/json", authorization: auth },
|
|
40
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
41
|
+
}) as any, env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
42
|
+
await Promise.allSettled(pending);
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
let data: any = text;
|
|
45
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* plain */ }
|
|
46
|
+
return data;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Inbox read does not end the busy window. The next task is one catch-up, then a later task wakes.
|
|
50
|
+
const busy = envFor();
|
|
51
|
+
const token = (await call(busy, "POST", "/owner/peers", { label: "ada" })).token as string;
|
|
52
|
+
const taskIdOf = (data: any) => data?.result?.id || data?.result?.task?.id;
|
|
53
|
+
const msg = async (env: any, peer: string, contextId: string, text: string) => {
|
|
54
|
+
const data = await call(env, "POST", "/", {
|
|
55
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
56
|
+
params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), contextId, parts: [{ kind: "text", text }] } },
|
|
57
|
+
}, `Bearer ${peer}`);
|
|
58
|
+
const id = taskIdOf(data);
|
|
59
|
+
assert.equal(typeof id, "string", JSON.stringify(data));
|
|
60
|
+
return id as string;
|
|
61
|
+
};
|
|
62
|
+
const bodies = (contextId: string) => sent.filter((b) => b.contextId === contextId && b.from === "ada");
|
|
63
|
+
const row = () => busy.DB.db.prepare(
|
|
64
|
+
"SELECT pending_json, busy_until_ms, last_sent_ms FROM wakes WHERE context_id = 'ctx-busy' AND peer = 'ada'",
|
|
65
|
+
).get() as { pending_json: string | null; busy_until_ms: number | null; last_sent_ms: number };
|
|
66
|
+
|
|
67
|
+
const first = await msg(busy, token, "ctx-busy", "one");
|
|
68
|
+
assert.deepEqual(bodies("ctx-busy").map((b) => b.taskIds), [[first]]);
|
|
69
|
+
const busyUntil = row().busy_until_ms as number;
|
|
70
|
+
assert.ok(Math.abs(busyUntil - (row().last_sent_ms + 120_000)) < 1000, "busy starts at the send");
|
|
71
|
+
const inbox = await call(busy, "GET", "/owner/inbox?context=ctx-busy&from=ada");
|
|
72
|
+
assert.deepEqual(inbox.map((r: { taskId: string }) => r.taskId), [first]);
|
|
73
|
+
assert.equal(row().pending_json, null);
|
|
74
|
+
assert.equal(row().busy_until_ms, busyUntil, "the inbox read leaves the busy deadline in place");
|
|
75
|
+
|
|
76
|
+
offset += 11_000;
|
|
77
|
+
const second = await msg(busy, token, "ctx-busy", "two");
|
|
78
|
+
assert.equal(bodies("ctx-busy").length, 1, "a task during the busy window does not send");
|
|
79
|
+
const held = JSON.parse(row().pending_json!);
|
|
80
|
+
assert.equal(held.busy, 1);
|
|
81
|
+
assert.equal(held.taskId, second);
|
|
82
|
+
assert.deepEqual(held.taskIds, [second]);
|
|
83
|
+
assert.equal(row().busy_until_ms, busyUntil, "an absorbed event does not move the deadline");
|
|
84
|
+
|
|
85
|
+
offset = (busyUntil as number) - realNow() - 1000;
|
|
86
|
+
await call(busy, "GET", "/health");
|
|
87
|
+
assert.equal(bodies("ctx-busy").length, 1, "the catch-up waits for the busy deadline");
|
|
88
|
+
offset = (busyUntil as number) - realNow() + 1000;
|
|
89
|
+
await call(busy, "GET", "/health");
|
|
90
|
+
const after = bodies("ctx-busy");
|
|
91
|
+
assert.equal(after.length, 2);
|
|
92
|
+
assert.equal(after[1].taskId, second);
|
|
93
|
+
assert.deepEqual(after[1].taskIds, [second]);
|
|
94
|
+
assert.equal("busy" in after[1], false);
|
|
95
|
+
assert.equal(row().pending_json, null);
|
|
96
|
+
const nextBusy = row().busy_until_ms as number;
|
|
97
|
+
assert.ok(nextBusy > (busyUntil as number), "the catch-up starts a new busy window");
|
|
98
|
+
offset = nextBusy - realNow() + 1000;
|
|
99
|
+
await call(busy, "GET", "/health");
|
|
100
|
+
assert.equal(bodies("ctx-busy").length, 2, "an empty busy window does not send");
|
|
101
|
+
const third = await msg(busy, token, "ctx-busy", "three");
|
|
102
|
+
const done = bodies("ctx-busy");
|
|
103
|
+
assert.equal(done.length, 3);
|
|
104
|
+
assert.equal(done[2].taskId, third);
|
|
105
|
+
assert.deepEqual(done[2].taskIds, [third]);
|
|
106
|
+
|
|
107
|
+
// A burst inside the short debounce flushes then, not at the busy deadline.
|
|
108
|
+
const burst = envFor({ WAKE_DEBOUNCE_SECONDS: "30", WAKE_BUSY_SECONDS: "300" });
|
|
109
|
+
const burstToken = (await call(burst, "POST", "/owner/peers", { label: "ada" })).token as string;
|
|
110
|
+
const before = sent.length;
|
|
111
|
+
await msg(burst, burstToken, "ctx-burst", "one");
|
|
112
|
+
await msg(burst, burstToken, "ctx-burst", "two");
|
|
113
|
+
assert.equal(sent.length, before + 1);
|
|
114
|
+
const burstPending = JSON.parse(burst.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = 'ctx-burst'").get().pending_json);
|
|
115
|
+
assert.equal(burstPending.busy, undefined);
|
|
116
|
+
const due = await nextCronAt(resolveTenant(burst as never), Date.now());
|
|
117
|
+
assert.ok(due != null && due <= Date.now() + 31_000 && due > Date.now() + 20_000, `debounce due ${due}`);
|
|
118
|
+
offset += 31_000;
|
|
119
|
+
await call(burst, "GET", "/health");
|
|
120
|
+
assert.equal(sent.length, before + 2, "the burst flushes at the debounce");
|
|
121
|
+
|
|
122
|
+
// Explicit 0 disables the cool-down. Unset uses the preset default, shown on wake preview.
|
|
123
|
+
const off = envFor({ WAKE_PRESET: "claude-code", WAKE_BUSY_SECONDS: "0", WAKE_DEBOUNCE_SECONDS: "20" });
|
|
124
|
+
const offPreview = await call(off, "GET", "/owner/wake/preview");
|
|
125
|
+
assert.equal(offPreview.preset, "claude-code");
|
|
126
|
+
assert.equal(offPreview.debounceSeconds, 20);
|
|
127
|
+
assert.equal(offPreview.busySeconds, 0);
|
|
128
|
+
const grok = envFor({ WAKE_PRESET: "grok-bot", WAKE_DEBOUNCE_SECONDS: "" });
|
|
129
|
+
delete grok.WAKE_BUSY_SECONDS;
|
|
130
|
+
const grokPreview = await call(grok, "GET", "/owner/wake/preview");
|
|
131
|
+
assert.equal(grokPreview.debounceSeconds, 10);
|
|
132
|
+
assert.equal(grokPreview.busySeconds, 180);
|
|
133
|
+
const offToken = (await call(off, "POST", "/owner/peers", { label: "ada" })).token as string;
|
|
134
|
+
const offBefore = sent.length;
|
|
135
|
+
await msg(off, offToken, "ctx-off", "one");
|
|
136
|
+
offset += 21_000;
|
|
137
|
+
await msg(off, offToken, "ctx-off", "two");
|
|
138
|
+
assert.equal(sent.length, offBefore + 2, "busy 0 sends again once the debounce has passed");
|
|
139
|
+
const offRow = off.DB.db.prepare("SELECT busy_until_ms FROM wakes WHERE context_id = 'ctx-off'").get() as { busy_until_ms: number | null };
|
|
140
|
+
assert.equal(offRow.busy_until_ms, null);
|
|
141
|
+
|
|
142
|
+
// No minute cron and no hosted alarm: the cool-down stays off, so the next task sends after the debounce.
|
|
143
|
+
const bare = envFor({ WAKE_PRESET: "claude-code", WAKE_DEBOUNCE_SECONDS: "20" });
|
|
144
|
+
delete bare.ENABLE_CRON;
|
|
145
|
+
delete bare.WAKE_BUSY_SECONDS;
|
|
146
|
+
const barePreview = await call(bare, "GET", "/owner/wake/preview");
|
|
147
|
+
assert.equal(barePreview.debounceSeconds, 20);
|
|
148
|
+
assert.equal(barePreview.busySeconds, 0);
|
|
149
|
+
const bareToken = (await call(bare, "POST", "/owner/peers", { label: "ada" })).token as string;
|
|
150
|
+
const bareBefore = sent.length;
|
|
151
|
+
await msg(bare, bareToken, "ctx-bare", "one");
|
|
152
|
+
offset += 21_000;
|
|
153
|
+
await msg(bare, bareToken, "ctx-bare", "two");
|
|
154
|
+
assert.equal(sent.length, bareBefore + 2, "without a flush timer the second task sends");
|
|
155
|
+
const bareRow = bare.DB.db.prepare("SELECT busy_until_ms FROM wakes WHERE context_id = 'ctx-bare'").get() as { busy_until_ms: number | null };
|
|
156
|
+
assert.equal(bareRow.busy_until_ms, null);
|
|
157
|
+
|
|
158
|
+
const parsed = events.flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
159
|
+
assert.equal(parsed.filter((e) => e.event === "wake_absorbed" && e.contextId === "ctx-busy").length, 1);
|
|
160
|
+
assert.equal(parsed.some((e) => e.event === "wake_sent" && e.catchUp === true && e.contextId === "ctx-busy"), true);
|
|
161
|
+
assert.equal(parsed.some((e) => e.event === "wake_absorbed" && e.preview !== undefined), false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a busy pending wake is due at busy_until_ms, not the debounce", async () => {
|
|
165
|
+
const now = 1_700_000_000_000;
|
|
166
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
167
|
+
const tenant = resolveTenant({
|
|
168
|
+
DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
169
|
+
WAKE_DEBOUNCE_SECONDS: "10", WAKE_BUSY_SECONDS: "120", WAKE_MAX_PER_HOUR: "0",
|
|
170
|
+
} as never);
|
|
171
|
+
const until = now + 120_000;
|
|
172
|
+
const pending = JSON.stringify({ contextId: "ctx-lat", from: "ada", taskId: "t-2", taskIds: ["t-2"], kind: "inbound", busy: 1 });
|
|
173
|
+
await DB.prepare(
|
|
174
|
+
"INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms, busy_until_ms) VALUES ('ctx-lat', 'ada', ?, ?, ?, ?)",
|
|
175
|
+
).bind(now - 60_000, pending, now - 60_000, until).run();
|
|
176
|
+
await DB.prepare(
|
|
177
|
+
"INSERT INTO wakes (context_id, peer, last_sent_ms, pending_json, pending_since_ms) VALUES ('ctx-soon', 'ada', ?, ?, ?)",
|
|
178
|
+
).bind(now - 5_000, "{}", now).run();
|
|
179
|
+
assert.equal(await nextCronAt(tenant, now), now - 5_000 + 10_000);
|
|
180
|
+
await DB.prepare("DELETE FROM wakes WHERE context_id = 'ctx-soon'").run();
|
|
181
|
+
assert.equal(await nextCronAt(tenant, now), until);
|
|
182
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { createHmac } from "node:crypto";
|
|
5
|
-
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, wakePayload, type WakeEvent } from "../src/wake.ts";
|
|
5
|
+
import { renderWake, renderTemplate, wakeSummary, redact, addAccessHeaders, cloudflareErrorHint, PRESETS, defaultBusySeconds, defaultDebounceSeconds, wakePayload, type WakeEvent } from "../src/wake.ts";
|
|
6
6
|
|
|
7
7
|
const ev: WakeEvent = {
|
|
8
8
|
contextId: "ctx-1", taskId: "t-2", taskIds: ["t-1", "t-2"], from: "peer-a",
|
|
9
9
|
preview: 'Please "do" X\nnow', kind: "inbound", publicUrl: "https://agent.example.com",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
test("busy and debounce defaults differ by preset", () => {
|
|
13
|
+
assert.equal(defaultDebounceSeconds("claude-code"), 20);
|
|
14
|
+
assert.equal(defaultDebounceSeconds("grok-bot"), 10);
|
|
15
|
+
assert.equal(defaultBusySeconds("claude-code"), 300);
|
|
16
|
+
for (const preset of PRESETS) {
|
|
17
|
+
if (preset === "claude-code") continue;
|
|
18
|
+
assert.equal(defaultBusySeconds(preset), 180, preset);
|
|
19
|
+
assert.equal(defaultDebounceSeconds(preset), 10, preset);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
12
23
|
test("unconfigured wake is a no-op", async () => {
|
|
13
24
|
assert.equal(await renderWake({ preset: "grok-bot" }, ev), null);
|
|
14
25
|
});
|