scripts/verify-node-scheduler-offline.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* Offline production-process proof for the Node routine scheduler.
*
* Builds the real Node artifacts, starts scripts/start-node.mjs with an
* explicit environment file, and uses isolated SQLite plus loopback-only fake
* Slack and model services. The net guard rejects and records external fetches.
*
* Checks:
* 1. a due one-time schedule executes and delivers exactly once;
* 2. a clean restart runs a heartbeat without replaying that occurrence;
* 3. a second process cannot use the same state database;
* 4. a hard restart during a held model call reattaches the persisted
* admission/receipt and produces one final delivery.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import {
EVENTS_PATH,
NET_GUARD,
REPO_ROOT,
assertNodeVersion,
buildNodeServer,
delay,
getFreePort,
loadFake,
loadTsModule,
seedOfflineDemoChannelConfig,
seedOfflineSlackAuthority,
waitForReady,
} from './lib/offline-harness.mjs';
const WORKSPACE_ID = 'TDEMO';
const CHANNEL_ID = 'C_EXEC';
const USER_ID = 'U_ALICE';
const BOT_USER_ID = 'UBOT';
const AGENT_ID = 'agent_default';
const RESULT_MARKER = 'node-schedule-process-proof';
const TERMINAL_STATUSES = new Set(['succeeded', 'no_op', 'failed', 'skipped']);
const POLL_INTERVAL_MS = 100;
// Flue reclaims a crashed process's submission only after its 30 s lease
// expires. --expire-lease ends that lease in the database after the crash, so
// the replacement's startup reconciliation reclaims it at once. The release
// checkpoint runs without it and still proves Flue's own expiry.
const EXPIRE_LEASE = process.argv.includes('--expire-lease');
assertNodeVersion();
const directory = mkdtempSync(join(tmpdir(), 'chickpea-node-scheduler-offline-'));
const statePath = join(directory, 'state.sqlite');
const transcriptPath = join(directory, 'transcripts.sqlite');
const authPath = join(directory, 'auth.sqlite');
const keyringPath = join(directory, 'credential-keyring.json');
const netGuardLog = join(directory, 'external-fetch.log');
const environmentKeys = [
'SLACK_STATE_DB_PATH',
'TAG_DB_PATH',
'CHICKPEA_CREDENTIAL_KEYRING_PATH',
'CHICKPEA_DISABLE_TELEMETRY',
];
const priorEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
Object.assign(process.env, {
SLACK_STATE_DB_PATH: statePath,
TAG_DB_PATH: transcriptPath,
CHICKPEA_CREDENTIAL_KEYRING_PATH: keyringPath,
CHICKPEA_DISABLE_TELEMETRY: 'true',
});
let SqliteRoutineStore;
let agentRoutineDeletion;
let SqliteConfigStore;
let SqliteIdentityStore;
let backend;
let fake;
const providerCalls = [];
let holdProvider = false;
let provider;
async function handleProviderRequest(request, response) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
providerCalls.push({ path: request.url, model: body.model, held: holdProvider });
if (holdProvider) return;
const resultTool = body.tools?.find((tool) =>
tool.function?.name === 'submit_routine_result');
if (!resultTool) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: { message: 'Expected routine result tool.' } }));
return;
}
const base = {
id: 'chatcmpl-node-scheduler-offline',
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1_000),
model: body.model,
};
const args = JSON.stringify({ outcome: 'succeeded', message: RESULT_MARKER });
const events = [
{
...base,
choices: [{
index: 0,
delta: {
role: 'assistant',
tool_calls: [{
index: 0,
id: 'call_node_scheduler_offline',
type: 'function',
function: { name: 'submit_routine_result', arguments: args },
}],
},
finish_reason: null,
}],
},
{ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
{
...base,
choices: [],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
];
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.end(`${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('')}data: [DONE]\n\n`);
}
let providerUrl;
let store;
const ownedRuns = new Set();
function pass(name, detail) {
console.log(`PASS ${name} — ${detail}`);
}
function writeRuntimeEnvironment(port, suffix) {
const origin = `http://127.0.0.1:${port}`;
const file = join(directory, `runtime-${suffix}.env`);
const values = {
NODE_ENV: 'production',
HOST: '127.0.0.1',
PORT: String(port),
TAG_DB_PATH: transcriptPath,
SLACK_STATE_DB_PATH: statePath,
CHICKPEA_CREDENTIAL_KEYRING_PATH: keyringPath,
CHICKPEA_AUTH_DB_PATH: authPath,
CHICKPEA_AUTH_SECRET: '9d'.repeat(32),
CHICKPEA_GATEWAY_URL: '',
SLACK_TAG_PUBLIC_URL: origin,
SLACK_API_URL: `${fake.url}/api/`,
LOCAL_STUB_URL: providerUrl,
SLACK_TAG_MODEL: 'local-stub/parity-stub-1',
CHICKPEA_DISABLE_TELEMETRY: 'true',
DO_NOT_TRACK: '1',
};
writeFileSync(
file,
`${Object.entries(values).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join('\n')}\n`,
{ mode: 0o600 },
);
return { file, origin };
}
function scrubbedChildEnvironment() {
const env = { ...process.env };
const scrubbedPrefixes = [
'ANTHROPIC_', 'CHICKPEA_', 'CLOUDFLARE_', 'COMPOSIO_', 'GEMINI_', 'GITHUB_',
'LOCAL_STUB_', 'OPENAI_', 'OPENROUTER_', 'SLACK_',
];
for (const key of Object.keys(env)) {
if (scrubbedPrefixes.some((prefix) => key.startsWith(prefix)) ||
['HOST', 'NODE_ENV', 'PORT', 'TAG_DB_PATH', 'NET_GUARD_ALLOW', 'NODE_OPTIONS'].includes(key)) {
delete env[key];
}
}
return {
...env,
NODE_OPTIONS: `--import ${NET_GUARD}`,
NET_GUARD_LOG: netGuardLog,
};
}
function launch(environmentFile, origin) {
const child = spawn(
process.execPath,
[join(REPO_ROOT, 'scripts/start-node.mjs'), '--env-file', environmentFile],
{
cwd: REPO_ROOT,
env: scrubbedChildEnvironment(),
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
child.stdout.on('data', (chunk) => { output += chunk; });
child.stderr.on('data', (chunk) => { output += chunk; });
const run = { child, output: () => output, origin };
ownedRuns.add(run);
return run;
}
async function waitForExit(run, timeoutMs = 10_000) {
if (run.child.exitCode !== null || run.child.signalCode !== null) return;
let timeout;
try {
await Promise.race([
once(run.child, 'exit'),
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error('Owned Node process did not stop.')), timeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
}
async function stop(run, signal = 'SIGTERM') {
if (!run) return;
if (run.child.exitCode === null && run.child.signalCode === null) run.child.kill(signal);
await waitForExit(run);
ownedRuns.delete(run);
}
async function poll(label, run, check, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await check();
if (result) return result;
if (run?.child.exitCode !== null) {
throw new Error(`${label}: Node process exited early (${run.child.exitCode}).\n${run.output()}`);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(`${label}: timed out.\n${run?.output() ?? ''}`);
}
async function waitForHeartbeat(run) {
await poll('startup routine heartbeat', run, () =>
/event: 'routine\.heartbeat'/.test(run.output()));
}
async function seedSchedule(routineId) {
const now = Date.now();
const routine = await store.save({
actorId: USER_ID,
actorClass: 'member',
workspaceId: WORKSPACE_ID,
channelId: CHANNEL_ID,
draft: {
action: 'create',
routineId,
definition: {
name: 'Offline Node schedule',
description: 'Production-process scheduler verification.',
taskText: `Submit ${RESULT_MARKER} as the final routine result.`,
triggerKind: 'once',
scheduleInput: 'Now',
scheduleJson: JSON.stringify({ version: 1, kind: 'once', at: now }),
timezone: 'UTC',
outputPolicy: 'post',
authorityMode: 'live_channel_v1',
},
nextRunAt: now,
projectedDailyStarts: 0,
reservations: [{ windowStart: now, count: 1 }],
},
idempotencyKey: `offline:create:${routineId}`,
});
const identity = new SqliteIdentityStore(statePath);
const config = new SqliteConfigStore(statePath, agentRoutineDeletion);
try {
const actor = await identity.resolveSlackIdentity(WORKSPACE_ID, USER_ID);
assert(actor?.membership?.id, 'Offline Slack member was not seeded.');
await config.putAgentScheduleReference({
scheduleId: routine.id,
agentId: AGENT_ID,
workspaceId: WORKSPACE_ID,
channelId: CHANNEL_ID,
destinationKind: 'channel',
destinationBindingDigest: null,
createdByMembershipId: actor.membership.id,
runsAsMembershipId: actor.membership.id,
authorityReceiptId: `receipt_${routineId}`,
requiredConnectionAccountIds: [],
boundRoutineVersion: routine.authorityBindingVersion ?? routine.version,
state: 'active',
});
} finally {
config.close();
identity.close();
}
return routine;
}
function deliveredMarkers() {
return backend.callsOfMethod('chat.postMessage').filter((call) =>
JSON.stringify(call.body).includes(RESULT_MARKER));
}
let failure;
try {
const fakeModule = await loadFake();
({ agentRoutineDeletion, SqliteRoutineStore } = await loadTsModule('src/routines/store.ts'));
({ SqliteConfigStore } = await loadTsModule('src/config/store.ts'));
({ SqliteIdentityStore } = await loadTsModule('src/identity/store.ts'));
backend = new fakeModule.FakeSlackBackend({
slack: {
channels: [{ id: CHANNEL_ID, name: 'node-schedules', isMember: true, teamId: WORKSPACE_ID }],
channelMembers: { [CHANNEL_ID]: [USER_ID, BOT_USER_ID] },
workspaceUsers: [
{ id: USER_ID, teamId: WORKSPACE_ID },
{ id: BOT_USER_ID, teamId: WORKSPACE_ID, isBot: true, isAppUser: true },
],
},
});
provider = createServer(handleProviderRequest);
fake = await backend.listen();
await new Promise((resolve, reject) => {
provider.once('error', reject);
provider.listen(0, '127.0.0.1', resolve);
});
providerUrl = `http://127.0.0.1:${provider.address().port}/v1`;
await buildNodeServer();
await seedOfflineDemoChannelConfig(statePath);
store = new SqliteRoutineStore(statePath);
const port = await getFreePort();
const primaryEnvironment = writeRuntimeEnvironment(port, 'primary');
await seedOfflineSlackAuthority({
stateDbPath: statePath,
keyringPath,
canonicalOrigin: primaryEnvironment.origin,
teamId: WORKSPACE_ID,
slackUserId: USER_ID,
appId: 'ADEMO',
botUserId: BOT_USER_ID,
});
const config = new SqliteConfigStore(statePath, agentRoutineDeletion);
try {
await config.updateAgent(AGENT_ID, { model: 'local-stub/parity-stub-1' });
} finally {
config.close();
}
const firstRoutine = await seedSchedule('routine_node_once');
let run = launch(primaryEnvironment.file, primaryEnvironment.origin);
await waitForReady(run.child, `${run.origin}${EVENTS_PATH}`, run.output);
const firstOccurrence = await poll('first scheduled occurrence', run, async () => {
const [occurrence] = await store.listRuns({ routineId: firstRoutine.id });
return occurrence && TERMINAL_STATUSES.has(occurrence.status) ? occurrence : undefined;
});
assert.equal(firstOccurrence.status, 'succeeded', JSON.stringify(firstOccurrence));
assert.equal(deliveredMarkers().length, 1);
assert.equal((await store.getRoutine(firstRoutine.id)).state, 'completed');
pass('due schedule delivered once', `occurrence=${firstOccurrence.id}`);
const secondPort = await getFreePort();
const secondEnvironment = writeRuntimeEnvironment(secondPort, 'overlap');
const overlap = launch(secondEnvironment.file, secondEnvironment.origin);
await waitForExit(overlap);
ownedRuns.delete(overlap);
assert.equal(overlap.child.exitCode, 1);
assert.match(overlap.output(), /Another Chickpea Node process is already using/);
assert.doesNotMatch(overlap.output(), /listening/i);
pass('second process excluded', 'rejected before app startup');
await stop(run);
run = launch(primaryEnvironment.file, primaryEnvironment.origin);
await waitForReady(run.child, `${run.origin}${EVENTS_PATH}`, run.output);
await waitForHeartbeat(run);
assert.equal((await store.listRuns({ routineId: firstRoutine.id })).length, 1);
assert.equal(deliveredMarkers().length, 1);
pass('clean restart did not duplicate delivery', `occurrence=${firstOccurrence.id}`);
await stop(run);
const crashRoutine = await seedSchedule('routine_node_crash');
holdProvider = true;
run = launch(primaryEnvironment.file, primaryEnvironment.origin);
await waitForReady(run.child, `${run.origin}${EVENTS_PATH}`, run.output);
const inFlight = await poll('persisted in-flight scheduled dispatch', run, async () => {
const [occurrence] = await store.listRuns({ routineId: crashRoutine.id });
const receipt = occurrence &&
(await store.listAdmissions(occurrence.id)).at(-1)?.flueAgentReceipt;
return receipt && providerCalls.some((call) => call.held) ? occurrence : undefined;
});
const admissionsBefore = await store.listAdmissions(inFlight.id);
assert(admissionsBefore.at(-1)?.flueAgentReceipt, 'Persisted Flue receipt was not observed.');
await stop(run, 'SIGKILL');
if (EXPIRE_LEASE) {
const transcripts = new DatabaseSync(transcriptPath);
try {
const { changes } = transcripts.prepare(
"UPDATE flue_agent_submissions SET lease_expires_at = 1 WHERE status = 'running' AND lease_expires_at > 0",
).run();
assert.equal(changes, 1, 'the crashed process should leave exactly one leased submission');
} finally {
transcripts.close();
}
}
holdProvider = false;
run = launch(primaryEnvironment.file, primaryEnvironment.origin);
await waitForReady(run.child, `${run.origin}${EVENTS_PATH}`, run.output);
const recovered = await poll('recovered scheduled occurrence', run, async () => {
const occurrence = await store.getRun(inFlight.id);
return occurrence && TERMINAL_STATUSES.has(occurrence.status) ? occurrence : undefined;
}, 45_000);
const admissionsAfter = await store.listAdmissions(inFlight.id);
assert.equal(recovered.status, 'succeeded', JSON.stringify(recovered));
assert.equal((await store.listRuns({ routineId: crashRoutine.id })).length, 1);
assert.equal(admissionsAfter.length, admissionsBefore.length);
assert.deepEqual(
admissionsAfter.at(-1)?.flueAgentReceipt,
admissionsBefore.at(-1)?.flueAgentReceipt,
);
assert.equal(deliveredMarkers().length, 2);
pass('hard restart reattached persisted admission', `occurrence=${recovered.id}`);
await stop(run);
const blocked = existsSync(netGuardLog) ? readFileSync(netGuardLog, 'utf8').trim() : '';
assert.equal(blocked, '');
pass('network guard stayed empty', 'zero external fetch attempts');
} catch (error) {
failure = error;
} finally {
for (const run of ownedRuns) await stop(run, 'SIGKILL').catch(() => undefined);
store?.close();
if (provider?.listening) {
provider.closeAllConnections();
await new Promise((resolve) => provider.close(resolve));
}
await backend?.close();
for (const [key, value] of priorEnvironment) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(directory, { recursive: true, force: true });
}
if (failure) {
console.error(failure instanceof Error ? failure.stack : String(failure));
process.exitCode = 1;
}