Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/desktop/src/main/__tests__/automation-canfire.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
/**
* evaluateAutomationCanFire — the kind-aware fire gate.
*
* Regression coverage for the P1 durability bug: a cron must keep firing even
* after the conversation that created it is archived, deleted, or gone after a
* restart (cron spawns a FRESH session, so its creator session is irrelevant).
* Heartbeats stay gated on their own session; incognito blocks everything.
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { evaluateAutomationCanFire } from '../automation-wiring.js';

const IDLE = new Set(['active', 'done', 'waiting_for_user']);
const cron = { kind: 'cron' as const, sessionId: 'creator' };
const beat = { kind: 'heartbeat' as const, sessionId: 'own' };

function deps(over: Partial<Parameters<typeof evaluateAutomationCanFire>[1]> = {}) {
return {
isIncognitoActive: async () => false,
readSessionHeader: async () => ({ status: 'active' as string, archivedAt: null as number | null }),
idleStatuses: IDLE,
...over,
};
}

describe('evaluateAutomationCanFire — kind-aware fire gate', () => {
it('cron fires regardless of its creator session (archived)', async () => {
const d = deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: 123 }) });
assert.equal(await evaluateAutomationCanFire(cron, d), true);
});

it('cron fires even when its creator session was DELETED (readHeader throws)', async () => {
const d = deps({ readSessionHeader: async () => { throw new Error('ENOENT'); } });
assert.equal(await evaluateAutomationCanFire(cron, d), true);
});

it('cron never reads the session header at all', async () => {
let read = false;
const d = deps({ readSessionHeader: async () => { read = true; return { status: 'active', archivedAt: null }; } });
await evaluateAutomationCanFire(cron, d);
assert.equal(read, false);
});

it('incognito blocks cron', async () => {
assert.equal(await evaluateAutomationCanFire(cron, deps({ isIncognitoActive: async () => true })), false);
});

it('incognito blocks heartbeat', async () => {
assert.equal(await evaluateAutomationCanFire(beat, deps({ isIncognitoActive: async () => true })), false);
});

it('heartbeat fires into an idle (active/done/waiting_for_user) session', async () => {
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: null }) })), true);
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'done', archivedAt: null }) })), true);
// #639 decision: waiting_for_user is the wakeup's HOME scenario — the
// heartbeat starts a turn in place of the user.
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'waiting_for_user', archivedAt: null }) })), true);
});

it('heartbeat does NOT fire into a busy/blocked session', async () => {
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'running', archivedAt: null }) })), false);
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'blocked', archivedAt: null }) })), false);
});

it('heartbeat does NOT fire into an archived or missing session', async () => {
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => ({ status: 'active', archivedAt: 1 }) })), false);
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => null })), false);
});

it('heartbeat does NOT fire when its session was deleted (readHeader throws)', async () => {
assert.equal(await evaluateAutomationCanFire(beat, deps({ readSessionHeader: async () => { throw new Error('ENOENT'); } })), false);
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
/**
* End-to-end (no Electron): a durable cron keeps firing through the REAL manager
* + scheduler + the REAL kind-aware canFire gate, even after its creator session
* is archived/deleted — while a heartbeat in the same archived session does not.
*
* This ties the P1 fix together: evaluateAutomationCanFire (cron ignores its
* creator session) → AutomationScheduler actually dispatches the cron.
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { AutomationManager, AutomationScheduler, type AutomationDefinition } from '@maka/runtime';
import { evaluateAutomationCanFire } from '../automation-wiring.js';

const IDLE = new Set(['active', 'done', 'waiting_for_user']);

function harness(opts: { sessionArchived: boolean; incognito?: boolean }) {
let time = 1_700_000_000_000;
let idc = 0;
const timers: Array<{ fn: () => void; id: number }> = [];
let timerId = 0;
const freshRuns: string[] = [];
const injected: string[] = [];

const manager = new AutomationManager({ generateId: () => `a-${++idc}`, now: () => time, random: () => 0 });

const scheduler = new AutomationScheduler({
automationManager: manager,
// The REAL kind-aware gate. The creator session is "archived" (or gone).
canFire: (automation: AutomationDefinition) => evaluateAutomationCanFire(automation, {
isIncognitoActive: async () => opts.incognito === true,
readSessionHeader: async () =>
opts.sessionArchived ? { status: 'active', archivedAt: time } : { status: 'active', archivedAt: null },
idleStatuses: IDLE,
}),
injectTurn: async (_s, _p, id) => { injected.push(id); return { runId: `h-${id}`, ok: true }; },
createFreshRun: async (_p, id) => { freshRuns.push(id); return { runId: `c-${id}`, ok: true }; },
setTimeout: (fn) => { const id = ++timerId; timers.push({ fn, id }); return id; },
clearTimeout: (t) => { const i = timers.findIndex(x => x.id === t); if (i >= 0) timers.splice(i, 1); },
now: () => time,
});

return {
manager, scheduler, freshRuns, injected,
advance: (ms: number) => { time += ms; },
async tick() { const t = timers.shift(); if (t) t.fn(); for (let i = 0; i < 8; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); },
};
}

describe('E2E: durable cron fires after its creator session is archived', () => {
it('cron fires even though the creating conversation is archived', async () => {
const h = harness({ sessionArchived: true });
const cron = h.manager.create({
kind: 'cron', name: 'nightly', prompt: 'run it',
sessionId: 'archived-conversation', schedule: { type: 'interval', seconds: 30 },
});
assert.ok(!('error' in cron));

h.advance(31_000);
h.scheduler.start();
await h.tick();

assert.equal(h.freshRuns.length, 1, 'cron should fire despite the archived creator session');
h.scheduler.dispose();
});

it('a heartbeat in the same archived session does NOT fire', async () => {
const h = harness({ sessionArchived: true });
const beat = h.manager.create({
kind: 'heartbeat', name: 'poll', prompt: 'check',
sessionId: 'archived-conversation', schedule: { type: 'interval', seconds: 30 },
});
assert.ok(!('error' in beat));

h.advance(31_000);
h.scheduler.start();
// canFire=false → defers, never injects. Tick a few times to be sure.
await h.tick(); await h.tick(); await h.tick();

assert.equal(h.injected.length, 0, 'heartbeat must not fire into an archived session');
h.scheduler.dispose();
});

it('incognito blocks the cron too', async () => {
const h = harness({ sessionArchived: false, incognito: true });
const cron = h.manager.create({
kind: 'cron', name: 'nightly', prompt: 'run it',
sessionId: 's', schedule: { type: 'interval', seconds: 30 },
});
assert.ok(!('error' in cron));

h.advance(31_000);
h.scheduler.start();
await h.tick();

assert.equal(h.freshRuns.length, 0, 'cron must not fire while incognito is active');
h.scheduler.dispose();
});
});
217 changes: 217 additions & 0 deletions apps/desktop/src/main/__tests__/automation-persistence-e2e.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
/**
* End-to-end: durable cron persistence + cross-session query/management.
*
* Exercises the REAL host wiring (createMainAutomationWiring) against a REAL
* FileAutomationStore on a real temp workspace, simulating an app restart:
*
* session A creates a durable cron ──sync──► <workspace>/automations.json
* │
* (restart: a fresh wiring loads it) ◄──loadAll────────┘
* │
* session B (never saw it) lists / pauses / resumes / deletes it
*
* This is the query-and-persistence loop the reviewer asked for: a persisted
* cron is not just fireable after restart, it stays visible and manageable
* from a brand-new session. Nothing here is mocked except the fire executors
* (we assert on persisted state, not on runs).
*/

import { strict as assert } from 'node:assert';
import { describe, it, before, after } from 'node:test';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { MakaToolContext, MakaTool } from '@maka/runtime';
import { createMainAutomationWiring } from '../automation-wiring.js';

function ctx(sessionId: string): MakaToolContext {
return {
sessionId,
turnId: 'turn-1',
cwd: '/tmp',
toolCallId: 'tc-1',
abortSignal: new AbortController().signal,
emitOutput: () => {},
};
}

function makeWiring(workspaceRoot: string) {
return createMainAutomationWiring({
workspaceRoot,
canFire: async () => true,
injectTurn: async () => ({ runId: 'run', ok: true }),
// Presence of createFreshRun is what advertises the cron kind to the tool.
createFreshRun: async () => ({ runId: 'run', ok: true }),
});
}

/** A cron-DISABLED host (heartbeat-only), like the `maka` CLI — no createFreshRun. */
function makeCronDisabledWiring(workspaceRoot: string) {
return createMainAutomationWiring({
workspaceRoot,
canFire: async () => true,
injectTurn: async () => ({ runId: 'run', ok: true }),
// createFreshRun omitted → cron disabled → must not persist/adopt durable state.
});
}

function automationTool(wiring: ReturnType<typeof makeWiring>): MakaTool {
return wiring.tools[0];
}

async function readStore(workspaceRoot: string): Promise<Array<{ id: string; name: string }>> {
try {
const raw = await readFile(join(workspaceRoot, 'automations.json'), 'utf8');
return (JSON.parse(raw) as { automations: Array<{ id: string; name: string }> }).automations;
} catch {
return [];
}
}

/** The store sync is fire-and-forget; poll the file until it settles. */
async function waitForStore(
workspaceRoot: string,
predicate: (rows: Array<{ id: string; name: string }>) => boolean,
timeoutMs = 2000,
): Promise<Array<{ id: string; name: string }>> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const rows = await readStore(workspaceRoot);
if (predicate(rows)) return rows;
if (Date.now() >= deadline) return rows;
await new Promise((r) => setTimeout(r, 25));
}
}

describe('E2E: durable cron persistence + cross-session query/management', () => {
let workspaceRoot: string;

before(async () => {
workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-'));
});
after(async () => {
await rm(workspaceRoot, { recursive: true, force: true });
});

it('a durable cron created in one session is queryable and manageable from a fresh session after restart', async () => {
const SESSION_A = 'session-A-original';
const SESSION_B = 'session-B-after-restart';

// ── session A: create a durable cron via the real Automation tool ──────
const wiring1 = makeWiring(workspaceRoot);
const created = await automationTool(wiring1).impl({
mode: 'create',
kind: 'cron',
name: 'nightly backup',
prompt: 'run the nightly backup',
schedule: { type: 'cron', expression: '0 3 * * *' },
}, ctx(SESSION_A)) as string;
assert.ok(created.includes('Automation created'), created);
// cron defaults to durable, so it must be advertised as such.
assert.ok(created.includes('durable'), created);

// ── it reaches disk (persistence) ─────────────────────────────────────
const persisted = await waitForStore(workspaceRoot, (rows) => rows.some((r) => r.name === 'nightly backup'));
assert.equal(persisted.length, 1);
assert.equal(persisted[0].name, 'nightly backup');
const cronId = persisted[0].id;

// ── restart: a fresh wiring loads the persisted cron from disk ─────────
const wiring2 = makeWiring(workspaceRoot);
await wiring2.loadDurableAutomations();

// ── session B (never saw the cron): query it ──────────────────────────
const listed = await automationTool(wiring2).impl({ mode: 'list' }, ctx(SESSION_B)) as string;
assert.ok(listed.includes('nightly backup'), `session B should see the persisted cron:\n${listed}`);
assert.ok(listed.includes(cronId), listed);

// ── session B: manage it (pause → resume → delete) ────────────────────
const paused = await automationTool(wiring2).impl({ mode: 'pause', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(paused.includes('paused'), paused);
assert.equal(wiring2.manager.get(cronId)?.status, 'paused');

const resumed = await automationTool(wiring2).impl({ mode: 'resume', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(resumed.includes('resumed'), resumed);
assert.equal(wiring2.manager.get(cronId)?.status, 'active');

const deleted = await automationTool(wiring2).impl({ mode: 'delete', id: cronId }, ctx(SESSION_B)) as string;
assert.ok(deleted.toLowerCase().includes('delet'), deleted);
assert.equal(wiring2.manager.get(cronId), undefined);

// ── the deletion is durable too: disk no longer holds it ──────────────
const afterDelete = await waitForStore(workspaceRoot, (rows) => rows.every((r) => r.id !== cronId));
assert.ok(afterDelete.every((r) => r.id !== cronId), 'deleted cron must be gone from disk');

wiring1.scheduler.dispose();
wiring2.scheduler.dispose();
});

it('a non-durable heartbeat does NOT leak into another session and is not persisted', async () => {
const ws = await mkdtemp(join(tmpdir(), 'maka-automation-e2e-hb-'));
try {
const wiring = makeWiring(ws);
const created = await automationTool(wiring).impl({
mode: 'create',
kind: 'heartbeat',
name: 'poll status',
prompt: 'check status',
schedule: { type: 'interval', seconds: 60 },
}, ctx('owner-session')) as string;
assert.ok(created.includes('Automation created'), created);

// A different session cannot see or manage the session-private heartbeat.
const listedElsewhere = await automationTool(wiring).impl({ mode: 'list' }, ctx('stranger-session')) as string;
assert.ok(listedElsewhere.includes('No automations'), listedElsewhere);

// And it never hits disk (non-durable).
const rows = await waitForStore(ws, () => false, 300); // give sync a chance, expect empty
assert.equal(rows.length, 0, 'a non-durable heartbeat must not be persisted');

wiring.scheduler.dispose();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});

describe('E2E: a cron-disabled host (CLI) sharing the workspace never clobbers durable crons', () => {
it('a heartbeat-only wiring neither loads nor overwrites the owner\'s automations.json', async () => {
const ws = await mkdtemp(join(tmpdir(), 'maka-automation-clobber-'));
try {
// ── owner (cron-enabled, desktop) creates a durable cron ──────────────
const owner = makeWiring(ws);
await automationTool(owner).impl({
mode: 'create', kind: 'cron', name: 'daily backup', prompt: 'back up',
schedule: { type: 'cron', expression: '0 3 * * *' },
}, ctx('desktop-session')) as string;
const persisted = await waitForStore(ws, (rows) => rows.some(r => r.name === 'daily backup'));
assert.equal(persisted.length, 1);
owner.scheduler.dispose();

// ── a cron-disabled host (CLI) boots on the SAME workspace ────────────
const cli = makeCronDisabledWiring(ws);
// It must not adopt the cron it cannot run.
await cli.loadDurableAutomations();
assert.equal(cli.manager.listAll().length, 0, 'cron-disabled host must not load crons it cannot run');

// It creates a heartbeat and manages it — all the activity that would
// trigger a durable sync on a cron-enabled host.
await automationTool(cli).impl({
mode: 'create', kind: 'heartbeat', name: 'poll', prompt: 'p',
schedule: { type: 'interval', seconds: 60 },
}, ctx('cli-session')) as string;
const listed = await automationTool(cli).impl({ mode: 'list' }, ctx('cli-session')) as string;
const idMatch = listed.match(/ID: ([a-f0-9-]+)/i);
if (idMatch) await automationTool(cli).impl({ mode: 'delete', id: idMatch[1] }, ctx('cli-session')) as string;

// Give any (erroneous) sync a chance to land, then assert the owner's cron
// is STILL on disk, untouched.
await new Promise(r => setTimeout(r, 200));
const after = await readStore(ws);
assert.deepEqual(after.map(r => r.name), ['daily backup'], 'CLI must not overwrite/erase the desktop\'s durable cron');
cli.scheduler.dispose();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
Loading
Loading