blun-king-cli 9.1.589 → 9.1.595
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +12 -0
- package/agent-spine-plugin/CHANGELOG.md +1 -0
- package/agent-spine-plugin/docs/host-integration.md +15 -1
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install-hook.js +6 -5
- package/agent-spine-plugin/scripts/check-install-king.js +37 -0
- package/agent-spine-plugin/scripts/check-install.js +6 -1
- package/agent-spine-plugin/scripts/release-check.js +3 -2
- package/agent-spine-plugin/src/cli-agent.js +20 -1
- package/agent-spine-plugin/src/cli.js +1 -0
- package/agent-spine-plugin/src/index.js +1 -1
- package/agent-spine-plugin/src/lib/delivery-command-actions.js +16 -7
- package/agent-spine-plugin/src/lib/gateway-control.js +69 -1
- package/agent-spine-plugin/src/lib/gateway-host-fencing.js +92 -0
- package/agent-spine-plugin/src/lib/gateway-host-lifecycle.js +9 -1
- package/agent-spine-plugin/src/lib/gateway-prepared-host.js +28 -0
- package/agent-spine-plugin/src/lib/gateway-runs.js +46 -10
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1 -1
- package/agent-spine-plugin/src/lib/gateway-state.js +3 -1
- package/agent-spine-plugin/src/lib/hook-context.js +8 -3
- package/agent-spine-plugin/src/lib/hook-output.js +2 -3
- package/agent-spine-plugin/src/lib/hook-process-advisory.js +1 -2
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +17 -0
- package/agent-spine-plugin/src/lib/preflight.js +5 -19
- package/agent-spine-plugin/src/lib/source-roots.js +3 -2
- package/agent-spine-plugin/src/worker.js +30 -12
- package/bin/agentspine-king-goal-inbox.mjs +127 -0
- package/bin/agentspine-king-goal-intake.mjs +106 -0
- package/bin/agentspine-king-host-runner.mjs +109 -0
- package/bin/agentspine-king-snapshot-policy.cjs +80 -0
- package/bin/agentspine-king-status-policy.mjs +226 -0
- package/bin/agentspine-king-worker-host.mjs +160 -0
- package/bin/core-bootstrap.js +20 -3
- package/bin/launcher-mode.js +38 -1
- package/bin/launcher-restart-policy.cjs +131 -0
- package/bin/launcher-runtime.js +48 -9
- package/bin/runtime-exit-ledger.cjs +144 -0
- package/bin/runtime-exit-ledger.d.cts +23 -0
- package/bin/windows-node-crash-dump.cjs +289 -0
- package/blun.mjs +83241 -74234
- package/bundled-agent-sources.json +109 -34
- package/codebase-index/codebase_index.py +470 -0
- package/package.json +6 -1
- package/telegram-plugin/dist/bridge.mjs +390 -4
- package/worker-host.mjs +348023 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ASSIGNMENT_SCHEMA = 'blun.king-goal-assignment/v1';
|
|
6
|
+
const INSTRUCTION_RE = /(ZIEL-[A-Za-z0-9._-]+\.json)\b[^\r\n]{0,200}?\bsha256\b\s*[=:]?\s*([a-f0-9]{64})(?:\s|[),.;]|$)/iu;
|
|
7
|
+
|
|
8
|
+
function digest(value) {
|
|
9
|
+
return createHash('sha256').update(value).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseGoalInstruction(text) {
|
|
13
|
+
const match = String(text || '').match(INSTRUCTION_RE);
|
|
14
|
+
if (!match) throw new Error('GOAL_INSTRUCTION_REQUIRES_FILE_AND_SHA256');
|
|
15
|
+
return Object.freeze({ fileName: match[1], sha256: match[2].toLowerCase() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assignmentInput(value) {
|
|
19
|
+
if (!value || value.schema !== ASSIGNMENT_SCHEMA || typeof value.goalId !== 'string'
|
|
20
|
+
|| typeof value.logicalPersonaId !== 'string' || typeof value.successCriterion !== 'string'
|
|
21
|
+
|| !Array.isArray(value.steps) || value.steps.length === 0) {
|
|
22
|
+
throw new Error('GOAL_ASSIGNMENT_INVALID');
|
|
23
|
+
}
|
|
24
|
+
assertTerminalGoalGate(value.steps);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertTerminalGoalGate(steps) {
|
|
29
|
+
const ids = new Set(steps.map((step) => step?.stepId));
|
|
30
|
+
if (ids.size !== steps.length || ids.has(undefined)) throw new Error('GOAL_PLAN_STEP_IDS_INVALID');
|
|
31
|
+
const dependedOn = new Set(steps.flatMap((step) => Array.isArray(step.dependsOn) ? step.dependsOn : []));
|
|
32
|
+
const terminalSteps = steps.filter((step) => !dependedOn.has(step.stepId));
|
|
33
|
+
if (terminalSteps.length !== 1 || !terminalSteps[0].execution?.verification) {
|
|
34
|
+
throw new Error('GOAL_PLAN_TERMINAL_GATE_REQUIRED');
|
|
35
|
+
}
|
|
36
|
+
const terminalId = terminalSteps[0].stepId;
|
|
37
|
+
const byId = new Map(steps.map((step) => [step.stepId, step]));
|
|
38
|
+
const reachesGate = (stepId, seen = new Set()) => {
|
|
39
|
+
if (stepId === terminalId) return true;
|
|
40
|
+
if (seen.has(stepId)) return false;
|
|
41
|
+
seen.add(stepId);
|
|
42
|
+
return steps.some((candidate) => Array.isArray(candidate.dependsOn)
|
|
43
|
+
&& candidate.dependsOn.includes(stepId) && reachesGate(candidate.stepId, new Set(seen)));
|
|
44
|
+
};
|
|
45
|
+
if ([...byId.keys()].some((stepId) => !reachesGate(stepId))) {
|
|
46
|
+
throw new Error('GOAL_PLAN_TERMINAL_GATE_MUST_DEPEND_ON_ALL_STEPS');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function inside(directory, candidate) {
|
|
51
|
+
const relative = path.relative(directory, candidate);
|
|
52
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readVerifiedGoalFile(exchangeDirectory, instruction) {
|
|
56
|
+
const exchange = await realpath(path.resolve(exchangeDirectory));
|
|
57
|
+
const candidate = path.resolve(exchange, instruction.fileName);
|
|
58
|
+
if (!inside(exchange, candidate)) throw new Error('GOAL_FILE_OUTSIDE_EXCHANGE');
|
|
59
|
+
const metadata = await lstat(candidate);
|
|
60
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 1024 * 1024) {
|
|
61
|
+
throw new Error('GOAL_FILE_NOT_REGULAR');
|
|
62
|
+
}
|
|
63
|
+
const canonical = await realpath(candidate);
|
|
64
|
+
if (!inside(exchange, canonical)) throw new Error('GOAL_FILE_OUTSIDE_EXCHANGE');
|
|
65
|
+
const payload = await readFile(canonical);
|
|
66
|
+
if (digest(payload) !== instruction.sha256) throw new Error('GOAL_FILE_SHA256_MISMATCH');
|
|
67
|
+
let value;
|
|
68
|
+
try { value = JSON.parse(payload.toString('utf8')); }
|
|
69
|
+
catch { throw new Error('GOAL_FILE_INVALID_JSON'); }
|
|
70
|
+
return { assignment: assignmentInput(value), filePath: canonical, sha256: instruction.sha256 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ingestGoalInstruction(input) {
|
|
74
|
+
const instruction = parseGoalInstruction(input.instructionText);
|
|
75
|
+
const verified = await readVerifiedGoalFile(input.exchangeDirectory, instruction);
|
|
76
|
+
const value = verified.assignment;
|
|
77
|
+
if (value.logicalPersonaId !== input.logicalPersonaId) throw new Error('GOAL_PERSONA_MISMATCH');
|
|
78
|
+
if (value.projectId !== input.projectId) throw new Error('GOAL_PROJECT_MISMATCH');
|
|
79
|
+
if (value.groupId !== input.groupId || value.ownerSubjectId !== input.ownerSubjectId) {
|
|
80
|
+
throw new Error('GOAL_AUTHORITY_MISMATCH');
|
|
81
|
+
}
|
|
82
|
+
if (value.snapshotSha256 !== input.snapshot.snapshotSha256) throw new Error('GOAL_SNAPSHOT_MISMATCH');
|
|
83
|
+
const result = await input.assignGoal({
|
|
84
|
+
root: input.snapshot.root,
|
|
85
|
+
goalId: value.goalId,
|
|
86
|
+
agentId: input.runtimePersonaId,
|
|
87
|
+
ownerSubjectId: value.ownerSubjectId,
|
|
88
|
+
projectId: value.projectId,
|
|
89
|
+
groupId: value.groupId,
|
|
90
|
+
priority: value.priority ?? 70,
|
|
91
|
+
successCriterion: value.successCriterion,
|
|
92
|
+
nextSafeStep: value.nextSafeStep ?? null,
|
|
93
|
+
steps: value.steps,
|
|
94
|
+
deadline: value.deadline ?? null,
|
|
95
|
+
confirmation: 'local-owner-confirmed',
|
|
96
|
+
});
|
|
97
|
+
return Object.freeze({ ...verified, duplicate: result.duplicate === true, goal: result.goal });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export {
|
|
101
|
+
ASSIGNMENT_SCHEMA,
|
|
102
|
+
assertTerminalGoalGate,
|
|
103
|
+
ingestGoalInstruction,
|
|
104
|
+
parseGoalInstruction,
|
|
105
|
+
readVerifiedGoalFile,
|
|
106
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MAX_RAW_BYTES = 64 * 1024;
|
|
4
|
+
export const MAX_CHECKPOINT_BYTES = 16 * 1024;
|
|
5
|
+
export const MAX_TICK_MS = 1_800_000;
|
|
6
|
+
export const RESULT_SCHEMA = 'agentspine.blun-step-result/v1';
|
|
7
|
+
const HOST_ENVIRONMENT_KEYS = [
|
|
8
|
+
'AGENTSPINE_GATEWAY_CONTEXT', 'AGENTSPINE_ENTITY_ID', 'AGENTSPINE_PROJECT_ID',
|
|
9
|
+
'AGENTSPINE_HOST', 'AGENTSPINE_GATEWAY_QUEUE_ID', 'AGENTSPINE_GATEWAY_ATTEMPT',
|
|
10
|
+
'AGENTSPINE_GROUP_ID', 'AGENTSPINE_GOAL_ID', 'AGENTSPINE_GOAL_STEP_ID',
|
|
11
|
+
'AGENTSPINE_PLAN_DEFINITIONS_DIGEST', 'AGENTSPINE_CHANNEL_EVENT_ID',
|
|
12
|
+
'AGENTSPINE_CHANNEL_PROVIDER', 'AGENTSPINE_PORTAL_REF', 'AGENTSPINE_THREAD_REF',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function goalSessionKaos(kaos, environment) {
|
|
16
|
+
if (environment === undefined) return kaos;
|
|
17
|
+
if (!environment || typeof environment !== 'object' || Array.isArray(environment)
|
|
18
|
+
|| Object.entries(environment).some(([key, value]) => !HOST_ENVIRONMENT_KEYS.includes(key)
|
|
19
|
+
|| typeof value !== 'string' || value.includes('\0'))) throw new Error('GOAL_WORKER_ENVIRONMENT_INVALID');
|
|
20
|
+
if (typeof kaos?.withEnv !== 'function') throw new Error('GOAL_WORKER_ENVIRONMENT_REQUIRED');
|
|
21
|
+
// Replace the complete per-tick scope without changing process.env or the base Kaos.
|
|
22
|
+
return kaos.withEnv(Object.fromEntries(HOST_ENVIRONMENT_KEYS.map(key => [key, environment[key] ?? ''])));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildGoalPrompt(item) {
|
|
26
|
+
return 'Execute exactly one bounded goal-plan tick. Return exactly one visible final JSON object.\n'
|
|
27
|
+
+ JSON.stringify({ schema: 'agentspine.blun-step-request/v1', goal: item.goal, goalStep: item.goalStep,
|
|
28
|
+
selfHelpPolicy: item.selfHelpPolicy, requiredResult: { schema: RESULT_SCHEMA,
|
|
29
|
+
fields: ['text', 'checkpoint', 'completed', 'blocked', 'blocker', 'knowledgeGap', 'selfHelp', 'execution'],
|
|
30
|
+
rule: 'Every tick requires a non-null checkpoint; only report completed work backed by verification.' } });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseGoalResult(text) {
|
|
34
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RAW_BYTES) throw new Error('GOAL_STEP_RESULT_EXCEEDS_64_KIB');
|
|
35
|
+
let value;
|
|
36
|
+
try { value = JSON.parse(text.trim()); } catch { throw new Error('GOAL_STEP_RESULT_INVALID_JSON'); }
|
|
37
|
+
if (!value || value.schema !== RESULT_SCHEMA || typeof value.text !== 'string'
|
|
38
|
+
|| !value.text.trim() || value.text.length > 16000
|
|
39
|
+
|| (value.completed !== undefined && typeof value.completed !== 'boolean')
|
|
40
|
+
|| (value.blocked !== undefined && typeof value.blocked !== 'boolean')
|
|
41
|
+
|| (value.completed === true && value.blocked === true)) throw new Error('GOAL_STEP_RESULT_INVALID');
|
|
42
|
+
if (value.checkpoint === undefined || value.checkpoint === null) throw new Error('GOAL_STEP_CHECKPOINT_REQUIRED');
|
|
43
|
+
if (Buffer.byteLength(JSON.stringify(value.checkpoint), 'utf8') > MAX_CHECKPOINT_BYTES) {
|
|
44
|
+
throw new Error('GOAL_STEP_CHECKPOINT_TOO_LARGE');
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runSessionStep(session, item, timeoutMs, signal) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let ended = false;
|
|
52
|
+
let text = '';
|
|
53
|
+
let turnId;
|
|
54
|
+
let unsubscribe = () => {};
|
|
55
|
+
const finish = (error) => {
|
|
56
|
+
if (ended) return;
|
|
57
|
+
ended = true; clearTimeout(timer); unsubscribe(); signal?.removeEventListener('abort', aborted);
|
|
58
|
+
if (error) { reject(error); return; }
|
|
59
|
+
try { resolve(parseGoalResult(text)); } catch (error) { reject(error); }
|
|
60
|
+
};
|
|
61
|
+
const aborted = () => finish(new Error('GOAL_WORKER_STOP_REQUESTED'));
|
|
62
|
+
const timer = setTimeout(() => finish(new Error('GOAL_STEP_DEADLINE')), timeoutMs);
|
|
63
|
+
unsubscribe = session.onEvent((event) => {
|
|
64
|
+
if (event.sessionId !== session.id || event.agentId !== 'main') return;
|
|
65
|
+
if (event.type === 'turn.started' && turnId === undefined) { turnId = event.turnId; return; }
|
|
66
|
+
if (event.type === 'error') { finish(new Error('GOAL_STEP_SESSION_ERROR')); return; }
|
|
67
|
+
if (turnId === undefined || event.turnId !== turnId) return;
|
|
68
|
+
if (event.type === 'turn.step.started' || event.type === 'turn.step.retrying') text = '';
|
|
69
|
+
if (event.type === 'assistant.delta') {
|
|
70
|
+
text += event.delta;
|
|
71
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_RAW_BYTES) finish(new Error('GOAL_STEP_RESULT_EXCEEDS_64_KIB'));
|
|
72
|
+
}
|
|
73
|
+
if (event.type === 'turn.ended') finish(event.reason === 'completed' ? null : new Error('GOAL_STEP_TURN_NOT_COMPLETED'));
|
|
74
|
+
});
|
|
75
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
76
|
+
if (signal?.aborted) aborted();
|
|
77
|
+
if (!ended) Promise.resolve().then(() => { if (!ended) return session.prompt(buildGoalPrompt(item)); }).catch(finish);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createGoalHostFactory({ harness, workDir, model, permission, tickTimeoutMs = MAX_TICK_MS,
|
|
82
|
+
cleanupTimeoutMs = 10000, sessionOptions, onSession, signal }) {
|
|
83
|
+
if (!Number.isInteger(tickTimeoutMs) || tickTimeoutMs < 1000 || tickTimeoutMs > MAX_TICK_MS
|
|
84
|
+
|| !Number.isInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1 || cleanupTimeoutMs > 60000) {
|
|
85
|
+
throw new Error('GOAL_WORKER_TIMEOUT_OUT_OF_RANGE');
|
|
86
|
+
}
|
|
87
|
+
if (!['manual', 'auto', 'yolo'].includes(permission)) throw new Error('GOAL_WORKER_PERMISSION_REQUIRED');
|
|
88
|
+
return async (item) => {
|
|
89
|
+
if (item.projectRoot !== workDir || !item.goal) throw new Error('GOAL_WORKER_SCOPE_MISMATCH');
|
|
90
|
+
const session = await harness.createSession({ ...sessionOptions, id: 'worker-' + randomUUID(),
|
|
91
|
+
workDir, model, permission, kaos: goalSessionKaos(sessionOptions?.kaos, item.hostEnvironment),
|
|
92
|
+
metadata: { gatewayQueueId: item.queueId, gatewayAttempt: item.attempts } });
|
|
93
|
+
onSession?.(session);
|
|
94
|
+
let started = false;
|
|
95
|
+
return { sessionId: session.id,
|
|
96
|
+
async run() {
|
|
97
|
+
if (started) throw new Error('GOAL_WORKER_SESSION_ALREADY_RUN');
|
|
98
|
+
started = true;
|
|
99
|
+
return runSessionStep(session, item, tickTimeoutMs, signal);
|
|
100
|
+
},
|
|
101
|
+
async quiesce() {
|
|
102
|
+
const proof = await session.quiesce({ deadlineAt: Date.now() + cleanupTimeoutMs });
|
|
103
|
+
if (proof.status === 'quiescent' && proof.sessionId === session.id && proof.remaining.length === 0) {
|
|
104
|
+
await session.close();
|
|
105
|
+
}
|
|
106
|
+
return proof;
|
|
107
|
+
} };
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const STAND_SCHEMA = 'blun.snapshot-stand/v1';
|
|
8
|
+
const SHA256_RE = /^[a-f0-9]{64}$/iu;
|
|
9
|
+
|
|
10
|
+
function sha256(value) {
|
|
11
|
+
return createHash('sha256').update(value).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function exactSha(value, label) {
|
|
15
|
+
const digest = String(value || '').trim().toLowerCase();
|
|
16
|
+
if (!SHA256_RE.test(digest)) throw new Error(`${label} must be a SHA256 digest`);
|
|
17
|
+
return digest;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isInside(root, candidate) {
|
|
21
|
+
const relative = path.relative(root, candidate);
|
|
22
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function standMetadata(fsImpl, root, standPath) {
|
|
26
|
+
if (!isInside(root, standPath)) throw new Error('SNAPSHOT_STAND_OUTSIDE_WORKSPACE');
|
|
27
|
+
const parts = path.relative(root, standPath).split(path.sep);
|
|
28
|
+
let cursor = root;
|
|
29
|
+
let metadata;
|
|
30
|
+
for (let index = 0; index < parts.length; index++) {
|
|
31
|
+
cursor = path.join(cursor, parts[index]);
|
|
32
|
+
metadata = fsImpl.lstatSync(cursor, { bigint: true });
|
|
33
|
+
if (metadata.isSymbolicLink() || (index < parts.length - 1 ? !metadata.isDirectory() : !metadata.isFile())) {
|
|
34
|
+
throw new Error('SNAPSHOT_STAND_NOT_REGULAR');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!isInside(root, fsImpl.realpathSync(standPath))) throw new Error('SNAPSHOT_STAND_OUTSIDE_WORKSPACE');
|
|
38
|
+
return metadata;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sameFile(left, right) {
|
|
42
|
+
return left.isFile() && right.isFile() && ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs']
|
|
43
|
+
.every(key => left[key] === right[key]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function verifyWorkspaceSnapshot(input, options = {}) {
|
|
47
|
+
const fsImpl = options.fsImpl || fs;
|
|
48
|
+
const root = fsImpl.realpathSync(path.resolve(input.root));
|
|
49
|
+
const standPath = path.resolve(root, input.standFile || '.blun-snapshot-stand.json');
|
|
50
|
+
const metadata = standMetadata(fsImpl, root, standPath);
|
|
51
|
+
const descriptor = fsImpl.openSync(standPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
const before = fsImpl.fstatSync(descriptor, { bigint: true });
|
|
55
|
+
if (!sameFile(metadata, before)) throw new Error('SNAPSHOT_STAND_CHANGED');
|
|
56
|
+
payload = fsImpl.readFileSync(descriptor);
|
|
57
|
+
const after = fsImpl.fstatSync(descriptor, { bigint: true });
|
|
58
|
+
if (!sameFile(before, after) || !sameFile(after, standMetadata(fsImpl, root, standPath))) {
|
|
59
|
+
throw new Error('SNAPSHOT_STAND_CHANGED');
|
|
60
|
+
}
|
|
61
|
+
} finally { fsImpl.closeSync(descriptor); }
|
|
62
|
+
const standSha256 = sha256(payload);
|
|
63
|
+
if (standSha256 !== exactSha(input.expectedStandSha256, 'expectedStandSha256')) {
|
|
64
|
+
throw new Error('SNAPSHOT_STAND_SHA256_MISMATCH');
|
|
65
|
+
}
|
|
66
|
+
let stand;
|
|
67
|
+
try { stand = JSON.parse(payload.toString('utf8')); }
|
|
68
|
+
catch { throw new Error('SNAPSHOT_STAND_INVALID_JSON'); }
|
|
69
|
+
if (stand?.schema !== STAND_SCHEMA || typeof stand.projectId !== 'string') {
|
|
70
|
+
throw new Error('SNAPSHOT_STAND_INVALID');
|
|
71
|
+
}
|
|
72
|
+
const snapshotSha256 = exactSha(stand.snapshotSha256, 'snapshotSha256');
|
|
73
|
+
if (snapshotSha256 !== exactSha(input.expectedSnapshotSha256, 'expectedSnapshotSha256')) {
|
|
74
|
+
throw new Error('SNAPSHOT_SHA256_MISMATCH');
|
|
75
|
+
}
|
|
76
|
+
if (stand.projectId !== input.projectId) throw new Error('SNAPSHOT_PROJECT_MISMATCH');
|
|
77
|
+
return Object.freeze({ root, standPath, standSha256, snapshotSha256, projectId: stand.projectId });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { STAND_SCHEMA, verifyWorkspaceSnapshot };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SCHEMA = 'blun.king-goal-status/v2';
|
|
6
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const STATES = new Set(['pending', 'dispatching', 'uncertain', 'delivered']);
|
|
8
|
+
const RESULT_REJECTIONS = new Set(['GOAL_STEP_RESULT_EXCEEDS_64_KIB', 'GOAL_STEP_RESULT_INVALID_JSON',
|
|
9
|
+
'GOAL_STEP_RESULT_INVALID', 'GOAL_STEP_CHECKPOINT_REQUIRED', 'GOAL_STEP_CHECKPOINT_TOO_LARGE']);
|
|
10
|
+
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
11
|
+
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9:._@/-]{1,200}$/.test(value);
|
|
12
|
+
const routeKeys = ['id', 'provider', 'tenantId', 'accountId', 'chatId', 'threadId', 'agentId',
|
|
13
|
+
'projectId', 'groupId', 'createdAt'];
|
|
14
|
+
|
|
15
|
+
export function goalResultRejectionCode(error) {
|
|
16
|
+
const code = String(error?.message ?? error).replace(/^Host runtime unavailable: /, '');
|
|
17
|
+
return RESULT_REJECTIONS.has(code) ? code : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeScope(input) {
|
|
21
|
+
const keys = ['logicalPersonaId', 'runtimePersonaId', 'projectId', 'groupId', 'ownerSubjectId',
|
|
22
|
+
'chatId', 'senderId'];
|
|
23
|
+
if (!input || keys.some(key => !identifier(input[key]))
|
|
24
|
+
|| (input.threadId !== null && input.threadId !== undefined && !identifier(input.threadId))) throw new Error('GOAL_STATUS_SCOPE_INVALID');
|
|
25
|
+
return { ...Object.fromEntries(keys.map(key => [key, input[key]])), threadId: input.threadId ?? null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorCode(error) {
|
|
29
|
+
return /^(GOAL_[A-Z0-9_]{1,100})(?::|\s|$)/.exec(String(error ?? ''))?.[1] ?? 'GOAL_STATUS_OPERATION_REJECTED';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeEvent(input) {
|
|
33
|
+
if (!input || !identifier(input.eventId)) throw new Error('GOAL_STATUS_EVENT_INVALID');
|
|
34
|
+
if (input.kind === 'intake-rejected' && identifier(input.messageId)) {
|
|
35
|
+
return { kind: input.kind, eventId: input.eventId, messageId: input.messageId, error: errorCode(input.error) };
|
|
36
|
+
}
|
|
37
|
+
if (!identifier(input.goalId) || (input.goalStepId !== null && input.goalStepId !== undefined && !identifier(input.goalStepId))) {
|
|
38
|
+
throw new Error('GOAL_STATUS_EVENT_INVALID');
|
|
39
|
+
}
|
|
40
|
+
const event = { kind: input.kind, eventId: input.eventId, goalId: input.goalId, goalStepId: input.goalStepId ?? null };
|
|
41
|
+
if (input.kind === 'result-rejected' && Number.isSafeInteger(input.attempt) && input.attempt > 0) {
|
|
42
|
+
return { ...event, attempt: input.attempt, error: errorCode(input.error) };
|
|
43
|
+
}
|
|
44
|
+
if (input.kind !== 'goal-status' || !identifier(input.status)
|
|
45
|
+
|| !['offen', 'blockiert', 'bestanden'].includes(input.gate)) throw new Error('GOAL_STATUS_EVENT_INVALID');
|
|
46
|
+
return { ...event, status: input.status, gate: input.gate };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function message(scope, event) {
|
|
50
|
+
if (event.kind === 'intake-rejected') {
|
|
51
|
+
return `${scope.logicalPersonaId} | Zielannahme Nachricht ${event.messageId} | abgelehnt | ${event.error}`;
|
|
52
|
+
}
|
|
53
|
+
if (event.kind === 'result-rejected') {
|
|
54
|
+
return `${scope.logicalPersonaId} | Zielantwort ${event.goalId} / ${event.goalStepId ?? '-'} | abgelehnt ${event.attempt} | ${event.error}`;
|
|
55
|
+
}
|
|
56
|
+
return `${scope.logicalPersonaId} | Ziel ${event.goalId} | Schritt ${event.goalStepId ?? '-'} | ${event.status} | Gate ${event.gate}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matches(binding, scope) {
|
|
60
|
+
return binding?.status === 'active' && binding.provider === 'telegram'
|
|
61
|
+
&& binding.authority === 'explicit-local-channel-policy'
|
|
62
|
+
&& binding.agentId === scope.runtimePersonaId && binding.projectId === scope.projectId
|
|
63
|
+
&& binding.groupId === scope.groupId && binding.chatId === scope.chatId
|
|
64
|
+
&& (binding.threadId ?? null) === scope.threadId
|
|
65
|
+
&& binding.senderIds?.includes(scope.senderId) && binding.capabilities?.includes('reply');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function selectRoute(loaded, scope) {
|
|
69
|
+
const candidates = loaded?.policy?.bindings?.filter(binding => matches(binding, scope)) ?? [];
|
|
70
|
+
if (candidates.length !== 1) return null;
|
|
71
|
+
const binding = candidates[0];
|
|
72
|
+
if (routeKeys.some(key => key !== 'threadId' && (typeof binding[key] !== 'string' || !binding[key]))) return null;
|
|
73
|
+
return Object.fromEntries(routeKeys.map(key => [key, binding[key] ?? null]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameRoute(left, right) {
|
|
77
|
+
return left && right && routeKeys.every(key => left[key] === right[key]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function assertRegular(file) {
|
|
81
|
+
try {
|
|
82
|
+
const meta = await lstat(file);
|
|
83
|
+
if (!meta.isFile() || meta.isSymbolicLink()) throw new Error('GOAL_STATUS_NOT_REGULAR');
|
|
84
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function loadState(file) {
|
|
88
|
+
await assertRegular(file);
|
|
89
|
+
let bytes;
|
|
90
|
+
try { bytes = await readFile(file); } catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT') return { schema: SCHEMA, receipts: [] };
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
if (bytes.length > MAX_BYTES) throw new Error('oversized');
|
|
96
|
+
const state = JSON.parse(bytes);
|
|
97
|
+
if (state?.schema !== SCHEMA || !Array.isArray(state.receipts)) throw new Error('schema');
|
|
98
|
+
const keys = new Set();
|
|
99
|
+
for (const record of state.receipts) {
|
|
100
|
+
const scope = normalizeScope(record.scope);
|
|
101
|
+
const event = normalizeEvent(record.event);
|
|
102
|
+
if (JSON.stringify(scope) !== JSON.stringify(record.scope) || JSON.stringify(event) !== JSON.stringify(record.event)
|
|
103
|
+
|| record.key !== digest({ scope, event }) || keys.has(record.key) || !STATES.has(record.status)
|
|
104
|
+
|| !Number.isSafeInteger(record.nextAttemptAt) || record.nextAttemptAt < 0
|
|
105
|
+
|| !Number.isSafeInteger(record.attempts) || record.attempts < 0
|
|
106
|
+
|| (record.route !== null && (!record.route || routeKeys.some(key => !(key in record.route))))
|
|
107
|
+
|| (record.status === 'delivered' && !/^telegram-message:\d+$/.test(record.receipt))) throw new Error('receipt');
|
|
108
|
+
keys.add(record.key);
|
|
109
|
+
}
|
|
110
|
+
return state;
|
|
111
|
+
} catch { throw new Error('GOAL_STATUS_STATE_INVALID'); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function saveState(file, state, assertOwned) {
|
|
115
|
+
const bytes = JSON.stringify(state) + '\n';
|
|
116
|
+
if (Buffer.byteLength(bytes) > MAX_BYTES) throw new Error('GOAL_STATUS_CAPACITY');
|
|
117
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
118
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
119
|
+
try {
|
|
120
|
+
await handle.writeFile(bytes);
|
|
121
|
+
await handle.sync();
|
|
122
|
+
} finally { await handle.close(); }
|
|
123
|
+
try {
|
|
124
|
+
await assertOwned();
|
|
125
|
+
await assertRegular(file);
|
|
126
|
+
await rename(temporary, file);
|
|
127
|
+
} finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createGoalStatusReporter({ stateFile, scope: inputScope, withOwnedFileLock,
|
|
131
|
+
loadPolicy, adapter, now = Date.now, maxRecords = 512 }) {
|
|
132
|
+
const scope = normalizeScope(inputScope);
|
|
133
|
+
if (!path.isAbsolute(stateFile) || typeof withOwnedFileLock !== 'function'
|
|
134
|
+
|| typeof loadPolicy !== 'function' || typeof adapter?.send !== 'function'
|
|
135
|
+
|| !Number.isSafeInteger(maxRecords) || maxRecords < 1) throw new Error('GOAL_STATUS_CONFIG_INVALID');
|
|
136
|
+
const scopeKey = digest(scope);
|
|
137
|
+
const transact = async task => {
|
|
138
|
+
const directory = path.dirname(stateFile);
|
|
139
|
+
await mkdir(directory, { recursive: true });
|
|
140
|
+
if (path.relative(directory, await realpath(directory)) !== '') throw new Error('GOAL_STATUS_DIRECTORY_REDIRECTED');
|
|
141
|
+
const assertPath = async () => {
|
|
142
|
+
if (path.relative(directory, await realpath(directory)) !== '') throw new Error('GOAL_STATUS_DIRECTORY_REDIRECTED');
|
|
143
|
+
await assertRegular(`${stateFile}.lock`);
|
|
144
|
+
};
|
|
145
|
+
return withOwnedFileLock(`${stateFile}.lock`, async ({ assertOwned }) => {
|
|
146
|
+
const state = await loadState(stateFile);
|
|
147
|
+
return task(state, () => saveState(stateFile, state, assertOwned), assertOwned);
|
|
148
|
+
}, { assertPath });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const record = async input => {
|
|
152
|
+
const event = normalizeEvent(input);
|
|
153
|
+
return transact(async (state, save) => {
|
|
154
|
+
const key = digest({ scope, event });
|
|
155
|
+
const existing = state.receipts.find(row => row.key === key);
|
|
156
|
+
if (existing) return { key, status: existing.status };
|
|
157
|
+
// Retain the deduplication history and every unresolved delivery; never silently evict one.
|
|
158
|
+
if (state.receipts.length >= maxRecords) throw new Error('GOAL_STATUS_CAPACITY');
|
|
159
|
+
state.receipts.push({ key, scope, event, route: selectRoute(await loadPolicy(), scope),
|
|
160
|
+
status: 'pending', attempts: 0, nextAttemptAt: 0, receipt: null });
|
|
161
|
+
await save();
|
|
162
|
+
return { key, status: 'pending' };
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const flush = () => transact(async (state, save, assertOwned) => {
|
|
167
|
+
const results = [];
|
|
168
|
+
for (const row of state.receipts) {
|
|
169
|
+
if (digest(row.scope) !== scopeKey || row.status === 'delivered') continue;
|
|
170
|
+
const result = status => { results.push({ key: row.key, status }); };
|
|
171
|
+
// A process may have died after Telegram accepted the request but before the receipt was saved.
|
|
172
|
+
if (row.status === 'dispatching') { row.status = 'uncertain'; await save(); }
|
|
173
|
+
if (row.status === 'uncertain') { result('uncertain'); continue; }
|
|
174
|
+
const current = selectRoute(await loadPolicy(), scope);
|
|
175
|
+
if (!current) { result(row.route ? 'route-changed' : 'unrouted'); continue; }
|
|
176
|
+
if (row.route && !sameRoute(row.route, current)) { result('route-changed'); continue; }
|
|
177
|
+
if (row.nextAttemptAt > now()) { result('waiting'); continue; }
|
|
178
|
+
row.route ??= current;
|
|
179
|
+
row.status = 'dispatching'; row.attempts++;
|
|
180
|
+
await save();
|
|
181
|
+
if (!sameRoute(row.route, selectRoute(await loadPolicy(), scope))) {
|
|
182
|
+
row.status = 'pending'; await save(); result('route-changed'); continue;
|
|
183
|
+
}
|
|
184
|
+
await assertOwned();
|
|
185
|
+
let outcome;
|
|
186
|
+
try {
|
|
187
|
+
outcome = await adapter.send({ provider: row.route.provider, bindingId: row.route.id,
|
|
188
|
+
tenantId: row.route.tenantId, accountId: row.route.accountId, chatId: row.route.chatId,
|
|
189
|
+
threadId: row.route.threadId, replyTo: null,
|
|
190
|
+
idempotencyKey: row.key, text: message(scope, row.event) });
|
|
191
|
+
} catch { outcome = { ok: false, effect: 'unknown' }; }
|
|
192
|
+
if (outcome?.ok === true && /^telegram-message:\d+$/.test(outcome.receipt)) {
|
|
193
|
+
row.status = 'delivered'; row.receipt = outcome.receipt;
|
|
194
|
+
} else if (outcome?.ok === false && outcome.effect === 'none') {
|
|
195
|
+
row.status = 'pending';
|
|
196
|
+
const wait = Number.isSafeInteger(outcome.retryAfterMs) && outcome.retryAfterMs > 0 ? outcome.retryAfterMs : 15000;
|
|
197
|
+
row.nextAttemptAt = Math.min(Number.MAX_SAFE_INTEGER, now() + wait);
|
|
198
|
+
} else { row.status = 'uncertain'; }
|
|
199
|
+
await save();
|
|
200
|
+
result(row.status);
|
|
201
|
+
}
|
|
202
|
+
return results;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const reconcile = async context => {
|
|
206
|
+
for (const goal of context.goals) {
|
|
207
|
+
if (goal.agentId !== scope.runtimePersonaId || goal.projectId !== scope.projectId
|
|
208
|
+
|| goal.groupId !== scope.groupId || goal.ownerSubjectId !== scope.ownerSubjectId) continue;
|
|
209
|
+
const rows = context.queue.filter(row => row.goalId === goal.goalId && row.agentId === scope.runtimePersonaId
|
|
210
|
+
&& row.projectId === scope.projectId && row.groupId === scope.groupId && row.attempts > 0);
|
|
211
|
+
const row = rows.toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)).at(-1);
|
|
212
|
+
if (!row) continue;
|
|
213
|
+
const step = goal.plan?.steps.find(item => item.stepId === row.goalStepId);
|
|
214
|
+
if (row.goalStepId && !step) continue;
|
|
215
|
+
const identity = { eventId: `${row.queueId}:${row.attempts}`, goalId: goal.goalId, goalStepId: row.goalStepId ?? null };
|
|
216
|
+
const rejection = goalResultRejectionCode(row.lastError);
|
|
217
|
+
if (rejection) {
|
|
218
|
+
await record({ ...identity, kind: 'result-rejected', attempt: row.attempts, error: rejection });
|
|
219
|
+
}
|
|
220
|
+
const status = row.status === 'blocked' ? 'blocked' : step?.status ?? goal.status;
|
|
221
|
+
await record({ ...identity, kind: 'goal-status', status,
|
|
222
|
+
gate: goal.status === 'completed' ? 'bestanden' : status === 'blocked' ? 'blockiert' : 'offen' });
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
return Object.freeze({ record, flush, reconcile });
|
|
226
|
+
}
|