@agilno-tech/rivet 0.1.0-alpha.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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/bin/cli.js +42 -0
- package/dist/agents/backend-django-agent/SKILL.md +35 -0
- package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
- package/dist/agents/boss-agent/SKILL.md +28 -0
- package/dist/agents/bounded-worker/SKILL.md +28 -0
- package/dist/agents/data-performance-agent/SKILL.md +32 -0
- package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
- package/dist/agents/devops-agent/SKILL.md +34 -0
- package/dist/agents/engineering-manager/SKILL.md +28 -0
- package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
- package/dist/agents/frontend-web-agent/SKILL.md +31 -0
- package/dist/agents/incident-response-agent/SKILL.md +42 -0
- package/dist/agents/jest-agent/SKILL.md +77 -0
- package/dist/agents/mobile-agent/SKILL.md +32 -0
- package/dist/agents/playwright-agent/SKILL.md +54 -0
- package/dist/agents/product-design-manager/SKILL.md +27 -0
- package/dist/agents/qa-agent/SKILL.md +29 -0
- package/dist/agents/quality-manager/SKILL.md +28 -0
- package/dist/agents/vitest-agent/SKILL.md +77 -0
- package/dist/governance/pre-push-rules.md +75 -0
- package/dist/governance/prompt-hygiene.md +22 -0
- package/dist/governance/review-checklist.md +24 -0
- package/dist/governance/safety-and-data.md +27 -0
- package/dist/governance/usage-rules.md +21 -0
- package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
- package/dist/mandatory/agentic-goal/SKILL.md +42 -0
- package/dist/mandatory/agentic-status/SKILL.md +74 -0
- package/dist/mandatory/apply-design/SKILL.md +228 -0
- package/dist/mandatory/check-ac/SKILL.md +124 -0
- package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
- package/dist/mandatory/design/SKILL.md +312 -0
- package/dist/mandatory/feature-workflow/SKILL.md +87 -0
- package/dist/mandatory/hotfix/SKILL.md +195 -0
- package/dist/mandatory/pre-push/SKILL.md +272 -0
- package/dist/mandatory/project-context/SKILL.md +180 -0
- package/dist/mandatory/release-docs/SKILL.md +191 -0
- package/dist/mandatory/review-pr/SKILL.md +323 -0
- package/dist/mandatory/security-review/SKILL.md +157 -0
- package/dist/skills/api-contract/SKILL.md +77 -0
- package/dist/skills/backend-django/SKILL.md +27 -0
- package/dist/skills/backend-nestjs/SKILL.md +65 -0
- package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
- package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
- package/dist/skills/database-migration/SKILL.md +97 -0
- package/dist/skills/debugging/SKILL.md +32 -0
- package/dist/skills/devops-infra/SKILL.md +26 -0
- package/dist/skills/documentation/SKILL.md +24 -0
- package/dist/skills/frontend-nextjs/SKILL.md +27 -0
- package/dist/skills/incident-postmortem/SKILL.md +38 -0
- package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
- package/dist/skills/mobile-react-native/SKILL.md +37 -0
- package/dist/skills/postgres-analytics/SKILL.md +29 -0
- package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
- package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
- package/dist/skills/refactoring/SKILL.md +32 -0
- package/dist/skills/sprint-planning/SKILL.md +32 -0
- package/dist/skills/testing-quality/SKILL.md +62 -0
- package/dist/v2/protocols/agent-orchestration.md +49 -0
- package/dist/v2/protocols/delivery.md +45 -0
- package/dist/v2/protocols/design-authority.md +29 -0
- package/dist/v2/protocols/goal-graph.md +44 -0
- package/dist/v2/protocols/pattern-first-development.md +29 -0
- package/dist/v2/protocols/qa-evidence.md +29 -0
- package/dist/v2/protocols/security.md +29 -0
- package/dist/v2/schemas/event.schema.json +86 -0
- package/dist/v2/schemas/evidence.schema.json +94 -0
- package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
- package/dist/v2/schemas/feature-plan.schema.json +95 -0
- package/dist/v2/schemas/goal-graph.schema.json +61 -0
- package/dist/v2/schemas/integration.schema.json +119 -0
- package/dist/v2/schemas/orchestration.schema.json +69 -0
- package/dist/v2/schemas/project.schema.json +209 -0
- package/dist/v2/schemas/providers.schema.json +142 -0
- package/dist/v2/schemas/quality.schema.json +50 -0
- package/dist/v2/schemas/work-action.schema.json +44 -0
- package/dist/v2/schemas/work-request.schema.json +253 -0
- package/dist/v2/templates/evidence/qa-bundle.json +194 -0
- package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
- package/dist/v2/templates/harness/SKILL.md +90 -0
- package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
- package/dist/v2/templates/project/.rivet/project.yaml +14 -0
- package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
- package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
- package/package.json +58 -0
- package/protocols/agent-orchestration.md +49 -0
- package/protocols/delivery.md +45 -0
- package/protocols/design-authority.md +29 -0
- package/protocols/goal-graph.md +44 -0
- package/protocols/pattern-first-development.md +29 -0
- package/protocols/qa-evidence.md +29 -0
- package/protocols/security.md +29 -0
- package/schemas/event.schema.json +86 -0
- package/schemas/evidence.schema.json +94 -0
- package/schemas/feature-decomposition.schema.json +48 -0
- package/schemas/feature-plan.schema.json +95 -0
- package/schemas/goal-graph.schema.json +61 -0
- package/schemas/integration.schema.json +119 -0
- package/schemas/orchestration.schema.json +69 -0
- package/schemas/project.schema.json +209 -0
- package/schemas/providers.schema.json +142 -0
- package/schemas/quality.schema.json +50 -0
- package/schemas/work-action.schema.json +44 -0
- package/schemas/work-request.schema.json +253 -0
- package/src/adapters/confluence.js +138 -0
- package/src/adapters/contract.js +657 -0
- package/src/adapters/factory.js +122 -0
- package/src/adapters/figma.js +178 -0
- package/src/adapters/fixtures.js +183 -0
- package/src/adapters/github.js +314 -0
- package/src/adapters/http.js +704 -0
- package/src/adapters/jira.js +195 -0
- package/src/adapters/linear.js +171 -0
- package/src/adapters/node-transport.js +59 -0
- package/src/cli/integration-setup-prompt.js +32 -0
- package/src/cli/interrupt.js +25 -0
- package/src/cli/main.js +596 -0
- package/src/cli/output.js +174 -0
- package/src/cli/parse-args.js +246 -0
- package/src/cli/project-discovery.js +70 -0
- package/src/cli/task-confirmation.js +38 -0
- package/src/cli/task-presentation.js +22 -0
- package/src/clients/claude.js +214 -0
- package/src/clients/codex.js +180 -0
- package/src/clients/compatibility.js +23 -0
- package/src/clients/contract.js +224 -0
- package/src/clients/fake.js +170 -0
- package/src/clients/process-runner.js +657 -0
- package/src/clients/result-contract.js +78 -0
- package/src/commands/delivery-publish.js +80 -0
- package/src/commands/delivery-remote.js +230 -0
- package/src/commands/delivery-review-update.js +39 -0
- package/src/commands/delivery-tracker.js +94 -0
- package/src/commands/delivery-transition.js +79 -0
- package/src/commands/delivery.js +223 -0
- package/src/commands/dependency-approval.js +21 -0
- package/src/commands/doctor.js +191 -0
- package/src/commands/evidence.js +44 -0
- package/src/commands/feature.js +212 -0
- package/src/commands/goals.js +231 -0
- package/src/commands/human-run.js +208 -0
- package/src/commands/human-task.js +278 -0
- package/src/commands/init.js +848 -0
- package/src/commands/install.js +814 -0
- package/src/commands/integration-setup.js +65 -0
- package/src/commands/integrations.js +54 -0
- package/src/commands/models.js +158 -0
- package/src/commands/orchestrate.js +61 -0
- package/src/commands/preflight.js +125 -0
- package/src/commands/protocols.js +228 -0
- package/src/commands/repositories.js +77 -0
- package/src/commands/setup-remote.js +41 -0
- package/src/commands/setup.js +214 -0
- package/src/commands/status.js +224 -0
- package/src/commands/support.js +44 -0
- package/src/commands/task-completion.js +104 -0
- package/src/commands/uninstall.js +234 -0
- package/src/commands/verify.js +171 -0
- package/src/commands/work.js +220 -0
- package/src/commands/worktrees.js +52 -0
- package/src/config/command-readiness.js +218 -0
- package/src/config/commands.js +206 -0
- package/src/config/defaults.js +55 -0
- package/src/config/load.js +230 -0
- package/src/config/validate.js +560 -0
- package/src/delivery/bitbucket-review.js +123 -0
- package/src/delivery/branch-publication.js +23 -0
- package/src/delivery/contract.js +379 -0
- package/src/delivery/github-deployment.js +132 -0
- package/src/delivery/github-review.js +5 -0
- package/src/delivery/github.js +382 -0
- package/src/delivery/gitlab-review.js +5 -0
- package/src/delivery/gitlab.js +395 -0
- package/src/delivery/prepare.js +90 -0
- package/src/delivery/publication-process.js +33 -0
- package/src/delivery/publication-transport.js +112 -0
- package/src/delivery/review-request.js +184 -0
- package/src/delivery/review-update.js +74 -0
- package/src/delivery/service.js +400 -0
- package/src/delivery/store.js +76 -0
- package/src/delivery/tracker-target.js +51 -0
- package/src/delivery/tracker-transition.js +158 -0
- package/src/delivery/tracker.js +164 -0
- package/src/discovery/git.js +85 -0
- package/src/discovery/portable.js +108 -0
- package/src/discovery/project.js +345 -0
- package/src/discovery/tools.js +145 -0
- package/src/evaluations/approval.js +20 -0
- package/src/evaluations/cost-policy.js +20 -0
- package/src/evaluations/harness-attempt.js +113 -0
- package/src/evaluations/live-runner.js +55 -0
- package/src/evaluations/profile.js +28 -0
- package/src/evaluations/report.js +11 -0
- package/src/evaluations/text-attempt.js +18 -0
- package/src/evidence/checksum.js +87 -0
- package/src/evidence/collect.js +727 -0
- package/src/evidence/validate.js +354 -0
- package/src/feature/accepted-integration.js +46 -0
- package/src/feature/actions.js +37 -0
- package/src/feature/client-profile.js +56 -0
- package/src/feature/decomposition-contract.js +58 -0
- package/src/feature/host-execution.js +633 -0
- package/src/feature/host-lock-recovery.js +96 -0
- package/src/feature/host-run-lock.js +32 -0
- package/src/feature/local-approval.js +183 -0
- package/src/feature/plan-contract.js +215 -0
- package/src/feature/planner.js +253 -0
- package/src/feature/run-store.js +241 -0
- package/src/feature/runtime-bridge.js +840 -0
- package/src/feature/verification-report.js +168 -0
- package/src/feature/workflow.js +375 -0
- package/src/git/client.js +509 -0
- package/src/git/integration-worktree.js +153 -0
- package/src/git/reconcile.js +222 -0
- package/src/git/reservations.js +450 -0
- package/src/git/worktrees.js +478 -0
- package/src/graph/completion.js +189 -0
- package/src/graph/fixtures.js +116 -0
- package/src/graph/reducer.js +124 -0
- package/src/graph/scheduler.js +252 -0
- package/src/graph/validate.js +106 -0
- package/src/install/managed.js +579 -0
- package/src/install/project-reference.js +33 -0
- package/src/install/project-runtime.js +142 -0
- package/src/install/runtime-integrity.cjs +141 -0
- package/src/integrations/capabilities.js +69 -0
- package/src/integrations/host-observation.js +26 -0
- package/src/integrations/registry.js +56 -0
- package/src/models/delegate.js +102 -0
- package/src/models/profiles.js +53 -0
- package/src/models/protocols.js +208 -0
- package/src/models/registry.js +83 -0
- package/src/models/transport.js +59 -0
- package/src/policy/approvals.js +251 -0
- package/src/policy/authority.js +282 -0
- package/src/policy/budget.js +149 -0
- package/src/policy/command-bootstrap.js +219 -0
- package/src/policy/commands.js +803 -0
- package/src/prompts/launch-contract.js +43 -0
- package/src/prompts/planning-contract.js +92 -0
- package/src/protocols/presentation.js +53 -0
- package/src/protocols/project.js +289 -0
- package/src/quality/runner.js +497 -0
- package/src/quality/traceability.js +215 -0
- package/src/repositories/identity.js +59 -0
- package/src/repositories/index.js +2 -0
- package/src/repositories/provider.js +210 -0
- package/src/runtime/application.js +353 -0
- package/src/runtime/dependency-directory.js +33 -0
- package/src/runtime/harness-discovery.js +121 -0
- package/src/runtime/heartbeat.js +47 -0
- package/src/runtime/instance-store.js +112 -0
- package/src/runtime/orchestrator.js +1133 -0
- package/src/runtime/portable-dependencies.js +94 -0
- package/src/runtime/recovery.js +190 -0
- package/src/runtime/retry.js +54 -0
- package/src/runtime/supervisor.js +166 -0
- package/src/runtime/worktree-bootstrap.js +169 -0
- package/src/state/event-store.js +333 -0
- package/src/state/lock.js +266 -0
- package/src/state/paths.js +274 -0
- package/src/state/redact.js +106 -0
- package/src/state/snapshot-store.js +310 -0
- package/src/status/public/app.js +96 -0
- package/src/status/public/index.html +37 -0
- package/src/status/public/styles.css +50 -0
- package/src/status/server.js +323 -0
- package/src/status/view-model.js +356 -0
- package/src/support/bundle.js +195 -0
- package/src/support/failure-report.js +96 -0
- package/src/work-request/contract.js +250 -0
- package/src/work-request/host.js +78 -0
- package/src/work-request/local.js +163 -0
- package/src/work-request/tracker.js +105 -0
- package/templates/evidence/qa-bundle.json +194 -0
- package/templates/github-actions/rivet-deploy.yml +98 -0
- package/templates/harness/SKILL.md +90 -0
- package/templates/project/.rivet/orchestration.yaml +42 -0
- package/templates/project/.rivet/project.yaml +14 -0
- package/templates/project/.rivet/providers.yaml +8 -0
- package/templates/project/.rivet/quality.yaml +16 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { buildStatusViewModel } from './view-model.js';
|
|
6
|
+
|
|
7
|
+
const objectFreeze = Object.freeze;
|
|
8
|
+
const getDescriptor = Object.getOwnPropertyDescriptor;
|
|
9
|
+
const getPrototypeOf = Object.getPrototypeOf;
|
|
10
|
+
const promiseThen = Promise.prototype.then;
|
|
11
|
+
const reflectApply = Reflect.apply;
|
|
12
|
+
const HOST = '127.0.0.1';
|
|
13
|
+
const MAX_JSON_BYTES = 512 * 1024;
|
|
14
|
+
const MAX_SSE_FRAME_BYTES = 32 * 1024;
|
|
15
|
+
const MAX_SSE_BATCH_BYTES = 256 * 1024;
|
|
16
|
+
const ASSETS = new Map([
|
|
17
|
+
['/', { type: 'text/html; charset=utf-8', body: readFileSync(fileURLToPath(new URL('./public/index.html', import.meta.url))) }],
|
|
18
|
+
['/app.js', { type: 'text/javascript; charset=utf-8', body: readFileSync(fileURLToPath(new URL('./public/app.js', import.meta.url))) }],
|
|
19
|
+
['/styles.css', { type: 'text/css; charset=utf-8', body: readFileSync(fileURLToPath(new URL('./public/styles.css', import.meta.url))) }],
|
|
20
|
+
]);
|
|
21
|
+
const ROUTES = new Set(['/api/state', '/api/events', '/events']);
|
|
22
|
+
const SECURITY_HEADERS = Object.freeze({
|
|
23
|
+
'content-security-policy': "default-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
24
|
+
'x-content-type-options': 'nosniff',
|
|
25
|
+
'x-frame-options': 'DENY',
|
|
26
|
+
'referrer-policy': 'no-referrer',
|
|
27
|
+
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
28
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
29
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
class StatusServerError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('Status unavailable');
|
|
35
|
+
this.name = 'StatusServerError';
|
|
36
|
+
this.code = 'ERR_STATUS_UNAVAILABLE';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function integer(value, minimum, maximum, fallback) {
|
|
41
|
+
return Number.isSafeInteger(value) && value >= minimum && value <= maximum ? value : fallback;
|
|
42
|
+
}
|
|
43
|
+
function send(response, status, body, headers = {}) {
|
|
44
|
+
if (response.destroyed || response.writableEnded) return;
|
|
45
|
+
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
46
|
+
response.writeHead(status, { ...SECURITY_HEADERS, 'content-length': bytes.length, ...headers });
|
|
47
|
+
response.end(bytes);
|
|
48
|
+
}
|
|
49
|
+
function sendUnavailable(response) {
|
|
50
|
+
send(response, 503, 'Status unavailable', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
51
|
+
}
|
|
52
|
+
function sendJson(response, value) {
|
|
53
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
54
|
+
if (body.length > MAX_JSON_BYTES) { sendUnavailable(response); return; }
|
|
55
|
+
send(response, 200, body, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
56
|
+
}
|
|
57
|
+
function requestPath(request) {
|
|
58
|
+
const value = request.url;
|
|
59
|
+
if (typeof value !== 'string' || value.length > 64 || value.includes('%') || value.includes('?') || value.includes('#')) return null;
|
|
60
|
+
return ROUTES.has(value) || ASSETS.has(value) ? value : null;
|
|
61
|
+
}
|
|
62
|
+
function rawHeaderValues(request, target) {
|
|
63
|
+
const result = [];
|
|
64
|
+
const values = request.rawHeaders;
|
|
65
|
+
if (!Array.isArray(values) || values.length % 2 !== 0) return result;
|
|
66
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
67
|
+
if (typeof values[index] === 'string' && values[index].toLowerCase() === target) result.push(values[index + 1]);
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
function reconnectCursor(request) {
|
|
72
|
+
const values = rawHeaderValues(request, 'last-event-id');
|
|
73
|
+
if (values.length === 0) return 0;
|
|
74
|
+
if (values.length !== 1 || typeof values[0] !== 'string' || !/^(?:0|[1-9][0-9]{0,9})$/.test(values[0])) return null;
|
|
75
|
+
return Number(values[0]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createStatusServer(options = {}) {
|
|
79
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || typeof options.readState !== 'function') {
|
|
80
|
+
throw new TypeError('Status server requires a state reader.');
|
|
81
|
+
}
|
|
82
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
83
|
+
const pollIntervalMs = integer(options.pollIntervalMs, 25, 60_000, 1_000);
|
|
84
|
+
const readTimeoutMs = integer(options.readTimeoutMs, 10, 30_000, 2_000);
|
|
85
|
+
const maxSseClients = integer(options.maxSseClients, 1, 128, 16);
|
|
86
|
+
const clients = new Set();
|
|
87
|
+
const sockets = new Set();
|
|
88
|
+
let actualPort = null;
|
|
89
|
+
let started = false;
|
|
90
|
+
let closing = null;
|
|
91
|
+
|
|
92
|
+
function boundedStateRead(externalSignal) {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
return new Promise((resolvePromise, reject) => {
|
|
95
|
+
let settled = false;
|
|
96
|
+
const finish = (callback, value) => {
|
|
97
|
+
if (settled) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
externalSignal?.removeEventListener?.('abort', onAbort);
|
|
101
|
+
callback(value);
|
|
102
|
+
};
|
|
103
|
+
const fail = () => {
|
|
104
|
+
controller.abort();
|
|
105
|
+
finish(reject, new StatusServerError());
|
|
106
|
+
};
|
|
107
|
+
const onAbort = () => fail();
|
|
108
|
+
const timer = setTimeout(fail, readTimeoutMs);
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
if (externalSignal?.aborted) { fail(); return; }
|
|
111
|
+
externalSignal?.addEventListener?.('abort', onAbort, { once: true });
|
|
112
|
+
let source;
|
|
113
|
+
try { source = options.readState({ signal: controller.signal }); }
|
|
114
|
+
catch { finish(reject, new StatusServerError()); return; }
|
|
115
|
+
const onFulfilled = value => finish(resolvePromise, value);
|
|
116
|
+
const onRejected = () => finish(reject, new StatusServerError());
|
|
117
|
+
try {
|
|
118
|
+
reflectApply(promiseThen, source, [onFulfilled, onRejected]);
|
|
119
|
+
} catch {
|
|
120
|
+
try {
|
|
121
|
+
const prototype = source !== null && typeof source === 'object' ? getPrototypeOf(source) : null;
|
|
122
|
+
const thenDescriptor = source !== null && (typeof source === 'object' || typeof source === 'function')
|
|
123
|
+
? getDescriptor(source, 'then')
|
|
124
|
+
: undefined;
|
|
125
|
+
if ((prototype === Object.prototype || prototype === null) && !thenDescriptor) onFulfilled(source);
|
|
126
|
+
else onRejected();
|
|
127
|
+
} catch { onRejected(); }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readModel(signal) {
|
|
133
|
+
const state = await boundedStateRead(signal);
|
|
134
|
+
return buildStatusViewModel(state, { nowMs: now(), limits: options.limits });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeSse(client, payload) {
|
|
138
|
+
if (client.closed || Buffer.byteLength(payload) > MAX_SSE_FRAME_BYTES) return Promise.resolve(false);
|
|
139
|
+
if (client.response.write(payload)) return Promise.resolve(true);
|
|
140
|
+
return new Promise(resolvePromise => {
|
|
141
|
+
let settled = false;
|
|
142
|
+
const finish = value => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
client.response.off('drain', onDrain);
|
|
147
|
+
client.response.off('close', onClose);
|
|
148
|
+
resolvePromise(value);
|
|
149
|
+
};
|
|
150
|
+
const onDrain = () => finish(true);
|
|
151
|
+
const onClose = () => finish(false);
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
client.abort.abort();
|
|
154
|
+
client.response.destroy();
|
|
155
|
+
finish(false);
|
|
156
|
+
}, 1_000);
|
|
157
|
+
timer.unref?.();
|
|
158
|
+
client.response.once('drain', onDrain);
|
|
159
|
+
client.response.once('close', onClose);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function sendSseEvents(client) {
|
|
164
|
+
if (client.closed || client.busy) return;
|
|
165
|
+
client.busy = true;
|
|
166
|
+
try {
|
|
167
|
+
const model = await readModel(client.abort.signal);
|
|
168
|
+
if (client.closed) return;
|
|
169
|
+
const firstAvailableSequence = model.events[0]?.sequence ?? 0;
|
|
170
|
+
const latestSequence = model.events.at(-1)?.sequence ?? 0;
|
|
171
|
+
let batchBytes = 0;
|
|
172
|
+
if (!client.resetChecked && client.cursor > 0
|
|
173
|
+
&& (firstAvailableSequence === 0 || client.cursor < firstAvailableSequence - 1 || client.cursor > latestSequence)) {
|
|
174
|
+
const reset = `id:\nevent: reset\ndata: ${JSON.stringify({ firstAvailableSequence, latestSequence })}\n\n`;
|
|
175
|
+
if (!await writeSse(client, reset)) return;
|
|
176
|
+
batchBytes += Buffer.byteLength(reset);
|
|
177
|
+
client.cursor = Math.max(0, firstAvailableSequence - 1);
|
|
178
|
+
}
|
|
179
|
+
client.resetChecked = true;
|
|
180
|
+
let wrote = false;
|
|
181
|
+
for (const event of model.events) {
|
|
182
|
+
if (event.sequence <= client.cursor) continue;
|
|
183
|
+
const frame = `id: ${event.sequence}\nevent: status\ndata: ${JSON.stringify(event)}\n\n`;
|
|
184
|
+
const frameBytes = Buffer.byteLength(frame);
|
|
185
|
+
if (batchBytes + frameBytes > MAX_SSE_BATCH_BYTES || !await writeSse(client, frame)) break;
|
|
186
|
+
batchBytes += frameBytes;
|
|
187
|
+
client.cursor = event.sequence;
|
|
188
|
+
wrote = true;
|
|
189
|
+
}
|
|
190
|
+
if (!wrote && batchBytes === 0) await writeSse(client, ': heartbeat\n\n');
|
|
191
|
+
} catch {
|
|
192
|
+
if (!client.closed) await writeSse(client, 'event: unavailable\ndata: {}\n\n');
|
|
193
|
+
} finally { client.busy = false; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const server = createServer(async (request, response) => {
|
|
197
|
+
for (const [key, value] of Object.entries(SECURITY_HEADERS)) response.setHeader(key, value);
|
|
198
|
+
const host = rawHeaderValues(request, 'host');
|
|
199
|
+
if (actualPort === null || host.length !== 1 || host[0] !== `${HOST}:${actualPort}`) {
|
|
200
|
+
send(response, 421, 'Misdirected Request', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const origins = rawHeaderValues(request, 'origin');
|
|
204
|
+
if (origins.length > 1 || (origins.length === 1 && origins[0] !== `http://${HOST}:${actualPort}`)) {
|
|
205
|
+
send(response, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (request.method !== 'GET') {
|
|
209
|
+
send(response, 405, 'Method Not Allowed', { allow: 'GET', 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const path = requestPath(request);
|
|
213
|
+
if (!path) {
|
|
214
|
+
send(response, 404, 'Not Found', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
if (path === '/api/state' || path === '/api/events') {
|
|
219
|
+
const abort = new AbortController();
|
|
220
|
+
const disconnect = () => abort.abort();
|
|
221
|
+
request.once('aborted', disconnect);
|
|
222
|
+
response.once('close', disconnect);
|
|
223
|
+
try {
|
|
224
|
+
const model = await readModel(abort.signal);
|
|
225
|
+
if (path === '/api/state') sendJson(response, model);
|
|
226
|
+
else sendJson(response, { events: model.events, truncated: model.truncated.events });
|
|
227
|
+
} finally {
|
|
228
|
+
request.off('aborted', disconnect);
|
|
229
|
+
response.off('close', disconnect);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (path === '/events') {
|
|
234
|
+
const cursor = reconnectCursor(request);
|
|
235
|
+
if (cursor === null) {
|
|
236
|
+
send(response, 400, 'Invalid Last-Event-ID', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (clients.size >= maxSseClients) {
|
|
240
|
+
send(response, 503, 'Status stream capacity reached', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'retry-after': '2' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
response.writeHead(200, { ...SECURITY_HEADERS, 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
244
|
+
response.write('retry: 2000\n\n');
|
|
245
|
+
const client = { response, cursor, resetChecked: false, busy: false, closed: false, timer: null, abort: new AbortController() };
|
|
246
|
+
clients.add(client);
|
|
247
|
+
const closeClient = () => {
|
|
248
|
+
if (client.closed) return;
|
|
249
|
+
client.closed = true;
|
|
250
|
+
client.abort.abort();
|
|
251
|
+
clearInterval(client.timer);
|
|
252
|
+
clients.delete(client);
|
|
253
|
+
};
|
|
254
|
+
request.once('aborted', closeClient);
|
|
255
|
+
request.once('close', closeClient);
|
|
256
|
+
response.once('close', closeClient);
|
|
257
|
+
await sendSseEvents(client);
|
|
258
|
+
if (!client.closed) {
|
|
259
|
+
client.timer = setInterval(() => { void sendSseEvents(client); }, pollIntervalMs);
|
|
260
|
+
client.timer.unref?.();
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const asset = ASSETS.get(path);
|
|
265
|
+
send(response, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
|
|
266
|
+
} catch {
|
|
267
|
+
if (!response.headersSent) sendUnavailable(response);
|
|
268
|
+
else if (!response.destroyed) response.destroy();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
server.requestTimeout = 10_000;
|
|
272
|
+
server.headersTimeout = 5_000;
|
|
273
|
+
server.keepAliveTimeout = 2_000;
|
|
274
|
+
server.on('connection', socket => {
|
|
275
|
+
sockets.add(socket);
|
|
276
|
+
socket.once('close', () => sockets.delete(socket));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
async start(startOptions = {}) {
|
|
281
|
+
if (started || closing) throw new Error('Status server is already started.');
|
|
282
|
+
if (!startOptions || typeof startOptions !== 'object' || Array.isArray(startOptions)
|
|
283
|
+
|| Object.keys(startOptions).some(key => key !== 'port')) throw new TypeError('Status server start options are invalid.');
|
|
284
|
+
const port = integer(startOptions.port ?? 0, 0, 65_535, null);
|
|
285
|
+
if (port === null) throw new TypeError('Status server port is invalid.');
|
|
286
|
+
await new Promise((resolvePromise, reject) => {
|
|
287
|
+
const onError = () => { server.off('listening', onListening); reject(new StatusServerError()); };
|
|
288
|
+
const onListening = () => { server.off('error', onError); resolvePromise(); };
|
|
289
|
+
server.once('error', onError);
|
|
290
|
+
server.once('listening', onListening);
|
|
291
|
+
server.listen(port, HOST);
|
|
292
|
+
});
|
|
293
|
+
const address = server.address();
|
|
294
|
+
if (!address || typeof address === 'string' || address.address !== HOST || !Number.isSafeInteger(address.port) || address.port < 1) {
|
|
295
|
+
await new Promise(resolvePromise => server.close(resolvePromise));
|
|
296
|
+
throw new StatusServerError();
|
|
297
|
+
}
|
|
298
|
+
actualPort = address.port;
|
|
299
|
+
started = true;
|
|
300
|
+
return objectFreeze({ host: HOST, port: actualPort, url: `http://${HOST}:${actualPort}` });
|
|
301
|
+
},
|
|
302
|
+
async close() {
|
|
303
|
+
if (closing) return closing;
|
|
304
|
+
if (!started) return;
|
|
305
|
+
closing = (async () => {
|
|
306
|
+
for (const client of clients) {
|
|
307
|
+
client.closed = true;
|
|
308
|
+
client.abort.abort();
|
|
309
|
+
clearInterval(client.timer);
|
|
310
|
+
client.response.destroy();
|
|
311
|
+
}
|
|
312
|
+
clients.clear();
|
|
313
|
+
const stopped = new Promise((resolvePromise, reject) => server.close(error => error ? reject(new StatusServerError()) : resolvePromise()));
|
|
314
|
+
server.closeAllConnections?.();
|
|
315
|
+
for (const socket of sockets) socket.destroy();
|
|
316
|
+
await stopped;
|
|
317
|
+
started = false;
|
|
318
|
+
actualPort = null;
|
|
319
|
+
})();
|
|
320
|
+
try { await closing; } finally { closing = null; }
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LIMITS = Object.freeze({ nodes: 256, agents: 256, events: 200, evidence: 512, stringLength: 256 });
|
|
4
|
+
const INPUT_LIMITS = Object.freeze({ array: 4_096, events: 10_000, record: 4_096, ids: 512 });
|
|
5
|
+
const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const STATUS = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const ROLE = new Set(['boss', 'manager', 'worker', 'human', 'system']);
|
|
8
|
+
const EVENT_TYPES = new Set([
|
|
9
|
+
'graph-created', 'state-transition', 'retry', 'heartbeat', 'evidence-recorded',
|
|
10
|
+
'approval-recorded', 'authority-adjusted', 'budget-adjusted', 'cancelled',
|
|
11
|
+
]);
|
|
12
|
+
const SECRET_TEXT = /(?:authorization\s*:|(?:bearer|basic)\s+[a-z0-9._~+/=-]{3,}|gh[pousr]_[a-z0-9_]{8,}|github_pat_[a-z0-9_]{8,}|glpat-[a-z0-9_-]{8,}|(?:npm|pypi|hf)_[a-z0-9_-]{8,}|akia[0-9a-z]{16}|aiza[0-9a-z_-]{16,}|(?:sk|xox[a-z]?)-[a-z0-9_-]{8,}|(?:password|passwd|secret|credential|token|api[-_ ]?key|private[-_ ]?key)\s*[:=]\s*\S+)/i;
|
|
13
|
+
const PRIVATE_PATH = /(?:^|[\s"'(])(?:\/(?:Users|home|private|tmp|var|etc|opt|srv|Volumes)(?:\/|$)|[a-z]:[\\/]|\\\\[^\\/\s]+[\\/][^\\/\s]+|\/\/[^/\s]+\/[^/\s]+)/i;
|
|
14
|
+
const ABSOLUTE_TEXT_PATH = /(?:^|[\s"'(])\/(?!\/)[^/\s]+\/[^\s]+/;
|
|
15
|
+
const URL_TEXT = /\b[a-z][a-z0-9+.-]*:\/\//i;
|
|
16
|
+
const CREDENTIAL_WORD = /\b(?:authorization|bearer|cookie|credential|password|passwd|private[-_ ]?key|secret|session(?:id)?|token)\b/i;
|
|
17
|
+
const ENV_CREDENTIAL = /\b(?:aws_(?:secret_access_key|access_key_id|session_token)|[a-z][a-z0-9_]*(?:api_key|access_key|private_key|credential(?:s)?|cookie|password|passwd|secret|session|session_?id|token|url))\b\s*[:=]/i;
|
|
18
|
+
const PUBLIC_REASON = /^[a-z0-9][a-z0-9 .,'()_:+-]*$/i;
|
|
19
|
+
const SAFE_PATH_SEGMENT = /^[a-z0-9][a-z0-9._~-]{0,95}$/i;
|
|
20
|
+
const CREDENTIAL_PATH_SEGMENT = /(?:^|[._~-])(?:access[-_]?key|api[-_]?key|auth(?:orization)?|bearer|cookie|credential|key|password|passwd|private[-_]?key|secret|session(?:id)?|token)(?:$|[._~-])/i;
|
|
21
|
+
|
|
22
|
+
const arrayIsArray = Array.isArray;
|
|
23
|
+
const getDescriptor = Object.getOwnPropertyDescriptor;
|
|
24
|
+
const getPrototypeOf = Object.getPrototypeOf;
|
|
25
|
+
const objectFreeze = Object.freeze;
|
|
26
|
+
const ownKeys = Reflect.ownKeys;
|
|
27
|
+
const stringNormalize = String.prototype.normalize;
|
|
28
|
+
|
|
29
|
+
export class StatusViewError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super('Status state is invalid.');
|
|
32
|
+
this.name = 'StatusViewError';
|
|
33
|
+
this.code = 'ERR_STATUS_VIEW_INVALID';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function invalid() { throw new StatusViewError(); }
|
|
38
|
+
|
|
39
|
+
function snapshotContext() {
|
|
40
|
+
const records = new WeakMap();
|
|
41
|
+
const arrays = new WeakMap();
|
|
42
|
+
function record(value, { optional = false } = {}) {
|
|
43
|
+
if (value === undefined || value === null) {
|
|
44
|
+
if (optional) return objectFreeze(Object.create(null));
|
|
45
|
+
invalid();
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== 'object' || arrayIsArray(value)) invalid();
|
|
48
|
+
if (records.has(value)) return records.get(value);
|
|
49
|
+
const prototype = getPrototypeOf(value);
|
|
50
|
+
if (prototype !== Object.prototype && prototype !== null) invalid();
|
|
51
|
+
const keys = ownKeys(value);
|
|
52
|
+
if (keys.length > INPUT_LIMITS.record) invalid();
|
|
53
|
+
const copy = Object.create(null);
|
|
54
|
+
records.set(value, copy);
|
|
55
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
56
|
+
const key = keys[index];
|
|
57
|
+
if (typeof key !== 'string') invalid();
|
|
58
|
+
const descriptor = getDescriptor(value, key);
|
|
59
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid();
|
|
60
|
+
copy[key] = descriptor.value;
|
|
61
|
+
}
|
|
62
|
+
return objectFreeze(copy);
|
|
63
|
+
}
|
|
64
|
+
function array(value, { optional = false, maximum = INPUT_LIMITS.array } = {}) {
|
|
65
|
+
if (value === undefined || value === null) {
|
|
66
|
+
if (optional) return objectFreeze([]);
|
|
67
|
+
invalid();
|
|
68
|
+
}
|
|
69
|
+
if (!arrayIsArray(value)) invalid();
|
|
70
|
+
if (arrays.has(value)) {
|
|
71
|
+
const cached = arrays.get(value);
|
|
72
|
+
if (cached.length > maximum) invalid();
|
|
73
|
+
return cached.value;
|
|
74
|
+
}
|
|
75
|
+
const lengthDescriptor = getDescriptor(value, 'length');
|
|
76
|
+
if (!lengthDescriptor || !Number.isSafeInteger(lengthDescriptor.value)
|
|
77
|
+
|| lengthDescriptor.value < 0 || lengthDescriptor.value > maximum) invalid();
|
|
78
|
+
const length = lengthDescriptor.value;
|
|
79
|
+
const keys = ownKeys(value);
|
|
80
|
+
if (keys.length !== length + 1) invalid();
|
|
81
|
+
const copy = new Array(length);
|
|
82
|
+
arrays.set(value, { value: copy, length });
|
|
83
|
+
for (let index = 0; index < length; index += 1) {
|
|
84
|
+
const key = String(index);
|
|
85
|
+
const descriptor = getDescriptor(value, key);
|
|
86
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid();
|
|
87
|
+
copy[index] = descriptor.value;
|
|
88
|
+
}
|
|
89
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
90
|
+
const key = keys[index];
|
|
91
|
+
if (key !== 'length' && (typeof key !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(key)
|
|
92
|
+
|| Number(key) >= length || String(Number(key)) !== key)) invalid();
|
|
93
|
+
}
|
|
94
|
+
return objectFreeze(copy);
|
|
95
|
+
}
|
|
96
|
+
return objectFreeze({ record, array });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function safeId(value) { return typeof value === 'string' && value.length <= 96 && ID.test(value) ? value : null; }
|
|
100
|
+
function safeStatus(value, fallback = 'unknown') { return typeof value === 'string' && value.length <= 48 && STATUS.test(value) ? value : fallback; }
|
|
101
|
+
function safeRole(value) { return ROLE.has(value) ? value : 'system'; }
|
|
102
|
+
function integer(value, fallback = 0) { return Number.isSafeInteger(value) && value >= 0 ? value : fallback; }
|
|
103
|
+
function decimal(value) {
|
|
104
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value;
|
|
105
|
+
return typeof value === 'string' && /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(value) && value.length <= 32 ? value : '0';
|
|
106
|
+
}
|
|
107
|
+
function timestamp(value) {
|
|
108
|
+
if (typeof value !== 'string' || value.length > 40) return null;
|
|
109
|
+
const parsed = Date.parse(value);
|
|
110
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value ? value : null;
|
|
111
|
+
}
|
|
112
|
+
function isoFromMs(value) { return Number.isSafeInteger(value) && value >= 0 ? new Date(value).toISOString() : null; }
|
|
113
|
+
function publicText(value, maximum) {
|
|
114
|
+
if (typeof value !== 'string') return null;
|
|
115
|
+
const normalized = stringNormalize.call(value, 'NFKC').replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
|
116
|
+
if (!normalized || SECRET_TEXT.test(normalized) || CREDENTIAL_WORD.test(normalized) || ENV_CREDENTIAL.test(normalized)
|
|
117
|
+
|| PRIVATE_PATH.test(normalized) || ABSOLUTE_TEXT_PATH.test(normalized)
|
|
118
|
+
|| URL_TEXT.test(normalized) || !PUBLIC_REASON.test(normalized)) return null;
|
|
119
|
+
return normalized.slice(0, maximum);
|
|
120
|
+
}
|
|
121
|
+
function ids(context, value, maximum = 64) {
|
|
122
|
+
const values = context.array(value, { optional: true, maximum: INPUT_LIMITS.ids });
|
|
123
|
+
const result = [];
|
|
124
|
+
for (let index = 0; index < Math.min(values.length, maximum); index += 1) {
|
|
125
|
+
const id = safeId(values[index]);
|
|
126
|
+
if (id) result.push(id);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
function limits(context, input) {
|
|
131
|
+
const candidate = context.record(input, { optional: true });
|
|
132
|
+
const result = {};
|
|
133
|
+
for (const key of ['nodes', 'agents', 'events', 'evidence', 'stringLength']) {
|
|
134
|
+
const value = candidate[key];
|
|
135
|
+
const fallback = DEFAULT_LIMITS[key];
|
|
136
|
+
result[key] = Number.isSafeInteger(value) && value > 0 && value <= fallback ? value : fallback;
|
|
137
|
+
}
|
|
138
|
+
return objectFreeze(result);
|
|
139
|
+
}
|
|
140
|
+
function deepFreeze(value) {
|
|
141
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
142
|
+
const values = Object.values(value);
|
|
143
|
+
for (let index = 0; index < values.length; index += 1) deepFreeze(values[index]);
|
|
144
|
+
return objectFreeze(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function projectEvents(context, source, { afterSequence, maxEvents, stringLength }) {
|
|
148
|
+
const events = context.array(source, { optional: true, maximum: INPUT_LIMITS.events });
|
|
149
|
+
const output = [];
|
|
150
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
151
|
+
const input = context.record(events[index]);
|
|
152
|
+
const eventId = safeId(input.eventId);
|
|
153
|
+
const sequence = input.sequence;
|
|
154
|
+
const type = input.type;
|
|
155
|
+
const eventTimestamp = timestamp(input.timestamp);
|
|
156
|
+
if (!eventId || !Number.isSafeInteger(sequence) || sequence < 1 || !EVENT_TYPES.has(type) || !eventTimestamp) continue;
|
|
157
|
+
const actorInput = context.record(input.actor, { optional: true });
|
|
158
|
+
const actorId = safeId(actorInput.id);
|
|
159
|
+
const event = {
|
|
160
|
+
id: eventId, sequence, timestamp: eventTimestamp, type,
|
|
161
|
+
...(safeId(input.nodeId) ? { nodeId: safeId(input.nodeId) } : {}),
|
|
162
|
+
actor: actorId ? { role: safeRole(actorInput.role), id: actorId } : { role: 'system', id: 'runtime' },
|
|
163
|
+
};
|
|
164
|
+
if (type === 'state-transition') {
|
|
165
|
+
event.priorState = safeStatus(input.priorState);
|
|
166
|
+
event.newState = safeStatus(input.newState);
|
|
167
|
+
} else if (type === 'retry') {
|
|
168
|
+
const reason = publicText(input.retryReason, stringLength);
|
|
169
|
+
if (reason) event.retryReason = reason;
|
|
170
|
+
} else if (type === 'heartbeat') {
|
|
171
|
+
event.heartbeatSequence = integer(input.heartbeatSequence);
|
|
172
|
+
event.heartbeatIntervalMs = integer(input.heartbeatIntervalMs);
|
|
173
|
+
} else if (type === 'evidence-recorded' || type === 'approval-recorded') {
|
|
174
|
+
event.evidenceRefs = ids(context, input.evidenceRefs);
|
|
175
|
+
} else if (type === 'cancelled') {
|
|
176
|
+
const reason = publicText(input.cancellationReason, stringLength);
|
|
177
|
+
if (reason) event.reason = reason;
|
|
178
|
+
}
|
|
179
|
+
if (sequence > afterSequence) output.push(event);
|
|
180
|
+
}
|
|
181
|
+
output.sort((left, right) => left.sequence - right.sequence);
|
|
182
|
+
return output.slice(-maxEvents);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function projectStatusEvents(events, options = {}) {
|
|
186
|
+
try {
|
|
187
|
+
const context = snapshotContext();
|
|
188
|
+
const safeOptions = context.record(options, { optional: true });
|
|
189
|
+
const afterSequence = Number.isSafeInteger(safeOptions.afterSequence) && safeOptions.afterSequence >= 0 ? safeOptions.afterSequence : 0;
|
|
190
|
+
const maxEvents = Number.isSafeInteger(safeOptions.maxEvents) && safeOptions.maxEvents > 0 ? Math.min(safeOptions.maxEvents, DEFAULT_LIMITS.events) : DEFAULT_LIMITS.events;
|
|
191
|
+
const stringLength = Number.isSafeInteger(safeOptions.stringLength) && safeOptions.stringLength > 0 ? Math.min(safeOptions.stringLength, DEFAULT_LIMITS.stringLength) : DEFAULT_LIMITS.stringLength;
|
|
192
|
+
return deepFreeze(projectEvents(context, events, { afterSequence, maxEvents, stringLength }));
|
|
193
|
+
} catch { invalid(); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function graphRows(context, allNodes, maximum) {
|
|
197
|
+
const rows = [];
|
|
198
|
+
const sources = new Map();
|
|
199
|
+
const agentIds = new Set();
|
|
200
|
+
for (let index = 0; index < allNodes.length; index += 1) {
|
|
201
|
+
const input = context.record(allNodes[index]);
|
|
202
|
+
const owner = context.record(input.owner, { optional: true });
|
|
203
|
+
const id = safeId(input.id) ?? 'unknown-node';
|
|
204
|
+
const ownerId = safeId(owner.id) ?? 'runtime';
|
|
205
|
+
if (safeId(owner.id)) agentIds.add(ownerId);
|
|
206
|
+
const row = { id, parentId: safeId(input.parentId), role: safeRole(owner.role), ownerId, status: safeStatus(input.status), dependencies: ids(context, input.dependencies) };
|
|
207
|
+
sources.set(id, input);
|
|
208
|
+
if (rows.length < maximum) rows.push(row);
|
|
209
|
+
}
|
|
210
|
+
return { rows, sources, agentIds };
|
|
211
|
+
}
|
|
212
|
+
function agentRows(context, nodes, state, nowMs, maximum) {
|
|
213
|
+
const intents = context.record(state.launchIntents, { optional: true });
|
|
214
|
+
const heartbeats = context.record(state.heartbeats, { optional: true });
|
|
215
|
+
const priority = new Map([['corrective', 0], ['running', 1], ['reserved', 2], ['ready', 3], ['blocked', 4], ['failed', 5], ['completed', 6]]);
|
|
216
|
+
const byAgent = new Map();
|
|
217
|
+
for (const node of nodes) {
|
|
218
|
+
const existing = byAgent.get(node.ownerId);
|
|
219
|
+
if (!existing || (priority.get(node.status) ?? 99) < (priority.get(existing.status) ?? 99)) byAgent.set(node.ownerId, node);
|
|
220
|
+
}
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const node of byAgent.values()) {
|
|
223
|
+
if (result.length >= maximum) break;
|
|
224
|
+
const heartbeat = context.record(heartbeats[node.id], { optional: true });
|
|
225
|
+
const heartbeatMs = heartbeat.timestampMs;
|
|
226
|
+
const latestHeartbeatAt = isoFromMs(heartbeatMs);
|
|
227
|
+
const intent = context.record(intents[node.id], { optional: true });
|
|
228
|
+
const startedAtMs = intent.startedAtMs;
|
|
229
|
+
result.push({
|
|
230
|
+
id: node.ownerId, role: node.role, status: node.status, currentNodeId: node.id,
|
|
231
|
+
attempt: Math.max(1, integer(intent.attempt, 1)),
|
|
232
|
+
elapsedMs: Number.isSafeInteger(startedAtMs) && startedAtMs >= 0 && startedAtMs <= nowMs ? nowMs - startedAtMs : 0,
|
|
233
|
+
latestHeartbeatAt, heartbeatAgeMs: latestHeartbeatAt && heartbeatMs <= nowMs ? nowMs - heartbeatMs : null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
function worktreeRows(context, nodes, state) {
|
|
239
|
+
const byNode = new Map();
|
|
240
|
+
for (const node of nodes) byNode.set(node.id, node);
|
|
241
|
+
const intents = context.record(state.launchIntents, { optional: true });
|
|
242
|
+
const keys = Object.keys(intents).sort();
|
|
243
|
+
const rows = [];
|
|
244
|
+
for (const nodeId of keys) {
|
|
245
|
+
const node = byNode.get(nodeId);
|
|
246
|
+
const intent = context.record(intents[nodeId], { optional: true });
|
|
247
|
+
if (!node || intent.worktree === undefined || intent.worktree === null) continue;
|
|
248
|
+
context.record(intent.worktree);
|
|
249
|
+
rows.push({ nodeId, ownerId: node.ownerId, status: safeStatus(intent.status), displayPath: `.worktrees/${node.id}` });
|
|
250
|
+
}
|
|
251
|
+
return rows;
|
|
252
|
+
}
|
|
253
|
+
function publicHostname(hostname) {
|
|
254
|
+
const lower = hostname.toLowerCase();
|
|
255
|
+
if (!lower || lower === 'localhost' || lower.endsWith('.localhost') || lower.endsWith('.local') || lower.endsWith('.internal')) return false;
|
|
256
|
+
if (isIP(lower) !== 0) return false;
|
|
257
|
+
return lower.includes('.') && !lower.startsWith('.') && !lower.endsWith('.');
|
|
258
|
+
}
|
|
259
|
+
function safeEvidenceUrl(value) {
|
|
260
|
+
if (typeof value !== 'string' || value.length > 2_048) return null;
|
|
261
|
+
try {
|
|
262
|
+
const url = new URL(value);
|
|
263
|
+
if (url.protocol !== 'https:' || url.username || url.password || !publicHostname(url.hostname)) return null;
|
|
264
|
+
if (url.pathname.length > 1_024 || url.pathname.includes('%')) return null;
|
|
265
|
+
const segments = url.pathname === '/' ? [] : url.pathname.slice(1).split('/');
|
|
266
|
+
if (segments.some(segment => !SAFE_PATH_SEGMENT.test(segment)
|
|
267
|
+
|| CREDENTIAL_PATH_SEGMENT.test(segment) || SECRET_TEXT.test(segment))) return null;
|
|
268
|
+
return `${url.origin}${url.pathname}`;
|
|
269
|
+
} catch { return null; }
|
|
270
|
+
}
|
|
271
|
+
function evidenceRows(context, state, maximum) {
|
|
272
|
+
const values = context.array(state.evidence, { optional: true });
|
|
273
|
+
const result = [];
|
|
274
|
+
for (let index = 0; index < Math.min(values.length, maximum); index += 1) {
|
|
275
|
+
const input = context.record(values[index]);
|
|
276
|
+
const row = { id: safeId(input.id) ?? 'unknown-evidence', nodeId: safeId(input.nodeId), type: safeStatus(input.type), approvalState: safeStatus(input.approvalState, 'pending') };
|
|
277
|
+
const url = safeEvidenceUrl(input.url);
|
|
278
|
+
if (url) row.url = url;
|
|
279
|
+
result.push(row);
|
|
280
|
+
}
|
|
281
|
+
return { rows: result, total: values.length };
|
|
282
|
+
}
|
|
283
|
+
function usageRecord(context, value) {
|
|
284
|
+
const input = context.record(value, { optional: true });
|
|
285
|
+
return { tokens: integer(input.tokens), costUsd: decimal(input.costUsd), retries: integer(input.retries), timeMinutes: integer(input.timeMinutes), taskLimit: integer(input.taskLimit) };
|
|
286
|
+
}
|
|
287
|
+
function earliestEventMs(events) {
|
|
288
|
+
for (const event of events) {
|
|
289
|
+
const value = Date.parse(event.timestamp);
|
|
290
|
+
if (Number.isFinite(value)) return value;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
function currentStage(nodes) {
|
|
295
|
+
for (const status of ['corrective', 'running', 'reserved', 'ready']) for (const node of nodes) if (node.status === status) return node;
|
|
296
|
+
return nodes.length ? nodes[nodes.length - 1] : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function build(stateValue, optionsValue) {
|
|
300
|
+
const context = snapshotContext();
|
|
301
|
+
const state = context.record(stateValue);
|
|
302
|
+
const options = context.record(optionsValue, { optional: true });
|
|
303
|
+
const nowMs = Number.isSafeInteger(options.nowMs) && options.nowMs >= 0 ? options.nowMs : Date.now();
|
|
304
|
+
const configuredLimits = limits(context, options.limits);
|
|
305
|
+
const graph = context.record(state.graph);
|
|
306
|
+
const allNodes = context.array(graph.nodes, { optional: true });
|
|
307
|
+
const projectedGraph = graphRows(context, allNodes, configuredLimits.nodes);
|
|
308
|
+
const nodes = projectedGraph.rows;
|
|
309
|
+
const allEvents = context.array(state.events, { optional: true, maximum: INPUT_LIMITS.events });
|
|
310
|
+
const events = projectEvents(context, allEvents, { afterSequence: 0, maxEvents: configuredLimits.events, stringLength: configuredLimits.stringLength });
|
|
311
|
+
const agents = agentRows(context, nodes, state, nowMs, configuredLimits.agents);
|
|
312
|
+
const evidenceResult = evidenceRows(context, state, configuredLimits.evidence);
|
|
313
|
+
const evidence = evidenceResult.rows;
|
|
314
|
+
const gates = [];
|
|
315
|
+
for (const node of nodes) {
|
|
316
|
+
const source = projectedGraph.sources.get(node.id);
|
|
317
|
+
const gateId = safeId(source?.approvalGate);
|
|
318
|
+
if (gateId) gates.push({ id: gateId, nodeId: node.id, status: node.status, requiredEvidence: ids(context, source.requiredEvidenceTypes) });
|
|
319
|
+
}
|
|
320
|
+
const gateByNode = new Map();
|
|
321
|
+
for (const gate of gates) gateByNode.set(gate.nodeId, gate.id);
|
|
322
|
+
const humanDecisions = [];
|
|
323
|
+
for (const event of events) if (event.type === 'approval-recorded' && event.actor.role === 'human') humanDecisions.push({
|
|
324
|
+
id: event.id, gateId: gateByNode.get(event.nodeId) ?? 'activation', nodeId: event.nodeId ?? null,
|
|
325
|
+
decision: 'approved', decidedAt: event.timestamp, actorId: event.actor.id, evidenceRefs: event.evidenceRefs ?? [],
|
|
326
|
+
});
|
|
327
|
+
const attempts = context.record(state.attempts, { optional: true });
|
|
328
|
+
const correctiveWork = [];
|
|
329
|
+
for (const node of nodes) {
|
|
330
|
+
if (node.status !== 'corrective') continue;
|
|
331
|
+
let retry = null;
|
|
332
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
333
|
+
if (events[index].type === 'retry' && events[index].nodeId === node.id) { retry = events[index]; break; }
|
|
334
|
+
}
|
|
335
|
+
correctiveWork.push({ nodeId: node.id, ownerId: node.ownerId, status: node.status, attempt: integer(attempts[node.id], 1), ...(retry?.retryReason ? { reason: retry.retryReason } : {}) });
|
|
336
|
+
}
|
|
337
|
+
const heartbeatTimes = [];
|
|
338
|
+
for (const agent of agents) if (agent.latestHeartbeatAt) heartbeatTimes.push(agent.latestHeartbeatAt);
|
|
339
|
+
heartbeatTimes.sort();
|
|
340
|
+
const startedAtMs = Number.isSafeInteger(state.startedAtMs) && state.startedAtMs >= 0 ? state.startedAtMs : earliestEventMs(events);
|
|
341
|
+
const stage = currentStage(nodes);
|
|
342
|
+
return deepFreeze({
|
|
343
|
+
schemaVersion: 1, generatedAt: new Date(nowMs).toISOString(),
|
|
344
|
+
goal: { id: safeId(graph.id) ?? 'unknown-goal', status: safeStatus(graph.status), version: integer(state.version), activated: state.activated === true, terminal: state.terminal === null ? null : safeStatus(state.terminal) },
|
|
345
|
+
currentStage: stage ? { nodeId: stage.id, status: stage.status } : null,
|
|
346
|
+
graph: { nodes }, agents, worktrees: worktreeRows(context, nodes, state), gates, correctiveWork, evidence, humanDecisions,
|
|
347
|
+
budget: { elapsedMs: startedAtMs !== null && startedAtMs <= nowMs ? nowMs - startedAtMs : 0, usage: usageRecord(context, state.usage), limits: usageRecord(context, state.limits) },
|
|
348
|
+
latestHeartbeatAt: heartbeatTimes.length ? heartbeatTimes[heartbeatTimes.length - 1] : null,
|
|
349
|
+
events,
|
|
350
|
+
truncated: { nodes: Math.max(0, allNodes.length - nodes.length), agents: Math.max(0, projectedGraph.agentIds.size - agents.length), events: Math.max(0, allEvents.length - events.length), evidence: Math.max(0, evidenceResult.total - evidence.length) },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function buildStatusViewModel(state, options = {}) {
|
|
355
|
+
try { return build(state, options); } catch { invalid(); }
|
|
356
|
+
}
|