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
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,49 @@ describe('control-plane heartbeat mutations', () => {
const runDetail = await caller.heartbeatRun({ taskId: 'repo-check', runId: 'run_heartbeat_1' });
expect(runDetail.run).toMatchObject({ taskId: 'repo-check', runId: 'run_heartbeat_1', loadedCheckpoint: true });
});

it('exposes due-task execution through the control-plane router', async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), 'heddle-cp-heartbeat-due-workspace-'));
const stateRoot = mkdtempSync(join(tmpdir(), 'heddle-cp-heartbeat-due-router-'));
const store = new FileHeartbeatTaskService({ dir: join(stateRoot, 'heartbeat') });
await store.saveTask(createTask({
enabled: false,
schedule: {
intervalMs: 60_000,
nextRunAt: undefined,
},
state: {
status: 'idle',
},
}));
const catalog = RuntimeWorkspaceService.ensureCatalog({ workspaceRoot, stateRoot });
const activeWorkspace = catalog.workspaces[0];
if (!activeWorkspace) {
throw new Error('expected default workspace');
}

const caller = controlPlaneRouter.createCaller({
workspaceRoot,
stateRoot,
activeWorkspaceId: activeWorkspace.id,
activeWorkspace,
workspaces: catalog.workspaces,
runtimeHost: null,
logger: pino({ level: 'silent' }),
});

const result = await caller.heartbeatRunDueTasks({
workspaceId: activeWorkspace.id,
model: 'gpt-5.4',
});

expect(result).toMatchObject({
checked: 0,
ran: 0,
failed: 0,
records: [],
});
});
});

function createHeartbeatResult(
Expand Down
36 changes: 35 additions & 1 deletion src/__tests__/integration/server/server-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js';
import { HeddleHeartbeatSchedulerHost } from '@/server/heartbeat-scheduler-host.js';
import { createServerLogger, startHeddleControlPlaneServer } from '@/server/index.js';

describe('control-plane server lifecycle', () => {
Expand Down Expand Up @@ -60,6 +61,39 @@ describe('control-plane server lifecycle', () => {
}
});

it('can start an embedded server without starting the heartbeat scheduler host', async () => {
const paths = createTestPaths('heddle-server-lifecycle-no-scheduler-');
const startScheduler = vi.spyOn(HeddleHeartbeatSchedulerHost.prototype, 'start');
const syncScheduler = vi.spyOn(HeddleHeartbeatSchedulerHost.prototype, 'sync');
const stopScheduler = vi.spyOn(HeddleHeartbeatSchedulerHost.prototype, 'stop');
const server = await startHeddleControlPlaneServer({
mode: 'embedded-chat',
serverId: 'embedded-no-scheduler',
host: '127.0.0.1',
port: 0,
workspaceRoot: paths.workspaceRoot,
stateRoot: paths.stateRoot,
daemonRegistryPath: paths.registryPath,
heartbeatScheduler: {
enabled: false,
},
serveAssets: false,
logger: createTestLogger(paths.stateRoot),
});

try {
expect(startScheduler).not.toHaveBeenCalled();
expect(syncScheduler).not.toHaveBeenCalled();
} finally {
await server.close();
}

expect(stopScheduler).not.toHaveBeenCalled();
startScheduler.mockRestore();
syncScheduler.mockRestore();
stopScheduler.mockRestore();
});

it('does not clear a newer live server record when an older lifecycle shuts down', async () => {
const paths = createTestPaths('heddle-server-lifecycle-owner-');
const server = await startHeddleControlPlaneServer({
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { formatChatV2RuntimeNotice, resolveChatV2Runtime } from '@/cli-v2/commands/chat-v2-command.js';
import { ControlPlaneCommandRuntimeService } from '@/cli-v2/commands/control-plane-command-runtime.js';
import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js';
import type { HeddleControlPlaneServerHandle } from '@/server/index.js';

Expand All @@ -12,7 +12,7 @@ describe('chat-v2 runtime bootstrap', () => {

it('attaches to a fresh live control-plane server', async () => {
const startServer = vi.fn();
const runtime = await resolveChatV2Runtime({
const runtime = await ControlPlaneCommandRuntimeService.resolve({
workspaceRoot: '/repo',
stateDir: '.heddle',
preferApiKey: false,
Expand All @@ -26,7 +26,7 @@ describe('chat-v2 runtime bootstrap', () => {
trpcUrl: 'http://127.0.0.1:8765/trpc',
serverId: 'server-1',
});
expect(formatChatV2RuntimeNotice(runtime)).toContain('attaching chat-v2');
expect(ControlPlaneCommandRuntimeService.formatNotice(runtime, 'chat-v2')).toContain('attaching chat-v2');
});

it('starts an embedded control-plane server when no live server exists', async () => {
Expand All @@ -38,7 +38,7 @@ describe('chat-v2 runtime bootstrap', () => {
close,
}));

const runtime = await resolveChatV2Runtime({
const runtime = await ControlPlaneCommandRuntimeService.resolve({
workspaceRoot: '/repo',
stateDir: '.heddle-test',
preferApiKey: true,
Expand All @@ -65,7 +65,7 @@ describe('chat-v2 runtime bootstrap', () => {
});
await runtime.close();
expect(close).toHaveBeenCalledTimes(1);
expect(formatChatV2RuntimeNotice(runtime)).toContain('browser=http://127.0.0.1:8123');
expect(ControlPlaneCommandRuntimeService.formatNotice(runtime, 'chat-v2')).toContain('browser=http://127.0.0.1:8123');
});

it('starts embedded when force-owner-conflict bypasses a live server', async () => {
Expand All @@ -75,7 +75,7 @@ describe('chat-v2 runtime bootstrap', () => {
port: 8765,
}));

const runtime = await resolveChatV2Runtime({
const runtime = await ControlPlaneCommandRuntimeService.resolve({
workspaceRoot: '/repo',
stateDir: '.heddle',
preferApiKey: false,
Expand Down
149 changes: 148 additions & 1 deletion src/__tests__/unit/tui/heartbeat-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { ClientSharedProxyApiService } from '@/client-shared/api/proxy.js';
import { ControlPlaneCommandRuntimeService } from '@/cli-v2/commands/control-plane-command-runtime.js';
import { runHeartbeatCli } from '@/cli-v2/commands/heartbeat-command.js';
import { formatDurationMs, parseDurationMs, parseHeartbeatArgs } from '../../../cli/heartbeat.js';

describe('heartbeat CLI helpers', () => {
Expand Down Expand Up @@ -80,4 +83,148 @@ describe('heartbeat CLI helpers', () => {
expect(() => parseDurationMs('soon')).toThrow('Invalid duration');
expect(() => parseDurationMs('0s')).toThrow('Invalid duration');
});

it('routes heartbeat task listing through the control-plane API', async () => {
const query = vi.fn(async () => ({ workspaceId: 'workspace-1', tasks: [] }));
const runtime = {
kind: 'attached' as const,
trpcUrl: 'http://127.0.0.1:8765/trpc',
endpoint: {
host: '127.0.0.1',
port: 8765,
},
serverId: 'server-1',
close: vi.fn(async () => undefined),
};
const resolve = vi.spyOn(ControlPlaneCommandRuntimeService, 'resolve').mockResolvedValue(runtime);
const createClient = vi.spyOn(ClientSharedProxyApiService, 'createClient').mockReturnValue({
controlPlane: {
heartbeatTasks: {
query,
},
},
} as never);
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

try {
await runHeartbeatCli(['task', 'list'], {
workspaceRoot: '/repo',
activeWorkspaceId: 'workspace-1',
stateDir: '.heddle',
preferApiKey: true,
runtimeHost: {
kind: 'none',
registryPath: '/registry.json',
},
});
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
heartbeatScheduler: {
enabled: false,
},
}));
expect(query).toHaveBeenCalledWith({ workspaceId: 'workspace-1' });
expect(runtime.close).toHaveBeenCalledTimes(1);
} finally {
resolve.mockRestore();
createClient.mockRestore();
stdout.mockRestore();
}
});

it('passes embedded scheduler config for heartbeat start', async () => {
const runtime = {
kind: 'attached' as const,
trpcUrl: 'http://127.0.0.1:8765/trpc',
endpoint: {
host: '127.0.0.1',
port: 8765,
},
serverId: 'server-1',
close: vi.fn(async () => undefined),
};
const resolve = vi.spyOn(ControlPlaneCommandRuntimeService, 'resolve').mockResolvedValue(runtime);
const createClient = vi.spyOn(ClientSharedProxyApiService, 'createClient').mockReturnValue({
controlPlane: {
heartbeatTasks: {
query: vi.fn(async () => ({ tasks: [] })),
},
heartbeatTaskCreate: {
mutate: vi.fn(async () => ({ task: { taskId: 'repo-gardener', state: { status: 'idle' } } })),
},
},
} as never);
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

try {
await runHeartbeatCli(['start', '--id', 'repo-gardener', '--task', 'Maintain the repo', '--poll', '5s'], {
workspaceRoot: '/repo',
activeWorkspaceId: 'workspace-1',
stateDir: '.heddle',
preferApiKey: true,
runtimeHost: {
kind: 'none',
registryPath: '/registry.json',
},
}).catch(() => undefined);
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
heartbeatScheduler: {
enabled: true,
pollIntervalMs: 5_000,
},
}));
} finally {
resolve.mockRestore();
createClient.mockRestore();
stdout.mockRestore();
}
});

it('rejects --poll when heartbeat start attaches to a live server', async () => {
const runtime = {
kind: 'attached' as const,
trpcUrl: 'http://127.0.0.1:8765/trpc',
endpoint: {
host: '127.0.0.1',
port: 8765,
},
serverId: 'server-1',
close: vi.fn(async () => undefined),
};
const resolve = vi.spyOn(ControlPlaneCommandRuntimeService, 'resolve').mockResolvedValue(runtime);
const createClient = vi.spyOn(ClientSharedProxyApiService, 'createClient');
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

try {
await expect(runHeartbeatCli(['start', '--poll', '5s'], {
workspaceRoot: '/repo',
activeWorkspaceId: 'workspace-1',
stateDir: '.heddle',
preferApiKey: true,
runtimeHost: freshRuntimeHost(),
})).rejects.toThrow('--poll only applies when heartbeat start launches an embedded control-plane server.');
expect(createClient).not.toHaveBeenCalled();
expect(runtime.close).toHaveBeenCalledTimes(1);
} finally {
resolve.mockRestore();
createClient.mockRestore();
stdout.mockRestore();
}
});
});

function freshRuntimeHost() {
return {
kind: 'server' as const,
registryPath: '/registry.json',
serverId: 'server-1',
mode: 'daemon' as const,
endpoint: {
host: '127.0.0.1',
port: 8765,
},
startedAt: '2026-06-02T00:00:00.000Z',
lastSeenAt: '2026-06-02T00:00:01.000Z',
stale: false,
ageMs: 100,
};
}
8 changes: 4 additions & 4 deletions src/cli-v2/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ domain should have:
| `heddle init` | `cli-v2` command adapter delegates `.heddle/config.json` path/default/template behavior to `ProjectConfigService`. | Direct management adapter over the core project-config service/schema contract. |
| `heddle memory status/list/read/search` | Direct memory visibility/catalog service calls implemented inline in `src/cli/main.ts`. | Direct management adapter calling documented memory service contracts. |
| `heddle memory init/validate/maintain` | Direct core memory services; maintenance resolves model/credentials locally. | Direct management adapter, once memory README/public methods explicitly cover validation, repair, backlog, and credential expectations. |
| `heddle heartbeat task ...` | Legacy CLI constructs and mutates heartbeat task objects directly. | Use existing control-plane heartbeat APIs or explicit heartbeat service methods; command code must not own task/schedule mutation policy. |
| `heddle heartbeat runs ...` | Legacy CLI reads run records through heartbeat store abstractions. | Read through the same API/service shape as web-v2. |
| `heddle heartbeat run` | Legacy CLI worker executes due tasks locally. | Server-backed runtime command. CLI requests execution from the live/embedded control-plane server. |
| `heddle heartbeat start` | Legacy CLI starts a long-running scheduler loop. | Server-backed lifecycle command. Start/attach to the control-plane server and report scheduler status; do not run a separate CLI scheduler loop. |
| `heddle heartbeat task ...` | `cli-v2` command adapter attaches/embeds the control-plane server and calls heartbeat task API procedures. | API-backed management command; command code must not own task/schedule mutation policy. |
| `heddle heartbeat runs ...` | `cli-v2` command adapter reads heartbeat run views through control-plane API procedures. | API-backed read command using the same run view shape as web-v2. |
| `heddle heartbeat run` | `cli-v2` command adapter requests task execution or due-task execution through the live/embedded control-plane server. | Server-backed runtime command; no local CLI scheduler worker. |
| `heddle heartbeat start` | `cli-v2` command adapter creates/updates a task through API and reports the server-backed scheduler. Embedded mode keeps the control-plane server alive until Ctrl+C. | Server-backed lifecycle command; do not run a separate CLI scheduler loop. |
| `heddle eval` | Local eval harness adapter over core eval modules. | Direct dev/management adapter unless remote/API evals become a product goal. |

## Migration Order
Expand Down
Loading
Loading