melchizedek-agents 0.16.2 → 0.18.0
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/.env.example +40 -3
- package/AGENT_SETUP.md +4 -2
- package/CHANGELOG.md +246 -0
- package/DOCUMENTATION.md +311 -15
- package/QUICKSTART.md +2 -2
- package/README.md +125 -149
- package/config/agents/examples/harness.yaml +135 -0
- package/config/agents/examples/model_zoo.yaml +23 -9
- package/config/agents/examples/pipeline.yaml +120 -0
- package/config/agents/examples/specs/open-meteo-forecast.json +77 -0
- package/config/agents/examples/specs/open-meteo-geocoding.json +53 -0
- package/config/agents/examples/weather.yaml +59 -0
- package/config/agents/syndicateSchema.yaml +125 -1
- package/db/hardening.sql +4 -3
- package/db/migrations/0011_erase_expired.sql +198 -0
- package/db/migrations/0012_audit_log.sql +98 -0
- package/dist/lib/a2a/app.d.ts +27 -0
- package/dist/lib/a2a/app.js +101 -5
- package/dist/lib/a2a/executor.d.ts +34 -4
- package/dist/lib/a2a/executor.js +109 -9
- package/dist/lib/compile.d.ts +41 -3
- package/dist/lib/compile.js +162 -17
- package/dist/lib/config.d.ts +24 -0
- package/dist/lib/config.js +24 -0
- package/dist/lib/doctor.js +17 -4
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/loadEnv.d.ts +5 -0
- package/dist/lib/loadEnv.js +7 -0
- package/dist/lib/loadSyndicate.d.ts +50 -0
- package/dist/lib/loadSyndicate.js +19 -3
- package/dist/lib/memory/erase.d.ts +5 -0
- package/dist/lib/memory/erase.js +5 -0
- package/dist/lib/memory/providers.d.ts +27 -0
- package/dist/lib/memory/providers.js +28 -0
- package/dist/lib/models/capabilities.js +10 -0
- package/dist/lib/models/endpoints.d.ts +1 -1
- package/dist/lib/models/endpoints.js +4 -1
- package/dist/lib/models/fallback.d.ts +41 -0
- package/dist/lib/models/fallback.js +105 -0
- package/dist/lib/models/gateway.d.ts +2 -2
- package/dist/lib/models/gateway.js +4 -4
- package/dist/lib/models/kimiLlm.d.ts +123 -0
- package/dist/lib/models/kimiLlm.js +171 -0
- package/dist/lib/models/providerMap.d.ts +2 -2
- package/dist/lib/models/providerMap.js +4 -1
- package/dist/lib/models/registry.d.ts +1 -1
- package/dist/lib/models/registry.js +11 -3
- package/dist/lib/net/redirects.d.ts +14 -0
- package/dist/lib/net/redirects.js +94 -0
- package/dist/lib/observability/audit.d.ts +21 -0
- package/dist/lib/observability/audit.js +39 -0
- package/dist/lib/observability/metrics.d.ts +6 -0
- package/dist/lib/observability/tracer.d.ts +14 -1
- package/dist/lib/observability/tracer.js +16 -2
- package/dist/lib/persistence/supabaseProvider.d.ts +2 -6
- package/dist/lib/persistence/supabaseProvider.js +2 -31
- package/dist/lib/runtime/questions.d.ts +55 -0
- package/dist/lib/runtime/questions.js +107 -0
- package/dist/lib/runtime/syndicateTurn.d.ts +45 -5
- package/dist/lib/runtime/syndicateTurn.js +165 -17
- package/dist/lib/storage/postgres/index.d.ts +9 -0
- package/dist/lib/storage/postgres/index.js +13 -0
- package/dist/lib/storage/rlsStatus.d.ts +45 -0
- package/dist/lib/storage/rlsStatus.js +69 -0
- package/dist/lib/syndicateSchema.d.ts +177 -0
- package/dist/lib/syndicateSchema.js +343 -3
- package/dist/lib/toolRegistry.js +9 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
- package/dist/lib/tools/mcpToolFactory.js +61 -4
- package/dist/lib/tools/openapiTools.d.ts +52 -0
- package/dist/lib/tools/openapiTools.js +225 -0
- package/dist/lib/tools/skillToolset.d.ts +135 -0
- package/dist/lib/tools/skillToolset.js +247 -0
- package/dist/lib/tools/urlContextTool.d.ts +29 -0
- package/dist/lib/tools/urlContextTool.js +39 -0
- package/dist/lib/workflow.d.ts +72 -0
- package/dist/lib/workflow.js +180 -0
- package/dist/lib/workflowConfig.d.ts +95 -0
- package/dist/lib/workflowConfig.js +93 -0
- package/dist/scripts/a2a_server.js +51 -8
- package/dist/scripts/db.d.ts +9 -0
- package/dist/scripts/db.js +30 -15
- package/dist/scripts/syndicate_chat.js +39 -5
- package/package.json +6 -3
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-author.md +8 -4
- package/skills/briefs/melchizedek-memory.md +1 -1
- package/skills/briefs/melchizedek-models.md +1 -1
- package/skills/briefs/melchizedek-scribe.md +4 -4
- package/skills/briefs/melchizedek-serve.md +1 -1
- package/skills/briefs/melchizedek.md +4 -1
- package/skills/melchizedek/SKILL.md +4 -1
- package/skills/melchizedek-author/SKILL.md +7 -3
- package/skills/melchizedek-memory/SKILL.md +1 -1
- package/skills/melchizedek-models/SKILL.md +1 -1
- package/skills/melchizedek-scribe/SKILL.md +3 -3
- package/skills/melchizedek-serve/SKILL.md +1 -1
- /package/skills/melchizedek-author/{templates → assets}/minimal.yaml +0 -0
- /package/skills/melchizedek-scribe/{templates → assets}/brief.md +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 0011_erase_expired.sql — a namespace erase reaches expired conversations
|
|
3
|
+
-- ============================================================================
|
|
4
|
+
-- melchizedek_erase_scope (0010) found a namespace's conversations through
|
|
5
|
+
-- its live adk_sessions rows and erased ledger turns and A2A tasks only for
|
|
6
|
+
-- those. Sessions expire after seven idle days (0001) while the ledger keeps
|
|
7
|
+
-- turns indefinitely, so a namespace erase (the default for DELETE /memory)
|
|
8
|
+
-- left every expired conversation's input, output and tool results behind
|
|
9
|
+
-- and reported success.
|
|
10
|
+
--
|
|
11
|
+
-- Turns and tasks do not record the memory namespace, so a namespace erase
|
|
12
|
+
-- now keeps only what provably belongs to ANOTHER namespace: rows whose
|
|
13
|
+
-- conversation is still live there. Everything else of the scope goes,
|
|
14
|
+
-- including conversations whose sessions have expired, whichever namespace
|
|
15
|
+
-- they were in. Erasure errs toward deleting, never toward keeping (0002).
|
|
16
|
+
-- An erase without a namespace is unchanged. Idempotent.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
CREATE OR REPLACE FUNCTION melchizedek_erase_scope(
|
|
20
|
+
p_scope_key text,
|
|
21
|
+
p_namespace text DEFAULT NULL,
|
|
22
|
+
p_include_nested boolean DEFAULT false
|
|
23
|
+
) RETURNS TABLE(store text, deleted bigint)
|
|
24
|
+
LANGUAGE plpgsql
|
|
25
|
+
SECURITY INVOKER
|
|
26
|
+
SET search_path = public
|
|
27
|
+
AS $$
|
|
28
|
+
DECLARE
|
|
29
|
+
-- LIKE pattern for scopes nested beneath this one, with the scope's own
|
|
30
|
+
-- wildcard characters escaped.
|
|
31
|
+
nested_like text := replace(replace(replace(p_scope_key, '\', '\\'), '%', '\%'), '_', '\_') || '/%';
|
|
32
|
+
contexts text[];
|
|
33
|
+
kept text[];
|
|
34
|
+
traces text[];
|
|
35
|
+
n bigint;
|
|
36
|
+
BEGIN
|
|
37
|
+
IF p_scope_key IS NULL OR btrim(p_scope_key) = '' THEN
|
|
38
|
+
RAISE EXCEPTION 'melchizedek_erase_scope: scope_key is required';
|
|
39
|
+
END IF;
|
|
40
|
+
|
|
41
|
+
-- The conversations (context ids) being erased. With a namespace, they are
|
|
42
|
+
-- the namespace's own session rows; without one, every row of the scope.
|
|
43
|
+
-- adk_sessions.id is '<app_name>:<user_id>:<context id>'.
|
|
44
|
+
SELECT coalesce(array_agg(DISTINCT substr(s.id, length(s.app_name) + length(s.user_id) + 3)), '{}')
|
|
45
|
+
INTO contexts
|
|
46
|
+
FROM adk_sessions s
|
|
47
|
+
WHERE (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
|
|
48
|
+
AND (p_namespace IS NULL OR s.app_name = p_namespace);
|
|
49
|
+
|
|
50
|
+
-- With a namespace: the conversations still live in another namespace,
|
|
51
|
+
-- which are the only ones a namespace erase keeps. Sub-agent rows share
|
|
52
|
+
-- their conversation's id, so excluding `contexts` keeps them with it.
|
|
53
|
+
SELECT coalesce(array_agg(DISTINCT substr(s.id, length(s.app_name) + length(s.user_id) + 3)), '{}')
|
|
54
|
+
INTO kept
|
|
55
|
+
FROM adk_sessions s
|
|
56
|
+
WHERE p_namespace IS NOT NULL
|
|
57
|
+
AND (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
|
|
58
|
+
AND s.app_name <> p_namespace
|
|
59
|
+
AND NOT (substr(s.id, length(s.app_name) + length(s.user_id) + 3) = ANY(contexts));
|
|
60
|
+
|
|
61
|
+
-- Ledger traces of the turns being erased, collected before anything is deleted.
|
|
62
|
+
IF to_regclass('public.adk_turns') IS NOT NULL THEN
|
|
63
|
+
EXECUTE $q$
|
|
64
|
+
SELECT coalesce(array_agg(DISTINCT trace_id), '{}') FROM adk_turns
|
|
65
|
+
WHERE (user_id = $1 OR ($2 AND user_id LIKE $3))
|
|
66
|
+
AND ($4 IS NULL OR session_id IS NULL OR NOT (session_id = ANY($5)))
|
|
67
|
+
$q$ INTO traces USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
|
|
68
|
+
ELSE
|
|
69
|
+
traces := '{}';
|
|
70
|
+
END IF;
|
|
71
|
+
|
|
72
|
+
-- Memory facts. user_key is '<namespace>/<scope>' and a namespace never
|
|
73
|
+
-- contains '/', so the scope is exactly what follows the first slash.
|
|
74
|
+
DELETE FROM adk_memory_facts f
|
|
75
|
+
WHERE (p_namespace IS NULL OR split_part(f.user_key, '/', 1) = p_namespace)
|
|
76
|
+
AND (substr(f.user_key, strpos(f.user_key, '/') + 1) = p_scope_key
|
|
77
|
+
OR (p_include_nested AND substr(f.user_key, strpos(f.user_key, '/') + 1) LIKE nested_like));
|
|
78
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
79
|
+
store := 'memory_facts'; deleted := n; RETURN NEXT;
|
|
80
|
+
|
|
81
|
+
-- Sessions: every row of the scope in those conversations, whatever app
|
|
82
|
+
-- name ADK gave it (subagents run under their own agent name).
|
|
83
|
+
DELETE FROM adk_sessions s
|
|
84
|
+
WHERE (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
|
|
85
|
+
AND substr(s.id, length(s.app_name) + length(s.user_id) + 3) = ANY(contexts);
|
|
86
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
87
|
+
store := 'sessions'; deleted := n; RETURN NEXT;
|
|
88
|
+
|
|
89
|
+
-- The ledger, when installed.
|
|
90
|
+
IF to_regclass('public.adk_turns') IS NOT NULL THEN
|
|
91
|
+
EXECUTE $q$
|
|
92
|
+
DELETE FROM adk_turns
|
|
93
|
+
WHERE (user_id = $1 OR ($2 AND user_id LIKE $3))
|
|
94
|
+
AND ($4 IS NULL OR session_id IS NULL OR NOT (session_id = ANY($5)))
|
|
95
|
+
$q$ USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
|
|
96
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
97
|
+
ELSE
|
|
98
|
+
n := 0;
|
|
99
|
+
END IF;
|
|
100
|
+
store := 'turns'; deleted := n; RETURN NEXT;
|
|
101
|
+
|
|
102
|
+
IF to_regclass('public.adk_telemetry') IS NOT NULL THEN
|
|
103
|
+
EXECUTE 'DELETE FROM adk_telemetry WHERE trace_id = ANY($1)' USING traces;
|
|
104
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
105
|
+
ELSE
|
|
106
|
+
n := 0;
|
|
107
|
+
END IF;
|
|
108
|
+
store := 'spans'; deleted := n; RETURN NEXT;
|
|
109
|
+
|
|
110
|
+
IF to_regclass('public.adk_payloads') IS NOT NULL THEN
|
|
111
|
+
EXECUTE 'DELETE FROM adk_payloads WHERE trace_id = ANY($1)' USING traces;
|
|
112
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
113
|
+
ELSE
|
|
114
|
+
n := 0;
|
|
115
|
+
END IF;
|
|
116
|
+
store := 'payloads'; deleted := n; RETURN NEXT;
|
|
117
|
+
|
|
118
|
+
IF to_regclass('public.adk_verdicts') IS NOT NULL THEN
|
|
119
|
+
EXECUTE 'DELETE FROM adk_verdicts WHERE trace_id = ANY($1)' USING traces;
|
|
120
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
121
|
+
ELSE
|
|
122
|
+
n := 0;
|
|
123
|
+
END IF;
|
|
124
|
+
store := 'verdicts'; deleted := n; RETURN NEXT;
|
|
125
|
+
|
|
126
|
+
IF EXISTS (
|
|
127
|
+
SELECT 1 FROM information_schema.columns
|
|
128
|
+
WHERE table_schema = 'public' AND table_name = 'adk_labels' AND column_name = 'trace_id'
|
|
129
|
+
) THEN
|
|
130
|
+
EXECUTE 'DELETE FROM adk_labels WHERE trace_id = ANY($1)' USING traces;
|
|
131
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
132
|
+
ELSE
|
|
133
|
+
n := 0;
|
|
134
|
+
END IF;
|
|
135
|
+
store := 'labels'; deleted := n; RETURN NEXT;
|
|
136
|
+
|
|
137
|
+
IF to_regclass('public.adk_a2a_tasks') IS NOT NULL THEN
|
|
138
|
+
EXECUTE $q$
|
|
139
|
+
DELETE FROM adk_a2a_tasks
|
|
140
|
+
WHERE (owner = $1 OR ($2 AND owner LIKE $3))
|
|
141
|
+
AND ($4 IS NULL OR context_id IS NULL OR NOT (context_id = ANY($5)))
|
|
142
|
+
$q$ USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
|
|
143
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
144
|
+
ELSE
|
|
145
|
+
n := 0;
|
|
146
|
+
END IF;
|
|
147
|
+
store := 'tasks'; deleted := n; RETURN NEXT;
|
|
148
|
+
|
|
149
|
+
-- Memory ingestion markers (migration 0007): how far each conversation was
|
|
150
|
+
-- distilled. They carry the scope's key and its conversation ids.
|
|
151
|
+
IF to_regclass('public.melchizedek_memory_ingest') IS NOT NULL THEN
|
|
152
|
+
EXECUTE $q$
|
|
153
|
+
DELETE FROM melchizedek_memory_ingest m
|
|
154
|
+
WHERE ($1 IS NULL OR split_part(m.user_key, '/', 1) = $1)
|
|
155
|
+
AND (substr(m.user_key, strpos(m.user_key, '/') + 1) = $2
|
|
156
|
+
OR ($3 AND substr(m.user_key, strpos(m.user_key, '/') + 1) LIKE $4))
|
|
157
|
+
$q$ USING p_namespace, p_scope_key, p_include_nested, nested_like;
|
|
158
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
159
|
+
ELSE
|
|
160
|
+
n := 0;
|
|
161
|
+
END IF;
|
|
162
|
+
store := 'memory_markers'; deleted := n; RETURN NEXT;
|
|
163
|
+
|
|
164
|
+
-- The task tools' list and job queue (migration 0009), per caller across
|
|
165
|
+
-- every syndicate: erased with the whole scope, kept by a namespace erase.
|
|
166
|
+
n := 0;
|
|
167
|
+
IF p_namespace IS NULL AND to_regclass('public.melchizedek_tasks') IS NOT NULL THEN
|
|
168
|
+
EXECUTE $q$
|
|
169
|
+
DELETE FROM melchizedek_tasks WHERE owner = $1 OR ($2 AND owner LIKE $3)
|
|
170
|
+
$q$ USING p_scope_key, p_include_nested, nested_like;
|
|
171
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
172
|
+
EXECUTE $q$
|
|
173
|
+
DELETE FROM melchizedek_task_owners WHERE owner = $1 OR ($2 AND owner LIKE $3)
|
|
174
|
+
$q$ USING p_scope_key, p_include_nested, nested_like;
|
|
175
|
+
END IF;
|
|
176
|
+
store := 'task_tools'; deleted := n; RETURN NEXT;
|
|
177
|
+
END;
|
|
178
|
+
$$;
|
|
179
|
+
|
|
180
|
+
-- No API role may execute it (db/hardening.sql repeats this for every
|
|
181
|
+
-- melchizedek function); the server's role gets it back.
|
|
182
|
+
DO $$
|
|
183
|
+
BEGIN
|
|
184
|
+
REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM PUBLIC;
|
|
185
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
186
|
+
REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM anon;
|
|
187
|
+
END IF;
|
|
188
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
189
|
+
REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM authenticated;
|
|
190
|
+
END IF;
|
|
191
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
192
|
+
GRANT EXECUTE ON FUNCTION melchizedek_erase_scope(text, text, boolean) TO service_role;
|
|
193
|
+
END IF;
|
|
194
|
+
END $$;
|
|
195
|
+
|
|
196
|
+
INSERT INTO melchizedek_schema_version (version, name)
|
|
197
|
+
VALUES (11, '0011_erase_expired')
|
|
198
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 0012_audit_log.sql — an append-only record of who did what, when (ADR 0042)
|
|
3
|
+
-- ============================================================================
|
|
4
|
+
-- One row per security-relevant event: a failed authentication, a task's
|
|
5
|
+
-- outcome, an erasure. The ledger (adk_turns) records what a turn said;
|
|
6
|
+
-- this records who asked, how they authenticated, from where, and what
|
|
7
|
+
-- happened, which is what an auditor asks for.
|
|
8
|
+
--
|
|
9
|
+
-- What a row holds: the event, its outcome, the caller's name and auth
|
|
10
|
+
-- method, a SHA-256 prefix of the scope (never the scope key itself), the
|
|
11
|
+
-- source address, the agent and task ids, and a small detail object. No
|
|
12
|
+
-- conversation content, ever.
|
|
13
|
+
--
|
|
14
|
+
-- Append-only: a trigger refuses UPDATE and DELETE. Rows leave only
|
|
15
|
+
-- through melchizedek_prune_audit(days), which removes rows older than the
|
|
16
|
+
-- retention period (the source address is personal data; keep it no longer
|
|
17
|
+
-- than the evidence needs). TRUNCATE is revoked from every API role. A
|
|
18
|
+
-- database superuser can still alter the table; ship the rows to a log
|
|
19
|
+
-- store you do not administer when the evidence must survive that.
|
|
20
|
+
-- Idempotent.
|
|
21
|
+
-- ============================================================================
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS melchizedek_audit (
|
|
24
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
25
|
+
at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
26
|
+
event TEXT NOT NULL, -- auth.failure | task.end | memory.erase
|
|
27
|
+
outcome TEXT, -- denied | completed | failed | rejected | canceled | input-required | ok
|
|
28
|
+
caller TEXT, -- the authenticated caller's name ('shared-secret', a caller token's name, 'jwt')
|
|
29
|
+
scope_hash TEXT, -- SHA-256 prefix of the scope key; joins rows without naming a user
|
|
30
|
+
source_ip TEXT,
|
|
31
|
+
agent_id TEXT,
|
|
32
|
+
task_id TEXT,
|
|
33
|
+
detail JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS melchizedek_audit_at_idx ON melchizedek_audit (at);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS melchizedek_audit_event_idx ON melchizedek_audit (event, at);
|
|
37
|
+
|
|
38
|
+
CREATE OR REPLACE FUNCTION melchizedek_audit_append_only()
|
|
39
|
+
RETURNS trigger
|
|
40
|
+
LANGUAGE plpgsql
|
|
41
|
+
SET search_path = public
|
|
42
|
+
AS $$
|
|
43
|
+
BEGIN
|
|
44
|
+
-- The retention function sets this for its own transaction only.
|
|
45
|
+
IF TG_OP = 'DELETE' AND coalesce(current_setting('melchizedek.audit_prune', true), '') = 'on' THEN
|
|
46
|
+
RETURN OLD;
|
|
47
|
+
END IF;
|
|
48
|
+
RAISE EXCEPTION 'melchizedek_audit is append-only (% refused); rows leave only through melchizedek_prune_audit', TG_OP;
|
|
49
|
+
END;
|
|
50
|
+
$$;
|
|
51
|
+
|
|
52
|
+
DROP TRIGGER IF EXISTS melchizedek_audit_append_only ON melchizedek_audit;
|
|
53
|
+
CREATE TRIGGER melchizedek_audit_append_only
|
|
54
|
+
BEFORE UPDATE OR DELETE ON melchizedek_audit
|
|
55
|
+
FOR EACH ROW EXECUTE FUNCTION melchizedek_audit_append_only();
|
|
56
|
+
|
|
57
|
+
-- Retention: delete rows older than p_days (at least 1). Returns the count.
|
|
58
|
+
-- SECURITY DEFINER: the API role holds SELECT and INSERT only, so pruning
|
|
59
|
+
-- runs with the owner's rights, through this function and nothing else.
|
|
60
|
+
CREATE OR REPLACE FUNCTION melchizedek_prune_audit(p_days integer)
|
|
61
|
+
RETURNS bigint
|
|
62
|
+
LANGUAGE plpgsql
|
|
63
|
+
SECURITY DEFINER
|
|
64
|
+
SET search_path = public
|
|
65
|
+
AS $$
|
|
66
|
+
DECLARE n bigint;
|
|
67
|
+
BEGIN
|
|
68
|
+
IF p_days IS NULL OR p_days < 1 THEN
|
|
69
|
+
RAISE EXCEPTION 'melchizedek_prune_audit: p_days must be at least 1';
|
|
70
|
+
END IF;
|
|
71
|
+
PERFORM set_config('melchizedek.audit_prune', 'on', true);
|
|
72
|
+
DELETE FROM melchizedek_audit WHERE at < now() - make_interval(days => p_days);
|
|
73
|
+
GET DIAGNOSTICS n = ROW_COUNT;
|
|
74
|
+
PERFORM set_config('melchizedek.audit_prune', '', true);
|
|
75
|
+
RETURN n;
|
|
76
|
+
END;
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
DO $$
|
|
80
|
+
BEGIN
|
|
81
|
+
REVOKE ALL ON melchizedek_audit FROM PUBLIC;
|
|
82
|
+
REVOKE ALL ON FUNCTION melchizedek_prune_audit(integer) FROM PUBLIC;
|
|
83
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
84
|
+
REVOKE ALL ON melchizedek_audit FROM anon;
|
|
85
|
+
END IF;
|
|
86
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
87
|
+
REVOKE ALL ON melchizedek_audit FROM authenticated;
|
|
88
|
+
END IF;
|
|
89
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
90
|
+
-- The server appends and reads; nothing else, so no UPDATE, DELETE or TRUNCATE.
|
|
91
|
+
GRANT SELECT, INSERT ON melchizedek_audit TO service_role;
|
|
92
|
+
GRANT EXECUTE ON FUNCTION melchizedek_prune_audit(integer) TO service_role;
|
|
93
|
+
END IF;
|
|
94
|
+
END $$;
|
|
95
|
+
|
|
96
|
+
INSERT INTO melchizedek_schema_version (version, name)
|
|
97
|
+
VALUES (12, '0012_audit_log')
|
|
98
|
+
ON CONFLICT (version) DO NOTHING;
|
package/dist/lib/a2a/app.d.ts
CHANGED
|
@@ -31,6 +31,10 @@ import type { BaseLlm, BaseMemoryService, BaseSessionService } from '@google/adk
|
|
|
31
31
|
import type { SyndicateYamlConfig } from '../loadSyndicate.ts';
|
|
32
32
|
import type { ProviderEndpoint, ProviderId } from '../models/registry.ts';
|
|
33
33
|
import type { TurnLock } from './turnLock.ts';
|
|
34
|
+
import type { RlsHardeningStatus } from '../storage/rlsStatus.ts';
|
|
35
|
+
import type { AuditSink } from '../observability/audit.ts';
|
|
36
|
+
/** One end user's concurrent tasks, unless maxConcurrentPerScope says otherwise (ADR 0039). */
|
|
37
|
+
export declare const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
|
|
34
38
|
import type { EraseCounts } from '../memory/erase.ts';
|
|
35
39
|
import type { Embedder, MemoryExtractor } from '../memory/providers.ts';
|
|
36
40
|
import type { A2AContext } from './executor.ts';
|
|
@@ -68,6 +72,14 @@ export interface A2AAppOptions {
|
|
|
68
72
|
* migrations this package ships (ADR 0021).
|
|
69
73
|
*/
|
|
70
74
|
schemaVersion?: () => Promise<number | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Whether db/hardening.sql is in force (postgresStorage supplies it).
|
|
77
|
+
* When given, the server checks it at boot like the supabase-js path:
|
|
78
|
+
* fatal with `requireHardenedDb`, a warning otherwise.
|
|
79
|
+
*/
|
|
80
|
+
rlsHardening?: () => Promise<RlsHardeningStatus>;
|
|
81
|
+
/** Appends audit events (postgresStorage supplies one; ADR 0042). */
|
|
82
|
+
audit?: AuditSink;
|
|
71
83
|
/**
|
|
72
84
|
* One turn at a time per conversation across instances (postgresStorage
|
|
73
85
|
* supplies an advisory lock). Default: a lock in this process.
|
|
@@ -155,6 +167,15 @@ export interface A2AAppOptions {
|
|
|
155
167
|
streamText?: boolean;
|
|
156
168
|
/** Concurrent tasks across all agents. 0 = unlimited. */
|
|
157
169
|
maxConcurrentTasks?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Where audit events go: failed authentications, task outcomes, erasures
|
|
172
|
+
* (ADR 0042). Default: the storage's sink when it has one, else none.
|
|
173
|
+
*/
|
|
174
|
+
audit?: AuditSink;
|
|
175
|
+
/** Concurrent tasks for one scope (one end user). Default 4; 0 = unlimited. */
|
|
176
|
+
maxConcurrentPerScope?: number;
|
|
177
|
+
/** Concurrent tasks for one authenticated caller. Default 0 (unlimited). */
|
|
178
|
+
maxConcurrentPerCaller?: number;
|
|
158
179
|
/** Task submissions (POST) per window per client IP. */
|
|
159
180
|
rateLimit?: {
|
|
160
181
|
windowMs: number;
|
|
@@ -225,6 +246,12 @@ export interface A2AApp {
|
|
|
225
246
|
/** Stop admitting tasks, wait up to `graceMs` for running ones, cancel the rest.
|
|
226
247
|
* Resolves with the number of tasks that had to be canceled. */
|
|
227
248
|
shutdown(graceMs: number): Promise<number>;
|
|
249
|
+
/**
|
|
250
|
+
* Fail `/readyz` from now on while still serving every request, so a load
|
|
251
|
+
* balancer stops routing here before the listener closes. The first step of
|
|
252
|
+
* a graceful stop; `shutdown()` is the last.
|
|
253
|
+
*/
|
|
254
|
+
markUnready(): void;
|
|
228
255
|
}
|
|
229
256
|
/**
|
|
230
257
|
* Build the agent card for one syndicate at one route prefix, in the A2A 1.0
|
package/dist/lib/a2a/app.js
CHANGED
|
@@ -36,8 +36,13 @@ import { providerForModel, resolveModel } from '../models/registry.js';
|
|
|
36
36
|
import { createSupabaseServices, hasSupabaseCredentials } from '../persistence/supabaseProvider.js';
|
|
37
37
|
import { inProcessTurnLock } from './turnLock.js';
|
|
38
38
|
import { compareSchema, schemaBehindMessage, shippedSchemaVersion } from '../storage/schemaVersion.js';
|
|
39
|
+
import { scopeHashOf } from '../observability/audit.js';
|
|
40
|
+
import { validTraceparent } from '../observability/tracer.js';
|
|
41
|
+
/** One end user's concurrent tasks, unless maxConcurrentPerScope says otherwise (ADR 0039). */
|
|
42
|
+
export const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
|
|
39
43
|
import { eraseScope } from '../memory/erase.js';
|
|
40
44
|
import { namespacedMemoryService } from '../memory/namespace.js';
|
|
45
|
+
import { memoryCrossesProviders, memoryDestinations } from '../memory/providers.js';
|
|
41
46
|
import { A2A_APP_NAME, SyndicateExecutor, TaskLimiter, deriveUserId, requestContextStorage, } from './executor.js';
|
|
42
47
|
import { HEADER_VALUE_PATTERN, SCOPE_KEY_PATTERN } from './identity.js';
|
|
43
48
|
import { createMetrics } from '../observability/metrics.js';
|
|
@@ -187,11 +192,13 @@ export async function createA2AApp(options) {
|
|
|
187
192
|
let memoryService;
|
|
188
193
|
let erase;
|
|
189
194
|
let readSchemaVersion;
|
|
195
|
+
let checkHardening;
|
|
190
196
|
if (options.storage) {
|
|
191
197
|
durableSessions = options.storage.sessionService;
|
|
192
198
|
memoryService = options.storage.memoryService;
|
|
193
199
|
erase = options.storage.erase;
|
|
194
200
|
readSchemaVersion = options.storage.schemaVersion;
|
|
201
|
+
checkHardening = options.storage.rlsHardening;
|
|
195
202
|
}
|
|
196
203
|
else if (hasSupabaseCredentials()) {
|
|
197
204
|
// ADR 0021 item 6: the supabase-js path stays for a transition period.
|
|
@@ -209,7 +216,11 @@ export async function createA2AApp(options) {
|
|
|
209
216
|
memoryService = services.memoryService;
|
|
210
217
|
erase = (scopeKey, eraseOpts) => eraseScope(services.rpcClient, scopeKey, eraseOpts);
|
|
211
218
|
readSchemaVersion = services.schemaVersion;
|
|
212
|
-
|
|
219
|
+
checkHardening = services.checkRlsHardening;
|
|
220
|
+
}
|
|
221
|
+
// Both storage paths: refuse a public deployment whose tables an API key can read.
|
|
222
|
+
if (checkHardening) {
|
|
223
|
+
const rls = await checkHardening();
|
|
213
224
|
if (rls.applied) {
|
|
214
225
|
log(`✓ DB hardening verified — ${rls.detail}.`);
|
|
215
226
|
}
|
|
@@ -280,6 +291,22 @@ export async function createA2AApp(options) {
|
|
|
280
291
|
* and only `long-term` syndicates get the memory service.
|
|
281
292
|
*/
|
|
282
293
|
const internalSessions = new InMemorySessionService();
|
|
294
|
+
// Long-term memory sends transcripts to its extraction and embedding
|
|
295
|
+
// providers; say so once per syndicate when they are not the agents' own.
|
|
296
|
+
const memoryFlowWarned = new Set();
|
|
297
|
+
const warnMemoryFlow = (cfg) => {
|
|
298
|
+
if (memoryFlowWarned.has(cfg.syndicate_name))
|
|
299
|
+
return;
|
|
300
|
+
memoryFlowWarned.add(cfg.syndicate_name);
|
|
301
|
+
const dest = memoryDestinations(process.env, { extractor: options.memory?.extractor, embedder: options.memory?.embedder });
|
|
302
|
+
const models = [cfg.orchestrator?.model, ...(cfg.subagents ?? []).map((s) => s.model)];
|
|
303
|
+
const crossed = memoryCrossesProviders(models, dest);
|
|
304
|
+
if (crossed.length) {
|
|
305
|
+
warn(`'${cfg.syndicate_name}' keeps long-term memory, so its transcripts also go to ${crossed.join(' and ')} `
|
|
306
|
+
+ `(extraction: ${dest.extraction}, embeddings: ${dest.embeddings}), which its agents do not use. `
|
|
307
|
+
+ 'Set MEMORY_EXTRACTION_MODEL and MEMORY_EMBEDDING_PROVIDER to keep memory on an approved provider.');
|
|
308
|
+
}
|
|
309
|
+
};
|
|
283
310
|
const servicesFor = (cfg) => {
|
|
284
311
|
const mode = cfg.memory_system;
|
|
285
312
|
const sessionService = mode === 'internal-only' || !durableSessions ? internalSessions : durableSessions;
|
|
@@ -287,6 +314,8 @@ export async function createA2AApp(options) {
|
|
|
287
314
|
// turn reaches — including AgentTool children, which run under their own
|
|
288
315
|
// ADK app name — recalls and stores in the root syndicate's memory.
|
|
289
316
|
const memory = mode === 'long-term' && memoryService ? namespacedMemoryService(memoryService, memoryAppName(cfg)) : undefined;
|
|
317
|
+
if (memory)
|
|
318
|
+
warnMemoryFlow(cfg);
|
|
290
319
|
if (mode === 'long-term' && !memoryService) {
|
|
291
320
|
warn(`'${cfg.syndicate_name}' requests long-term memory but Supabase is not configured — memory disabled.`);
|
|
292
321
|
}
|
|
@@ -295,7 +324,11 @@ export async function createA2AApp(options) {
|
|
|
295
324
|
}
|
|
296
325
|
return { sessionService, memoryService: memory, durable: sessionService !== internalSessions };
|
|
297
326
|
};
|
|
298
|
-
const
|
|
327
|
+
const audit = options.audit ?? options.storage?.audit;
|
|
328
|
+
const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0, {
|
|
329
|
+
perScope: options.maxConcurrentPerScope ?? DEFAULT_MAX_CONCURRENT_PER_SCOPE,
|
|
330
|
+
perCaller: options.maxConcurrentPerCaller ?? 0,
|
|
331
|
+
});
|
|
299
332
|
const metrics = options.metricsToken ? createMetrics() : undefined;
|
|
300
333
|
const onTaskEnd = (record) => {
|
|
301
334
|
metrics?.observeTask(record);
|
|
@@ -357,6 +390,7 @@ export async function createA2AApp(options) {
|
|
|
357
390
|
agentId,
|
|
358
391
|
policy: options.policy,
|
|
359
392
|
onTaskEnd,
|
|
393
|
+
onAudit: audit,
|
|
360
394
|
log,
|
|
361
395
|
warn,
|
|
362
396
|
});
|
|
@@ -409,11 +443,60 @@ export async function createA2AApp(options) {
|
|
|
409
443
|
res.type('text/plain; version=0.0.4').send(metrics.render(limiter.inFlight));
|
|
410
444
|
});
|
|
411
445
|
}
|
|
412
|
-
|
|
413
|
-
|
|
446
|
+
// Readiness: not while stopping, and not while durable storage is
|
|
447
|
+
// unreachable (a turn there would fail). The reason is logged once per
|
|
448
|
+
// change of state, never per probe, and the answer names no host.
|
|
449
|
+
let unready = false;
|
|
450
|
+
let storageDown = false;
|
|
451
|
+
// /readyz is unauthenticated, so the database is asked at most once per
|
|
452
|
+
// READY_CACHE_MS however often it is probed; concurrent probes share the
|
|
453
|
+
// one check in flight.
|
|
454
|
+
const READY_CACHE_MS = 2_000;
|
|
455
|
+
let lastCheck;
|
|
456
|
+
let checking;
|
|
457
|
+
const checkStorage = async () => {
|
|
458
|
+
let timer;
|
|
459
|
+
try {
|
|
460
|
+
await Promise.race([
|
|
461
|
+
readSchemaVersion(),
|
|
462
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('no answer within 2 s')), 2_000); }),
|
|
463
|
+
]);
|
|
464
|
+
if (storageDown)
|
|
465
|
+
log('readyz: the database answers again.');
|
|
466
|
+
storageDown = false;
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
if (!storageDown)
|
|
471
|
+
warn(`readyz: the database is unreachable (${err instanceof Error ? err.message : String(err)}); reporting not ready.`);
|
|
472
|
+
storageDown = true;
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const storageReachable = async () => {
|
|
480
|
+
if (!readSchemaVersion)
|
|
481
|
+
return true;
|
|
482
|
+
if (lastCheck && Date.now() - lastCheck.at < READY_CACHE_MS)
|
|
483
|
+
return lastCheck.ok;
|
|
484
|
+
checking ??= checkStorage().then((ok) => {
|
|
485
|
+
lastCheck = { at: Date.now(), ok };
|
|
486
|
+
checking = undefined;
|
|
487
|
+
return ok;
|
|
488
|
+
});
|
|
489
|
+
return checking;
|
|
490
|
+
};
|
|
491
|
+
app.get('/readyz', async (_req, res) => {
|
|
492
|
+
if (unready || limiter.isDraining) {
|
|
414
493
|
res.status(503).json({ status: 'draining' });
|
|
415
494
|
return;
|
|
416
495
|
}
|
|
496
|
+
if (!(await storageReachable())) {
|
|
497
|
+
res.status(503).json({ status: 'unavailable', reason: 'storage' });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
417
500
|
res.json({ status: 'ready', sessions: sessionBackend, inFlight: limiter.inFlight });
|
|
418
501
|
});
|
|
419
502
|
// ── 2–3. Authentication ───────────────────────────────────────────────────
|
|
@@ -431,6 +514,12 @@ export async function createA2AApp(options) {
|
|
|
431
514
|
}));
|
|
432
515
|
const rejectAuth = (req, res, error) => {
|
|
433
516
|
warn(`401 ${req.method} ${req.path} from ${req.ip}: ${error}`);
|
|
517
|
+
try {
|
|
518
|
+
audit?.({ event: 'auth.failure', outcome: 'denied', sourceIp: req.ip, detail: { method: req.method, path: req.path, reason: error } });
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* the refusal stands whatever the sink does */
|
|
522
|
+
}
|
|
434
523
|
res.status(401).json({ error });
|
|
435
524
|
};
|
|
436
525
|
if (options.serverSecret) {
|
|
@@ -538,6 +627,7 @@ export async function createA2AApp(options) {
|
|
|
538
627
|
caller: identity.caller,
|
|
539
628
|
ownsNested: identity.ownsNested ?? false,
|
|
540
629
|
operator: identity.operator ?? false,
|
|
630
|
+
sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
|
|
541
631
|
}, () => next());
|
|
542
632
|
return;
|
|
543
633
|
}
|
|
@@ -562,7 +652,7 @@ export async function createA2AApp(options) {
|
|
|
562
652
|
// The caller's key funds its provider, and its hash prefixes the scope
|
|
563
653
|
// so no key holder can reach another's data.
|
|
564
654
|
const scopeKey = deriveUserId({ apiKey, siteUserId });
|
|
565
|
-
requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret }, () => next());
|
|
655
|
+
requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret, sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent) }, () => next());
|
|
566
656
|
return;
|
|
567
657
|
}
|
|
568
658
|
// Server mode: the server's credentials pay; X-API-Key is not used.
|
|
@@ -579,6 +669,7 @@ export async function createA2AApp(options) {
|
|
|
579
669
|
// Everyone holding the secret is one caller; without a secret (loopback) the caller is local.
|
|
580
670
|
caller: options.serverSecret ? 'shared-secret' : 'local',
|
|
581
671
|
operator: !!options.serverSecret,
|
|
672
|
+
sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
|
|
582
673
|
}, () => next());
|
|
583
674
|
});
|
|
584
675
|
// ── 6. Task rate limit ────────────────────────────────────────────────────
|
|
@@ -634,11 +725,13 @@ export async function createA2AApp(options) {
|
|
|
634
725
|
includeNested: !!ctx.ownsNested,
|
|
635
726
|
});
|
|
636
727
|
log(`Erasure for scope ${ctx.scopeKey}${all ? ' (all namespaces)' : ''}: ${JSON.stringify(counts)}`);
|
|
728
|
+
audit?.({ event: 'memory.erase', outcome: 'ok', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all, deleted: counts } });
|
|
637
729
|
res.json({ deleted: counts });
|
|
638
730
|
}
|
|
639
731
|
catch (err) {
|
|
640
732
|
const msg = err instanceof Error ? err.message : String(err);
|
|
641
733
|
warn(`Erasure failed: ${msg}`);
|
|
734
|
+
audit?.({ event: 'memory.erase', outcome: 'failed', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all } });
|
|
642
735
|
res.status(503).json({ error: msg });
|
|
643
736
|
}
|
|
644
737
|
});
|
|
@@ -739,6 +832,9 @@ export async function createA2AApp(options) {
|
|
|
739
832
|
app,
|
|
740
833
|
config,
|
|
741
834
|
sessionBackend,
|
|
835
|
+
markUnready: () => {
|
|
836
|
+
unready = true;
|
|
837
|
+
},
|
|
742
838
|
shutdown: async (graceMs) => {
|
|
743
839
|
const left = await limiter.drain(graceMs);
|
|
744
840
|
for (const t of leaseTimers)
|
|
@@ -18,6 +18,7 @@ import type { TurnLock } from './turnLock.ts';
|
|
|
18
18
|
import type { PendingApproval } from '../runtime/approvals.ts';
|
|
19
19
|
import type { MessagePart, SyndicateTurnResult } from '../runtime/syndicateTurn.ts';
|
|
20
20
|
import type { TaskRecord } from '../observability/metrics.ts';
|
|
21
|
+
import type { AuditSink } from '../observability/audit.ts';
|
|
21
22
|
import type { Policy } from './policy.ts';
|
|
22
23
|
/** Per-request caller context, set by the server's identity middleware. */
|
|
23
24
|
export interface A2AContext {
|
|
@@ -44,6 +45,10 @@ export interface A2AContext {
|
|
|
44
45
|
* server secret), as opposed to an end user (a JWT, a gateway header).
|
|
45
46
|
* Operator-only adopter routes check it. */
|
|
46
47
|
operator?: boolean;
|
|
48
|
+
/** The request's source address (behind A2A_TRUST_PROXY), for the audit trail only. */
|
|
49
|
+
sourceIp?: string;
|
|
50
|
+
/** The caller's W3C traceparent, when well formed: the turn's root span links to it. */
|
|
51
|
+
traceparent?: string;
|
|
47
52
|
}
|
|
48
53
|
export interface SurfaceContext {
|
|
49
54
|
name: string;
|
|
@@ -103,6 +108,8 @@ export interface ExecutorOptions {
|
|
|
103
108
|
policy?: Policy;
|
|
104
109
|
/** One record per task, however it ended: the task log and metrics. */
|
|
105
110
|
onTaskEnd?: (record: TaskRecord) => void;
|
|
111
|
+
/** Receives one `task.end` audit event per task (ADR 0042). */
|
|
112
|
+
onAudit?: AuditSink;
|
|
106
113
|
/**
|
|
107
114
|
* Stream the answer as the model writes it, as `answer` artifact chunks
|
|
108
115
|
* (see answerStream). Off by default; never for a syndicate with guards.
|
|
@@ -115,16 +122,39 @@ export interface ExecutorOptions {
|
|
|
115
122
|
log: (message: string) => void;
|
|
116
123
|
warn: (message: string) => void;
|
|
117
124
|
}
|
|
118
|
-
/**
|
|
125
|
+
/** Per-key caps beside the global one: 0 or absent means no cap. */
|
|
126
|
+
export interface TaskLimits {
|
|
127
|
+
/** Concurrent tasks for one scope (one end user's data). */
|
|
128
|
+
perScope?: number;
|
|
129
|
+
/** Concurrent tasks for one authenticated caller (a backend, a token). */
|
|
130
|
+
perCaller?: number;
|
|
131
|
+
}
|
|
132
|
+
/** Concurrency caps and in-flight registry shared by all executors. */
|
|
119
133
|
export declare class TaskLimiter {
|
|
120
134
|
readonly max: number;
|
|
135
|
+
readonly perScope: number;
|
|
136
|
+
readonly perCaller: number;
|
|
121
137
|
private readonly running;
|
|
138
|
+
/** taskId → the keys it counts against, so release frees exactly those. */
|
|
139
|
+
private readonly keysOf;
|
|
140
|
+
private readonly counts;
|
|
122
141
|
private draining;
|
|
123
142
|
private idleWaiters;
|
|
124
|
-
constructor(max: number);
|
|
143
|
+
constructor(max: number, limits?: TaskLimits);
|
|
144
|
+
/**
|
|
145
|
+
* Why a task for this scope and caller may not start now, or undefined.
|
|
146
|
+
* Checked before acquire() so the refusal can name the cap that was hit.
|
|
147
|
+
*/
|
|
148
|
+
refusal(who?: {
|
|
149
|
+
scopeKey?: string;
|
|
150
|
+
caller?: string;
|
|
151
|
+
}): string | undefined;
|
|
125
152
|
get inFlight(): number;
|
|
126
|
-
/** Reserve a slot. Returns undefined when
|
|
127
|
-
acquire(taskId: string
|
|
153
|
+
/** Reserve a slot. Returns undefined when any cap is reached or the server is shutting down. */
|
|
154
|
+
acquire(taskId: string, who?: {
|
|
155
|
+
scopeKey?: string;
|
|
156
|
+
caller?: string;
|
|
157
|
+
}): AbortController | undefined;
|
|
128
158
|
release(taskId: string): void;
|
|
129
159
|
cancel(taskId: string): boolean;
|
|
130
160
|
/** Stop admitting tasks; resolve when all running ones finish or `graceMs` passes,
|