@edgehero/pi-dispatch 4.3.1 → 5.0.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 +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { watchDurationMs } from "./watch-contract.mjs";
|
|
3
|
+
|
|
4
|
+
export const WATCH_ISSUES_PER_SOURCE_MAX = 4096;
|
|
5
|
+
export function issueIdentity(target, source, ruleId, fingerprint) {
|
|
6
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(source) || !/^[A-Za-z0-9_-]{1,64}$/.test(ruleId) || !/^[a-f0-9]{64}$/.test(fingerprint) || !["github", "gitlab", "forgejo"].includes(target?.kind) || typeof target.repo !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(target.repo) || target.repo.split("/").some(x => [".", ".."].includes(x))) throw new Error("invalid watch issue identity");
|
|
7
|
+
// Quiet policy is mutable reviewed config, never part of the remote issue's permanent identity.
|
|
8
|
+
return createHash("sha256").update(JSON.stringify([{ kind: target.kind, repo: target.repo }, source, ruleId, fingerprint])).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export const WATCH_ISSUE_ACTIVITY_SCRIPT = `-- watch-issue-activity
|
|
11
|
+
if not redis.call('GET',KEYS[2]) then return cjson.encode({at=tonumber(ARGV[1]),version=0}) end
|
|
12
|
+
local raw=redis.call('GET',KEYS[1]); local activity=raw and cjson.decode(raw) or {at=0,version=0}
|
|
13
|
+
activity.at=math.max(activity.at,tonumber(ARGV[1])); activity.version=activity.version+1
|
|
14
|
+
redis.call('SET',KEYS[1],cjson.encode(activity),'PX',2678400000)
|
|
15
|
+
local mapped=redis.call('GET',KEYS[2]); if mapped then local m=cjson.decode(mapped); if m.closeAfterMs and m.state=='mapped' then redis.call('ZADD',KEYS[3],activity.at+m.closeAfterMs,ARGV[2]) end end
|
|
16
|
+
return cjson.encode(activity)`;
|
|
17
|
+
export async function noteWatchIssueActivity(redis, { target, source, ruleId, fingerprint }, { prefix = "watch:issue", now = () => Date.now() } = {}) {
|
|
18
|
+
const identity = issueIdentity(target, source, ruleId, fingerprint);
|
|
19
|
+
return JSON.parse(await redis.eval(WATCH_ISSUE_ACTIVITY_SCRIPT, 3, `${prefix}:activity:${identity}`, `${prefix}:${identity}`, `${prefix}:quiet`, now(), identity));
|
|
20
|
+
}
|
|
21
|
+
const RESERVE = `-- watch-issue-reserve
|
|
22
|
+
local existing=redis.call('GET',KEYS[1]); if existing then return cjson.encode({owner=false,mapping=cjson.decode(existing)}) end
|
|
23
|
+
if redis.call('ZCARD',KEYS[2])>=tonumber(ARGV[1]) then return cjson.encode({capped=true}) end
|
|
24
|
+
redis.call('ZADD',KEYS[2],ARGV[2],ARGV[3]); redis.call('SET',KEYS[1],ARGV[4]); return cjson.encode({owner=true,mapping=cjson.decode(ARGV[4])})`;
|
|
25
|
+
const CLAIM = `-- watch-issue-close-claim
|
|
26
|
+
if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
|
|
27
|
+
if redis.call('GET',KEYS[2])~=ARGV[2] then return 0 end
|
|
28
|
+
return redis.call('SET',KEYS[3],ARGV[3],'NX','PX',30000) and 1 or 0`;
|
|
29
|
+
const COMMIT = `-- watch-issue-close-commit
|
|
30
|
+
if redis.call('GET',KEYS[3])~=ARGV[1] then return 0 end
|
|
31
|
+
if redis.call('GET',KEYS[1])~=ARGV[2] then return 0 end
|
|
32
|
+
if redis.call('GET',KEYS[2])~=ARGV[3] then return 0 end
|
|
33
|
+
redis.call('SET',KEYS[1],ARGV[4]); redis.call('ZREM',KEYS[4],ARGV[5]); return 1`;
|
|
34
|
+
const UNLOCK = `-- watch-issue-unlock
|
|
35
|
+
if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0`;
|
|
36
|
+
|
|
37
|
+
/** Forge adapters own transport. Pending writes reconcile by marker, never blind-create again. */
|
|
38
|
+
export function makeWatchIssuePublisher({ redis, adapter, prefix = "watch:issue", now = () => Date.now(), maxPerSource = WATCH_ISSUES_PER_SOURCE_MAX, canClose = async () => false, reportClose = async () => {} }) {
|
|
39
|
+
for (const method of ["findIssue", "createIssue", "updateIssue"]) if (typeof adapter?.[method] !== "function") throw new Error("watch issue adapter is incomplete");
|
|
40
|
+
if (!Number.isSafeInteger(maxPerSource) || maxPerSource < 1 || maxPerSource > WATCH_ISSUES_PER_SOURCE_MAX) throw new Error("invalid watch issue index bound");
|
|
41
|
+
const activityKey = id => `${prefix}:activity:${id}`;
|
|
42
|
+
const noteActivity = args => noteWatchIssueActivity(redis, args, { prefix, now });
|
|
43
|
+
const schedule = async (identity, mapping) => { if (mapping.closeAfterMs) { const activity = JSON.parse(await redis.get(activityKey(identity))); await redis.zadd(`${prefix}:quiet`, activity.at + mapping.closeAfterMs, identity); } };
|
|
44
|
+
const publish = async function publishWatchIssue({ source, ruleId, fingerprint, finding, target }) {
|
|
45
|
+
const identity = issueIdentity(target, source, ruleId, fingerprint);
|
|
46
|
+
const closeAfterMs = target.closeAfter === undefined ? undefined : watchDurationMs(target.closeAfter);
|
|
47
|
+
if (closeAfterMs && typeof adapter.closeIssue !== "function") throw new Error("watch issue quiet closure unsupported");
|
|
48
|
+
const key = `${prefix}:${identity}`; const marker = `pi-dispatch-watch:${identity}`;
|
|
49
|
+
const pending = { state: "pending", marker, source, ruleId, fingerprint, target: { kind: target.kind, repo: target.repo }, ...(closeAfterMs ? { closeAfterMs } : {}) };
|
|
50
|
+
const reserved = JSON.parse(await redis.eval(RESERVE, 2, key, `${prefix}:source:${source}`, maxPerSource, now(), identity, JSON.stringify(pending)));
|
|
51
|
+
if (reserved.capped) return { status: "capped", reason: "watch-issue-cap" };
|
|
52
|
+
await noteActivity({ source, ruleId, fingerprint, target });
|
|
53
|
+
let mapping = reserved.mapping;
|
|
54
|
+
const title = `[watch] ${ruleId} ${fingerprint.slice(0, 12)}`;
|
|
55
|
+
const count = Number.isSafeInteger(finding.count) && finding.count >= 0 ? finding.count : 0;
|
|
56
|
+
const severity = ["debug", "info", "warning", "error", "critical"].includes(finding.severity) ? finding.severity : "unknown";
|
|
57
|
+
const body = `<!-- ${marker} -->\nWatch rule: ${ruleId}\nSource: ${source}\nFingerprint: ${fingerprint}\nSeverity: ${severity}\nEvents: ${count}\n\nA person with repository write access must apply the configured label before code work starts.`;
|
|
58
|
+
if (!reserved.owner && mapping.state === "pending") {
|
|
59
|
+
let found; try { found = await adapter.findIssue({ target, marker }); } catch { throw new Error("watch issue reconciliation unavailable"); }
|
|
60
|
+
if (!found) return { status: "uncertain", reason: "watch-issue-uncertain" };
|
|
61
|
+
mapping = { ...pending, state: "mapped", issueId: String(found.id) };
|
|
62
|
+
await redis.set(key, JSON.stringify(mapping));
|
|
63
|
+
}
|
|
64
|
+
if (mapping.issueId !== undefined) {
|
|
65
|
+
try { await adapter.updateIssue({ target, id: mapping.issueId, title, body, state: "open" }); } catch { throw new Error("watch issue update unavailable"); }
|
|
66
|
+
mapping = { ...pending, state: "mapped", issueId: mapping.issueId };
|
|
67
|
+
await redis.set(key, JSON.stringify(mapping)); await schedule(identity, mapping);
|
|
68
|
+
if (!closeAfterMs) await redis.zrem(`${prefix}:quiet`, identity);
|
|
69
|
+
return { status: "updated", issueId: mapping.issueId };
|
|
70
|
+
}
|
|
71
|
+
let issue; try { issue = await adapter.createIssue({ target, title, body, marker }); } catch { throw new Error("watch issue create outcome unknown"); }
|
|
72
|
+
if (!issue || issue.id === undefined) throw new Error("watch issue create outcome unknown");
|
|
73
|
+
mapping = { ...pending, state: "mapped", issueId: String(issue.id) };
|
|
74
|
+
await redis.set(key, JSON.stringify(mapping)); await schedule(identity, mapping);
|
|
75
|
+
return { status: "created", issueId: mapping.issueId };
|
|
76
|
+
};
|
|
77
|
+
publish.noteActivity = noteActivity;
|
|
78
|
+
publish.sweep = async function sweep() {
|
|
79
|
+
const outcomes = [];
|
|
80
|
+
const report = async (outcome, mapping) => { outcomes.push(outcome); try { await reportClose(outcome, mapping); } catch {} };
|
|
81
|
+
for (const identity of await redis.zrangebyscore(`${prefix}:quiet`, "-inf", now(), "LIMIT", 0, 128)) {
|
|
82
|
+
if (!/^[a-f0-9]{64}$/.test(identity)) continue;
|
|
83
|
+
const key = `${prefix}:${identity}`; const lock = `${prefix}:closing:${identity}`;
|
|
84
|
+
const raw = await redis.get(key); const activityRaw = await redis.get(activityKey(identity));
|
|
85
|
+
let mapping; let activity;
|
|
86
|
+
try { mapping = JSON.parse(raw); activity = JSON.parse(activityRaw); } catch { await report({ identity, status: "uncertain", reason: "watch-issue-close-uncertain" }, null); continue; }
|
|
87
|
+
if (!mapping?.closeAfterMs || mapping.state !== "mapped") { await redis.zrem(`${prefix}:quiet`, identity); continue; }
|
|
88
|
+
if (!activity || activity.at + mapping.closeAfterMs > now()) { if (activity) await redis.zadd(`${prefix}:quiet`, activity.at + mapping.closeAfterMs, identity); continue; }
|
|
89
|
+
let allowed = false; try { allowed = await canClose(mapping); } catch {}
|
|
90
|
+
if (!allowed) { await redis.zrem(`${prefix}:quiet`, identity); await report({ identity, status: "suppressed", reason: "watch-issue-close-policy-changed" }, mapping); continue; }
|
|
91
|
+
const token = randomUUID();
|
|
92
|
+
if (!await redis.eval(CLAIM, 3, key, activityKey(identity), lock, raw, activityRaw, token)) continue;
|
|
93
|
+
try {
|
|
94
|
+
await adapter.closeIssue({ target: mapping.target, id: mapping.issueId });
|
|
95
|
+
const closed = { ...mapping, state: "closed" };
|
|
96
|
+
const committed = await redis.eval(COMMIT, 4, key, activityKey(identity), lock, `${prefix}:quiet`, token, raw, activityRaw, JSON.stringify(closed), identity);
|
|
97
|
+
if (!committed) {
|
|
98
|
+
// A collector advanced activity during the remote close. Reopen rather than hide that race.
|
|
99
|
+
await adapter.updateIssue({ target: mapping.target, id: mapping.issueId, state: "open" });
|
|
100
|
+
const current = JSON.parse(await redis.get(key));
|
|
101
|
+
if (current?.state === "closed") await redis.set(key, JSON.stringify({ ...current, state: "mapped" }));
|
|
102
|
+
await schedule(identity, mapping);
|
|
103
|
+
await report({ identity, status: "active", reason: "watch-issue-close-raced" }, mapping);
|
|
104
|
+
} else await report({ identity, status: "closed" }, mapping);
|
|
105
|
+
} catch { await report({ identity, status: "uncertain", reason: "watch-issue-close-uncertain" }, mapping); }
|
|
106
|
+
finally { await redis.eval(UNLOCK, 1, lock, token); }
|
|
107
|
+
}
|
|
108
|
+
return outcomes;
|
|
109
|
+
};
|
|
110
|
+
return publish;
|
|
111
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Only reviewed names and numeric answers may enter a durable run record. */
|
|
2
|
+
export function recordedWatchDecision(value) {
|
|
3
|
+
if (!value || typeof value !== "object" || !["investigate", "file_issue", "ignore"].includes(value.action) || typeof value.model !== "string" || !/^[A-Za-z0-9_.:/@~-]{1,128}$/.test(value.model)) return null;
|
|
4
|
+
const out = { model: value.model, action: value.action, shadow: value.shadow === true };
|
|
5
|
+
if (Array.isArray(value.questions) && value.questions.length <= 16 && value.questions.every(n => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(n))) out.questions = [...value.questions];
|
|
6
|
+
if (value.probabilities && Object.keys(value.probabilities).every(k => ["investigate", "file_issue", "ignore"].includes(k)) && Object.values(value.probabilities).every(p => Number.isFinite(p) && p >= 0 && p <= 1)) out.probabilities = { ...value.probabilities };
|
|
7
|
+
if (Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1) out.confidence = value.confidence;
|
|
8
|
+
if (Number.isFinite(value.costUsd) && value.costUsd >= 0) out.costUsd = value.costUsd;
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { collectedWatchTrace } from "./watch-trace.mjs";
|
|
2
|
+
import { makeRedactor } from "./redact-secrets.mjs";
|
|
3
|
+
import { recordedWatchDecision } from "./watch-record.mjs";
|
|
4
|
+
import * as nodeFs from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { parseTriggersDocument } from "./triggers.mjs";
|
|
8
|
+
import { selectWatchDecision, projectWatchDecisionState } from "./watch-decision.mjs";
|
|
9
|
+
import { validateWatchBoard } from "./watch-board.mjs";
|
|
10
|
+
|
|
11
|
+
const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
12
|
+
const refusal = reason => ({ outcome: "policy", reason, budgetReserved: false });
|
|
13
|
+
const modelRef = ref => { const at = ref.indexOf("/"); return { provider: ref.slice(0, at), id: ref.slice(at + 1) }; };
|
|
14
|
+
|
|
15
|
+
/** Open the inode once: an agent may replace an outbox path, including with a FIFO or symlink. */
|
|
16
|
+
export function readWatchOutput(path, maxBytes, fs = nodeFs) {
|
|
17
|
+
let fd;
|
|
18
|
+
try {
|
|
19
|
+
fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
20
|
+
const stat = fs.fstatSync(fd);
|
|
21
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
22
|
+
const bytes = Buffer.alloc(maxBytes + 1);
|
|
23
|
+
let used = 0;
|
|
24
|
+
while (used < bytes.length) { const n = fs.readSync(fd, bytes, used, bytes.length - used, null); if (!n) break; used += n; }
|
|
25
|
+
return used > maxBytes ? null : bytes.subarray(0, used).toString("utf8");
|
|
26
|
+
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function refusedOutput(path, maxBytes, fs) {
|
|
30
|
+
try { const stat = fs.lstatSync(path); return stat.size > maxBytes ? "watch-board-oversize" : "watch-board-invalid"; }
|
|
31
|
+
catch (error) { return error?.code === "ENOENT" ? null : "watch-board-invalid"; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Producer references are resolved again at pickup; queue bytes never select execution settings. */
|
|
35
|
+
export function makeWatchRuntime({ store, triggersPath, readFile = nodeFs.readFileSync, enqueue, publishIssue, getSettings = () => ({}), pauseUntil = () => null, now = Date.now, fs = nodeFs, log = () => {} }) {
|
|
36
|
+
const document = () => parseTriggersDocument(readFile(triggersPath, "utf8"), triggersPath);
|
|
37
|
+
const currentRule = (doc, id) => doc.triggers.find(t => t.on.type === "watch" && t.on.id === id);
|
|
38
|
+
let draining = false;
|
|
39
|
+
return {
|
|
40
|
+
async started(job) {
|
|
41
|
+
if (job.watch?.stage !== "decision") return;
|
|
42
|
+
await store.updateFinding(job.watch.findingId, { status: "deciding", runId: job.watch.runId });
|
|
43
|
+
await store.scheduleAction(job.watch.findingId, job.watch.ruleId, now() + 35 * 60 * 1000);
|
|
44
|
+
},
|
|
45
|
+
async drain() {
|
|
46
|
+
if (draining) return;
|
|
47
|
+
draining = true;
|
|
48
|
+
try {
|
|
49
|
+
if (publishIssue?.sweep) for (const outcome of await publishIssue.sweep()) log("watch_issue_close", { identity: outcome.identity, status: outcome.status, ...(outcome.reason ? { reason: outcome.reason } : {}) });
|
|
50
|
+
for (const fields of await store.pendingActions()) {
|
|
51
|
+
if (!await store.readFinding(fields.id)) { await store.acknowledgeAction(fields.id); continue; }
|
|
52
|
+
await enqueue(fields, { phase: "action" });
|
|
53
|
+
}
|
|
54
|
+
} finally { draining = false; }
|
|
55
|
+
},
|
|
56
|
+
async resolve(data, runId) {
|
|
57
|
+
if (data?.kind !== "watch" && !data?.watch) return { data };
|
|
58
|
+
const ref = data.watch;
|
|
59
|
+
if (!ref || !/^[a-f0-9]{64}$/.test(ref.findingId) || !["dispatch", "action"].includes(ref.phase)) return { result: refusal("watch-reference-invalid") };
|
|
60
|
+
// Issue #761: a read of a file a newer pi-dispatch wrote is `{ newer: <the store> }`, never `watch-config-changed`,
|
|
61
|
+
// and is answered BEFORE the finding is taken (a take cannot be undone): the processor defers the job
|
|
62
|
+
// (`newer-release`) and this build neither misreads the rule nor spends the finding.
|
|
63
|
+
let doc;
|
|
64
|
+
try { doc = document(); } catch (error) { if (error?.newer === true) return { newer: "the triggers file" }; if (error?.piDispatchConfig || error?.code === "ENOENT") return { result: refusal("watch-config-changed") }; throw error; }
|
|
65
|
+
const rule = currentRule(doc, ref.ruleId);
|
|
66
|
+
if (!rule || !doc.watchSources?.[rule.on.source]) return { result: refusal("watch-config-changed") };
|
|
67
|
+
const target = rule.run.mode === "issue" ? { kind: rule.run.issue.kind, repo: rule.run.issue.repo } : { kind: "local", folder: rule.run.folder };
|
|
68
|
+
const until = pauseUntil(target, now());
|
|
69
|
+
if (Number.isFinite(until) && until > now()) return { deferUntil: until };
|
|
70
|
+
// The settings a decision reads, read ONCE and before the take (issue #761), so a newer overlay defers the job
|
|
71
|
+
// with the finding still in place. Only a deciding rule's dispatch can become a decision, so only it reads them.
|
|
72
|
+
const settings = rule.decide && ref.phase === "dispatch" ? getSettings() : null;
|
|
73
|
+
if (settings?.invalid && settings.newer === true) return { newer: "the settings file" };
|
|
74
|
+
const taken = await store.takeFinding(ref.findingId);
|
|
75
|
+
if (taken.status !== "ready") return { result: refusal(taken.status === "below-threshold" ? "watch-threshold" : taken.status === "held" ? "watch-window-pending" : "watch-evidence-expired") };
|
|
76
|
+
const finding = taken.finding;
|
|
77
|
+
if (finding.ruleId !== ref.ruleId || finding.source !== rule.on.source) return { result: refusal("watch-reference-invalid") };
|
|
78
|
+
if (finding.policyDigest && finding.policyDigest !== digest(rule)) return { result: refusal("watch-config-changed") };
|
|
79
|
+
const latest = await store.readFinding(ref.findingId, { private: true });
|
|
80
|
+
if (ref.phase === "action") await store.acknowledgeAction(ref.findingId);
|
|
81
|
+
const decision = recordedWatchDecision(latest?.decision);
|
|
82
|
+
let phase = "investigate";
|
|
83
|
+
let action = rule.run.mode === "issue" ? "file_issue" : "investigate";
|
|
84
|
+
if (rule.decide) {
|
|
85
|
+
if (ref.phase === "dispatch" && !decision && latest?.status !== "deciding") phase = "decision";
|
|
86
|
+
else action = decision ? (rule.decide.shadow ? "investigate" : decision.action) : "file_issue";
|
|
87
|
+
}
|
|
88
|
+
if (phase !== "decision" && action === "ignore") { await store.updateFinding(ref.findingId, { status: "ignored", reason: "watch-ignored" }); return { result: { outcome: "completed", reason: "watch-ignored", budgetReserved: false, ...(decision ? { decision } : {}) } }; }
|
|
89
|
+
if (phase !== "decision" && action === "file_issue") {
|
|
90
|
+
if (!rule.run.issue || typeof publishIssue !== "function") return { result: refusal("watch-issue-target-missing") };
|
|
91
|
+
const published = await publishIssue({ source: finding.source, ruleId: ref.ruleId, fingerprint: finding.fingerprint, finding, target: rule.run.issue });
|
|
92
|
+
await store.updateFinding(ref.findingId, { status: published.status === "uncertain" ? "issue-uncertain" : published.status === "capped" ? "capped" : "issue", ...(published.reason ? { reason: published.reason } : {}) });
|
|
93
|
+
return { result: { outcome: ["uncertain", "capped"].includes(published.status) ? "policy" : "completed", budgetReserved: false, ...(published.reason ? { reason: published.reason } : {}), ...(decision ? { decision } : {}) } };
|
|
94
|
+
}
|
|
95
|
+
const board = await store.readBoard(ref.ruleId);
|
|
96
|
+
const execution = { ...rule.run };
|
|
97
|
+
delete execution.mode; delete execution.issue; delete execution.queryLogs;
|
|
98
|
+
const watch = { findingId: ref.findingId, ruleId: ref.ruleId, phase: ref.phase, configDigest: digest(rule), ...(decision ? { decision } : {}) };
|
|
99
|
+
// BullMQ deferrals may persist job.data. Private evidence must survive in memory without
|
|
100
|
+
// becoming queue data when an unrelated admission gate persists its data.
|
|
101
|
+
Object.defineProperties(watch, { stage: { value: phase }, project: { value: doc.watchSources[rule.on.source].project ?? null }, runId: { value: runId }, finding: { value: finding }, board: { value: { version: 1, blocks: board.blocks } }, queries: { value: rule.run.queryLogs ?? {} } });
|
|
102
|
+
if (phase === "decision") {
|
|
103
|
+
Object.defineProperty(watch, "gate", { value: { version: 1, model: modelRef(rule.decide.model), state: projectWatchDecisionState({ ...finding, window: rule.on.window }, rule.decide.fields), questions: rule.decide.questions } });
|
|
104
|
+
execution.maxCostUsd = rule.decide.maxCostUsd;
|
|
105
|
+
// The read from before the take: a broken overlay is still refused here, as it always was.
|
|
106
|
+
if (settings.invalid) return { result: refusal("watch-config-changed") };
|
|
107
|
+
const primary = `${execution.provider ?? settings.provider}/${execution.model ?? settings.model}`;
|
|
108
|
+
execution.models = [...new Set([...(execution.models ?? []), primary, rule.decide.model])];
|
|
109
|
+
await store.updateFinding(ref.findingId, { status: "decision-pending", runId });
|
|
110
|
+
} else await store.updateFinding(ref.findingId, { status: "running", runId });
|
|
111
|
+
await store.appendTrace(ref.findingId, { id: `${runId}:admit`, kind: "rule", status: phase, ruleId: ref.ruleId, count: finding.count });
|
|
112
|
+
return { data: { ...execution, kind: "local", github: false, packages: false, watch, ...(rule.decide ? { watchDecisionModel: modelRef(rule.decide.model) } : {}) } };
|
|
113
|
+
},
|
|
114
|
+
/** A completed paid attempt is never retried because collecting its output failed. */
|
|
115
|
+
async collect({ job, prepared, completed, policyRefused = false, usage }) {
|
|
116
|
+
if (!job.watch || !prepared?.outboxDir) return;
|
|
117
|
+
const watch = job.watch;
|
|
118
|
+
const redact = prepared.watchRedact ?? makeRedactor();
|
|
119
|
+
const privateEntry = entry => JSON.parse(redact(JSON.stringify(entry)));
|
|
120
|
+
try {
|
|
121
|
+
const raw = readWatchOutput(join(prepared.outboxDir, "watch-trace.jsonl"), 262144, fs);
|
|
122
|
+
const traceIds = new Set();
|
|
123
|
+
let traceUnavailable = false;
|
|
124
|
+
if (raw === null && refusedOutput(join(prepared.outboxDir, "watch-trace.jsonl"), 262144, fs)) traceUnavailable = true;
|
|
125
|
+
if (raw !== null) for (const line of raw.split("\n").filter(Boolean).slice(0, 128)) {
|
|
126
|
+
let entry; try { entry = JSON.parse(line); } catch { traceUnavailable = true; continue; }
|
|
127
|
+
const projected = collectedWatchTrace(entry, { model: job.watchDecisionModel ? `${job.watchDecisionModel.provider}/${job.watchDecisionModel.id}` : null, ruleId: watch.ruleId, runId: watch.runId });
|
|
128
|
+
if (!projected) { traceUnavailable = true; continue; }
|
|
129
|
+
if (["query", "decide"].includes(entry.kind) && entry.status === "completed") traceIds.add(entry.id);
|
|
130
|
+
try { await store.appendTrace(watch.findingId, privateEntry(projected)); } catch { traceUnavailable = true; }
|
|
131
|
+
}
|
|
132
|
+
let rule;
|
|
133
|
+
try { rule = currentRule(document(), watch.ruleId); } catch {}
|
|
134
|
+
if (!rule || digest(rule) !== watch.configDigest) { await store.updateFinding(watch.findingId, { status: "refused", reason: "watch-config-changed" }); return; }
|
|
135
|
+
if (watch.stage === "decision") {
|
|
136
|
+
let output; try { output = JSON.parse(readWatchOutput(join(prepared.outboxDir, "decision.json"), 16384, fs)); } catch {}
|
|
137
|
+
const selected = completed ? selectWatchDecision(rule.decide, output) : { action: "file_issue", reason: policyRefused ? "decide-refused" : "decide-unanswered" };
|
|
138
|
+
const summary = recordedWatchDecision({ ...selected, model: rule.decide.model, questions: Object.keys(rule.decide.questions), shadow: rule.decide.shadow, costUsd: usage?.costUsd });
|
|
139
|
+
watch.decision = summary;
|
|
140
|
+
try { await store.appendTrace(watch.findingId, privateEntry({ id: "decision", kind: "decide", state: watch.gate.state, questions: rule.decide.questions, ...summary, modelReviewed: true, status: selected.reason ?? "answered" })); } catch { traceUnavailable = true; }
|
|
141
|
+
await store.updateFinding(watch.findingId, { status: "decided", decision: summary, ...(selected.reason ? { reason: selected.reason } : {}) });
|
|
142
|
+
await store.scheduleAction(watch.findingId, watch.ruleId);
|
|
143
|
+
await enqueue({ id: watch.findingId, ruleId: watch.ruleId, dueAt: now() }, { phase: "action" });
|
|
144
|
+
} else {
|
|
145
|
+
const boardRaw = completed ? readWatchOutput(join(prepared.outboxDir, "board.json"), 16384, fs) : null;
|
|
146
|
+
const boardProblem = completed && boardRaw === null ? refusedOutput(join(prepared.outboxDir, "board.json"), 16384, fs) : null;
|
|
147
|
+
if (boardProblem) await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: boardProblem });
|
|
148
|
+
if (boardRaw !== null && !traceUnavailable) {
|
|
149
|
+
const checked = validateWatchBoard(redact(boardRaw), { traceIds });
|
|
150
|
+
if (checked.ok) {
|
|
151
|
+
for (const block of checked.board.blocks) block.traceId = `${watch.runId}:${block.traceId}`;
|
|
152
|
+
const remapped = validateWatchBoard(checked.board);
|
|
153
|
+
if (remapped.ok) await store.proposeBoard(watch.ruleId, checked.board, watch.findingId);
|
|
154
|
+
else await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: remapped.reason });
|
|
155
|
+
}
|
|
156
|
+
else await store.appendTrace(watch.findingId, { id: "board-refused", kind: "board", status: checked.reason });
|
|
157
|
+
}
|
|
158
|
+
await store.updateFinding(watch.findingId, { status: completed ? "completed" : "failed" });
|
|
159
|
+
}
|
|
160
|
+
if (traceUnavailable) await store.updateFinding(watch.findingId, { reason: "watch-trace-unavailable" });
|
|
161
|
+
} catch {
|
|
162
|
+
log("watch-output-unavailable", { ruleId: watch.ruleId });
|
|
163
|
+
try { await store.updateFinding(watch.findingId, { status: "unavailable", reason: "watch-output-unavailable" }); } catch {}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { publicWatchTrace } from "./watch-trace.mjs";
|
|
2
|
+
import { recordedWatchDecision } from "./watch-record.mjs";
|
|
3
|
+
import { noteWatchIssueActivity } from "./watch-issue.mjs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { validateWatchBoard as checkBoard } from "./watch-board.mjs";
|
|
6
|
+
import { watchFingerprint, matchesWatchFinding, watchDurationMs } from "./watch-contract.mjs";
|
|
7
|
+
|
|
8
|
+
const TTL_MS = 7 * 86400000;
|
|
9
|
+
// One script owns replay, digest and caps: a receiver crash between separate writes must never
|
|
10
|
+
// consume a delivery without leaving the same enqueueable finding for its retry.
|
|
11
|
+
export const WATCH_INGEST_SCRIPT = `
|
|
12
|
+
local now = tonumber(ARGV[1])
|
|
13
|
+
local ttl = tonumber(ARGV[2])
|
|
14
|
+
local replay = redis.call('GET', KEYS[1])
|
|
15
|
+
if replay then return replay end
|
|
16
|
+
local sourceCount = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
17
|
+
if sourceCount >= tonumber(ARGV[3]) then return cjson.encode({status='capped',reason='watch-cap'}) end
|
|
18
|
+
redis.call('INCR', KEYS[2]); redis.call('PEXPIRE', KEYS[2], 172800000)
|
|
19
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[4], '-inf', now)
|
|
20
|
+
local active = redis.call('GET', KEYS[3])
|
|
21
|
+
local result
|
|
22
|
+
if active then
|
|
23
|
+
local a = cjson.decode(active)
|
|
24
|
+
if now > a.dueAt + tonumber(ARGV[7]) then redis.call('DEL', KEYS[3]); active=nil end
|
|
25
|
+
end
|
|
26
|
+
if active then
|
|
27
|
+
local a = cjson.decode(active)
|
|
28
|
+
if now <= a.dueAt then
|
|
29
|
+
local existing = redis.call('GET', ARGV[12] .. ':finding:' .. a.id)
|
|
30
|
+
if existing then
|
|
31
|
+
local f = cjson.decode(existing)
|
|
32
|
+
if not f.claimed then
|
|
33
|
+
local incoming = cjson.decode(ARGV[8])
|
|
34
|
+
f.count = math.min(1000000, f.count + incoming.count)
|
|
35
|
+
if incoming.firstSeen < f.firstSeen then f.firstSeen = incoming.firstSeen end
|
|
36
|
+
if incoming.lastSeen > f.lastSeen then f.lastSeen = incoming.lastSeen end
|
|
37
|
+
for _, sample in ipairs(incoming.samples) do if #f.samples < 8 then table.insert(f.samples, sample) end end
|
|
38
|
+
redis.call('SET', ARGV[12] .. ':finding:' .. a.id, cjson.encode(f), 'PX', ttl)
|
|
39
|
+
result = cjson.encode({status='held',id=a.id,fingerprint=ARGV[9],dueAt=a.dueAt})
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
if not result then result = cjson.encode({status='suppressed',id=a.id,fingerprint=ARGV[9],dueAt=a.dueAt,reason='watch-cooldown'}) end
|
|
44
|
+
elseif redis.call('ZCARD', KEYS[4]) >= tonumber(ARGV[4]) or tonumber(redis.call('GET', KEYS[5]) or '0') >= tonumber(ARGV[5]) then
|
|
45
|
+
result = cjson.encode({status='capped',reason='watch-cap',fingerprint=ARGV[9]})
|
|
46
|
+
else
|
|
47
|
+
local due = now + tonumber(ARGV[6])
|
|
48
|
+
local a = {id=ARGV[10],dueAt=due}
|
|
49
|
+
local f = cjson.decode(ARGV[8]); f.id=ARGV[10]; f.fingerprint=ARGV[9]; f.dueAt=due; f.createdAt=now
|
|
50
|
+
f.source=ARGV[11]; f.ruleId=ARGV[13]; f.status='held'; f.minCount=tonumber(ARGV[15])
|
|
51
|
+
redis.call('SET', KEYS[3], cjson.encode(a), 'PX', tonumber(ARGV[6])+tonumber(ARGV[7]))
|
|
52
|
+
redis.call('ZADD', KEYS[4], due+tonumber(ARGV[7]), ARGV[9]); redis.call('PEXPIRE', KEYS[4], ttl+tonumber(ARGV[7]))
|
|
53
|
+
redis.call('INCR', KEYS[5]); redis.call('PEXPIRE', KEYS[5], 172800000)
|
|
54
|
+
redis.call('SET', ARGV[12] .. ':finding:' .. ARGV[10], cjson.encode(f), 'PX', ttl)
|
|
55
|
+
redis.call('ZADD', KEYS[6], now, ARGV[10]); redis.call('ZREMRANGEBYSCORE', KEYS[6], '-inf', now-ttl); redis.call('PEXPIRE', KEYS[6], ttl)
|
|
56
|
+
redis.call('HSET', KEYS[7], ARGV[13], ARGV[14]); redis.call('PEXPIRE', KEYS[7], ttl)
|
|
57
|
+
result = cjson.encode({status='held',id=ARGV[10],fingerprint=ARGV[9],dueAt=due})
|
|
58
|
+
end
|
|
59
|
+
redis.call('SET', KEYS[1], result, 'PX', ttl)
|
|
60
|
+
return result
|
|
61
|
+
`;
|
|
62
|
+
const BOARD_SCRIPT = `
|
|
63
|
+
local raw=redis.call('GET',KEYS[1]); local b
|
|
64
|
+
if raw then b=cjson.decode(raw) else b=cjson.decode(ARGV[1]) end
|
|
65
|
+
if b.version ~= tonumber(ARGV[2]) then return cjson.encode({ok=false,reason='board-conflict',board=b}) end
|
|
66
|
+
local change=cjson.decode(ARGV[3])
|
|
67
|
+
if change.action=='freeze' then b.frozen=change.frozen
|
|
68
|
+
elseif change.action=='clear' then b.blocks={}; b.proposals={}
|
|
69
|
+
elseif change.action=='adopt' then
|
|
70
|
+
if b.frozen then return cjson.encode({ok=false,reason='board-frozen',board=b}) end
|
|
71
|
+
local found=false
|
|
72
|
+
for _,p in ipairs(b.proposals or {}) do if p.id==change.proposalId then b.blocks=p.blocks; found=true end end
|
|
73
|
+
if not found then return cjson.encode({ok=false,reason='board-proposal-missing',board=b}) end
|
|
74
|
+
elseif change.action=='revert' then
|
|
75
|
+
local found=false
|
|
76
|
+
for _,v in ipairs(b.versions) do if v.version==change.version then b.blocks=v.blocks; found=true end end
|
|
77
|
+
if not found then return cjson.encode({ok=false,reason='board-version-missing',board=b}) end
|
|
78
|
+
else return cjson.encode({ok=false,reason='board-action-invalid',board=b}) end
|
|
79
|
+
b.version=b.version+1
|
|
80
|
+
table.insert(b.versions,{version=b.version,blocks=b.blocks})
|
|
81
|
+
while #b.versions>20 do table.remove(b.versions,1) end
|
|
82
|
+
redis.call('SET',KEYS[1],cjson.encode(b),'PX',ARGV[4])
|
|
83
|
+
return cjson.encode({ok=true,board=b})
|
|
84
|
+
`;
|
|
85
|
+
function validId(value) { if (typeof value !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) throw new Error("invalid watch identifier"); return value; }
|
|
86
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
87
|
+
const parse = raw => raw ? JSON.parse(raw) : null;
|
|
88
|
+
function boardArrays(board) { if (!Array.isArray(board.blocks)) board.blocks = []; if (!Array.isArray(board.versions)) board.versions = []; if (!Array.isArray(board.proposals)) board.proposals = []; for (const proposal of board.proposals) if (!Array.isArray(proposal.blocks)) proposal.blocks = []; for (const version of board.versions) if (!Array.isArray(version.blocks)) version.blocks = []; return board; }
|
|
89
|
+
const publicFinding = f => f && { ...Object.fromEntries(["id", "source", "ruleId", "fingerprint", "severity", "count", "firstSeen", "lastSeen", "dueAt", "createdAt", "status", "reason", "runId"].filter(k => f[k] !== undefined).map(k => [k, f[k]])), ...(recordedWatchDecision(f.decision) ? { decision: recordedWatchDecision(f.decision) } : {}) };
|
|
90
|
+
export function makeWatchStore(redis, { now = () => Date.now(), prefix = "watch", sourceCaps = {} } = {}) {
|
|
91
|
+
const key = suffix => `${prefix}:${suffix}`;
|
|
92
|
+
const emptyBoard = ruleId => ({ ruleId, version: 0, frozen: false, blocks: [], versions: [], proposals: [] });
|
|
93
|
+
return {
|
|
94
|
+
async noteIssueActivity(args) { return noteWatchIssueActivity(redis, args, { now }); },
|
|
95
|
+
async recordIssueOutcome(ruleId, outcome) {
|
|
96
|
+
validId(ruleId);
|
|
97
|
+
if (!["closed", "active", "uncertain", "suppressed"].includes(outcome.status) || (outcome.reason !== undefined && !["watch-issue-close-uncertain", "watch-issue-close-raced", "watch-issue-close-policy-changed"].includes(outcome.reason))) throw new Error("invalid watch issue outcome");
|
|
98
|
+
await redis.multi().hset(key(`summary:${ruleId}`), "lastIssueStatus", outcome.status, "lastIssueReason", outcome.reason ?? "").pexpire(key(`summary:${ruleId}`), TTL_MS).exec();
|
|
99
|
+
},
|
|
100
|
+
async scheduleAction(id, ruleId, dueAt = now()) {
|
|
101
|
+
validId(id); validId(ruleId);
|
|
102
|
+
await redis.multi().hset(key("pending-action-rules"), id, ruleId).zadd(key("pending-actions"), dueAt, id).exec();
|
|
103
|
+
},
|
|
104
|
+
async pendingActions() {
|
|
105
|
+
const ids = await redis.zrangebyscore(key("pending-actions"), "-inf", now(), "LIMIT", 0, 100);
|
|
106
|
+
return (await Promise.all(ids.map(async id => ({ id, ruleId: await redis.hget(key("pending-action-rules"), id), dueAt: now() })))).filter(row => row.ruleId);
|
|
107
|
+
},
|
|
108
|
+
async acknowledgeAction(id) { validId(id); await redis.multi().zrem(key("pending-actions"), id).hdel(key("pending-action-rules"), id).exec(); },
|
|
109
|
+
async ingest({ source, rule, finding, delivery, sourceMaxPerDay }) {
|
|
110
|
+
validId(source); validId(rule.id); validId(delivery);
|
|
111
|
+
if (!matchesWatchFinding(rule, finding)) return { status: "unmatched", reason: "watch-unmatched" };
|
|
112
|
+
const fingerprint = watchFingerprint(source, rule, finding);
|
|
113
|
+
const clock = now(); const day = new Date(clock).toISOString().slice(0, 10);
|
|
114
|
+
const id = hash(JSON.stringify([source, rule.id, fingerprint, delivery, clock]));
|
|
115
|
+
const ruleKey = `${source}:${rule.id}`;
|
|
116
|
+
const result = await redis.eval(WATCH_INGEST_SCRIPT, 7,
|
|
117
|
+
key(`delivery:${source}:${rule.id}:${delivery}`), key(`source-day:${source}:${day}`), key(`active:${ruleKey}:${fingerprint}`), key(`fingerprints:${ruleKey}`), key(`rule-day:${ruleKey}:${day}`), key(`findings:${rule.id}`), key("rules"),
|
|
118
|
+
clock, TTL_MS, sourceMaxPerDay ?? sourceCaps[source] ?? 1000, rule.maxFingerprints ?? 256, rule.maxPerDay, rule.windowMs ?? watchDurationMs(rule.window), rule.cooldownMs ?? watchDurationMs(rule.cooldown), JSON.stringify(finding), fingerprint, id, source, prefix, rule.id, JSON.stringify({ id: rule.id, source, maxPerDay: rule.maxPerDay }), rule.minCount ?? 1);
|
|
119
|
+
return parse(result);
|
|
120
|
+
},
|
|
121
|
+
async takeFinding(id) {
|
|
122
|
+
validId(id);
|
|
123
|
+
const script = `local raw=redis.call('GET',KEYS[1]); if not raw then return cjson.encode({status='missing'}) end
|
|
124
|
+
local f=cjson.decode(raw)
|
|
125
|
+
if tonumber(ARGV[1]) < f.dueAt then return cjson.encode({status='held',dueAt=f.dueAt}) end
|
|
126
|
+
if not f.claimed then f.claimed=true; f.status=f.count>=f.minCount and 'ready' or 'below-threshold'; redis.call('SET',KEYS[1],cjson.encode(f),'KEEPTTL') end
|
|
127
|
+
return cjson.encode({status=f.count>=f.minCount and 'ready' or 'below-threshold',finding=f,source=f.source,ruleId=f.ruleId,fingerprint=f.fingerprint})`;
|
|
128
|
+
const result = parse(await redis.eval(script, 1, key(`finding:${id}`), now()));
|
|
129
|
+
if (result.finding && !Array.isArray(result.finding.samples)) result.finding.samples = [];
|
|
130
|
+
return result;
|
|
131
|
+
},
|
|
132
|
+
async proposeBoard(ruleId, board, runId) {
|
|
133
|
+
validId(ruleId); validId(runId); validateWatchBoard(board);
|
|
134
|
+
const proposal = { id: hash(JSON.stringify([ruleId, runId, board])), runId, blocks: board.blocks, createdAt: now() };
|
|
135
|
+
const script = `local raw=redis.call('GET',KEYS[1]); local b=raw and cjson.decode(raw) or cjson.decode(ARGV[1]); b.proposals=b.proposals or {}; local p=cjson.decode(ARGV[2]); for _,old in ipairs(b.proposals) do if old.id==p.id then return cjson.encode(old) end end; table.insert(b.proposals,p); while #b.proposals>20 do table.remove(b.proposals,1) end; redis.call('SET',KEYS[1],cjson.encode(b),'PX',ARGV[3]); return cjson.encode(p)`;
|
|
136
|
+
return parse(await redis.eval(script, 1, key(`board:${ruleId}`), JSON.stringify(emptyBoard(ruleId)), JSON.stringify(proposal), TTL_MS));
|
|
137
|
+
},
|
|
138
|
+
async recordVerdict(ruleId, source, status) {
|
|
139
|
+
validId(ruleId); validId(source);
|
|
140
|
+
if (!["held", "suppressed", "capped", "unmatched"].includes(status)) throw new Error("invalid watch verdict");
|
|
141
|
+
await redis.multi().hset(key("rules"), ruleId, JSON.stringify({ id: ruleId, source })).hincrby(key(`summary:${ruleId}`), status, 1).hset(key(`summary:${ruleId}`), "lastDeliveryAt", String(now())).pexpire(key(`summary:${ruleId}`), TTL_MS).pexpire(key("rules"), TTL_MS).exec();
|
|
142
|
+
},
|
|
143
|
+
async listRules() { return Promise.all(Object.values(await redis.hgetall(key("rules"))).map(parse).map(async rule => ({ ...rule, summary: await redis.hgetall(key(`summary:${rule.id}`)) }))); },
|
|
144
|
+
async listFindings(ruleId, { limit = 1000 } = {}) {
|
|
145
|
+
validId(ruleId); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error("invalid watch findings limit");
|
|
146
|
+
const ids = await redis.zrevrange(key(`findings:${ruleId}`), 0, limit - 1);
|
|
147
|
+
return (await Promise.all(ids.map(id => this.readFinding(id)))).filter(Boolean);
|
|
148
|
+
},
|
|
149
|
+
async readFinding(id, { private: privateRead = false } = {}) { validId(id); const f = parse(await redis.get(key(`finding:${id}`))); return privateRead ? f : publicFinding(f); },
|
|
150
|
+
async readTrace(id, { private: privateRead = false } = {}) {
|
|
151
|
+
validId(id); const entries = (await redis.lrange(key(`trace:${id}`), 0, 999)).map(parse);
|
|
152
|
+
const dropped = Number(await redis.get(key(`trace-dropped:${id}`)) ?? 0);
|
|
153
|
+
if (dropped > 0) entries.unshift({ id: "trace-trimmed", kind: "rule", status: "trace-trimmed", count: dropped });
|
|
154
|
+
return privateRead ? entries : entries.map(publicWatchTrace);
|
|
155
|
+
},
|
|
156
|
+
async appendTrace(id, entry) {
|
|
157
|
+
validId(id); const text = JSON.stringify({ ...entry, at: now() });
|
|
158
|
+
if (Buffer.byteLength(text) > 32768) throw new Error("watch trace entry exceeds limit");
|
|
159
|
+
const script = `local n=redis.call('RPUSH',KEYS[1],ARGV[1]); if n>1000 then redis.call('INCRBY',KEYS[2],n-1000); redis.call('LTRIM',KEYS[1],-1000,-1); redis.call('PEXPIRE',KEYS[2],ARGV[2]) end; redis.call('PEXPIRE',KEYS[1],ARGV[2]); return n`;
|
|
160
|
+
await redis.eval(script, 2, key(`trace:${id}`), key(`trace-dropped:${id}`), text, TTL_MS);
|
|
161
|
+
},
|
|
162
|
+
async updateFinding(id, patch) {
|
|
163
|
+
validId(id); const allowed = ["status", "decision", "reason", "runId"];
|
|
164
|
+
if (Object.keys(patch).some(k => !allowed.includes(k))) throw new Error("invalid watch finding update");
|
|
165
|
+
const script = `local raw=redis.call('GET',KEYS[1]); if not raw then return 0 end; local f=cjson.decode(raw); local p=cjson.decode(ARGV[1]); for k,v in pairs(p) do f[k]=v end; redis.call('SET',KEYS[1],cjson.encode(f),'KEEPTTL'); return 1`;
|
|
166
|
+
return Boolean(await redis.eval(script, 1, key(`finding:${id}`), JSON.stringify(patch)));
|
|
167
|
+
},
|
|
168
|
+
async readBoard(ruleId) { validId(ruleId); return boardArrays(parse(await redis.get(key(`board:${ruleId}`))) ?? emptyBoard(ruleId)); },
|
|
169
|
+
async mutateBoard(ruleId, change, expectedVersion) {
|
|
170
|
+
validId(ruleId);
|
|
171
|
+
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) throw new Error("invalid board version");
|
|
172
|
+
if (!["freeze", "clear", "revert", "adopt"].includes(change.action) || (change.action === "freeze" && typeof change.frozen !== "boolean")) throw new Error("invalid board change");
|
|
173
|
+
if (change.action === "adopt") validId(change.proposalId);
|
|
174
|
+
const result = parse(await redis.eval(BOARD_SCRIPT, 1, key(`board:${ruleId}`), JSON.stringify(emptyBoard(ruleId)), expectedVersion, JSON.stringify(change), TTL_MS));
|
|
175
|
+
result.board = boardArrays(result.board); return result;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function validateWatchBoard(board) {
|
|
180
|
+
const result = checkBoard({ version: 1, ...board });
|
|
181
|
+
if (!result.ok) throw new Error(result.reason);
|
|
182
|
+
return board;
|
|
183
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const ACTIONS = ["investigate", "file_issue", "ignore"];
|
|
2
|
+
const STATUSES = ["started", "completed", "failed", "proposed", "held", "suppressed", "capped", "unmatched", "decision", "investigate", "answered", "decide-unanswered", "decide-refused", "watch-ignored", "watch-board-invalid", "watch-board-oversize", "trace-trimmed"];
|
|
3
|
+
const token = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
4
|
+
const integer = value => Number.isSafeInteger(value) && value >= 0 && value <= 1000000;
|
|
5
|
+
|
|
6
|
+
/** Public trace values have types and closed vocabularies, not merely familiar key names. */
|
|
7
|
+
export function publicWatchTrace(entry) {
|
|
8
|
+
const out = {};
|
|
9
|
+
if (token(entry.id)) out.id = entry.id;
|
|
10
|
+
if (["stream", "rule", "decide", "query", "turn", "board"].includes(entry.kind)) out.kind = entry.kind;
|
|
11
|
+
if (Number.isSafeInteger(entry.at) && entry.at >= 0) out.at = entry.at;
|
|
12
|
+
if (STATUSES.includes(entry.status)) out.status = entry.status;
|
|
13
|
+
for (const key of ["count", "rows"]) if (integer(entry[key])) out[key] = entry[key];
|
|
14
|
+
if (typeof entry.fingerprint === "string" && /^[a-f0-9]{64}$/.test(entry.fingerprint)) out.fingerprint = entry.fingerprint;
|
|
15
|
+
for (const key of ["ruleId", "source"]) if (token(entry[key])) out[key] = entry[key];
|
|
16
|
+
if (["verified", "pull"].includes(entry.signature)) out.signature = entry.signature;
|
|
17
|
+
if (ACTIONS.includes(entry.action ?? entry.option)) out.action = entry.action ?? entry.option;
|
|
18
|
+
if (entry.modelReviewed === true && typeof entry.model === "string" && /^[A-Za-z0-9_./:@~-]{1,128}$/.test(entry.model)) { out.model = entry.model; out.modelReviewed = true; }
|
|
19
|
+
if (Number.isFinite(entry.confidence) && entry.confidence >= 0 && entry.confidence <= 1) out.confidence = entry.confidence;
|
|
20
|
+
if (Number.isFinite(entry.costUsd) && entry.costUsd >= 0) out.costUsd = entry.costUsd;
|
|
21
|
+
if (entry.probabilities && !Array.isArray(entry.probabilities) && Object.keys(entry.probabilities).every(k => ACTIONS.includes(k)) && Object.values(entry.probabilities).every(p => Number.isFinite(p) && p >= 0 && p <= 1)) out.probabilities = { ...entry.probabilities };
|
|
22
|
+
if (entry.checks && Object.keys(entry.checks).every(k => ["match", "threshold", "cooldown", "cap"].includes(k)) && Object.values(entry.checks).every(v => ["passed", "refused", "active", "at-window-end"].includes(v))) out.checks = { ...entry.checks };
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function collectedWatchTrace(entry, { model, ruleId, runId }) {
|
|
27
|
+
if (!entry || !["query", "decide", "board"].includes(entry.kind) || !/^watch-\d{1,4}$/.test(entry.id) || !STATUSES.includes(entry.status)) return null;
|
|
28
|
+
const out = { id: `${runId}:${entry.id}`, kind: entry.kind, status: entry.status, ruleId };
|
|
29
|
+
if (entry.kind === "decide" && model) Object.assign(out, { model, modelReviewed: true });
|
|
30
|
+
for (const key of ["rows", "blocks"]) if (integer(entry[key])) out[key] = entry[key];
|
|
31
|
+
// Query text, agent-chosen questions and state remain in the operator projection alone.
|
|
32
|
+
for (const key of ["state", "questions", "answers", "usage", "query", "callId"]) if (entry[key] !== undefined && Buffer.byteLength(JSON.stringify(entry[key])) <= 16384) out[key] = entry[key];
|
|
33
|
+
return out;
|
|
34
|
+
}
|