agent-mockingbird 0.0.1
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/.agents/skills/btca-cli/SKILL.md +64 -0
- package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
- package/.env.example +36 -0
- package/.githooks/pre-commit +33 -0
- package/.github/workflows/ci.yml +309 -0
- package/.opencode/bun.lock +18 -0
- package/.opencode/package.json +5 -0
- package/.opencode/tools/agent_type_manager.ts +100 -0
- package/.opencode/tools/config_manager.ts +87 -0
- package/.opencode/tools/cron_manager.ts +145 -0
- package/.opencode/tools/memory_get.ts +43 -0
- package/.opencode/tools/memory_remember.ts +53 -0
- package/.opencode/tools/memory_search.ts +48 -0
- package/AGENTS.md +126 -0
- package/MEMORY.md +2 -0
- package/README.md +451 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/agent-mockingbird.config.example.json +135 -0
- package/apps/server/package.json +32 -0
- package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
- package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
- package/apps/server/src/backend/agents/openclawImport.ts +797 -0
- package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
- package/apps/server/src/backend/agents/service.ts +10 -0
- package/apps/server/src/backend/config/example-config.test.ts +20 -0
- package/apps/server/src/backend/config/orchestration.ts +243 -0
- package/apps/server/src/backend/config/policy.ts +158 -0
- package/apps/server/src/backend/config/schema.test.ts +15 -0
- package/apps/server/src/backend/config/schema.ts +391 -0
- package/apps/server/src/backend/config/semantic.test.ts +34 -0
- package/apps/server/src/backend/config/semantic.ts +149 -0
- package/apps/server/src/backend/config/service.test.ts +75 -0
- package/apps/server/src/backend/config/service.ts +207 -0
- package/apps/server/src/backend/config/smoke.ts +77 -0
- package/apps/server/src/backend/config/store.test.ts +123 -0
- package/apps/server/src/backend/config/store.ts +581 -0
- package/apps/server/src/backend/config/testFixtures.ts +5 -0
- package/apps/server/src/backend/config/types.ts +56 -0
- package/apps/server/src/backend/contracts/events.ts +320 -0
- package/apps/server/src/backend/contracts/runtime.ts +111 -0
- package/apps/server/src/backend/cron/executor.ts +435 -0
- package/apps/server/src/backend/cron/repository.ts +170 -0
- package/apps/server/src/backend/cron/service.ts +660 -0
- package/apps/server/src/backend/cron/storage.ts +92 -0
- package/apps/server/src/backend/cron/types.ts +138 -0
- package/apps/server/src/backend/cron/utils.ts +351 -0
- package/apps/server/src/backend/db/client.ts +20 -0
- package/apps/server/src/backend/db/migrate.ts +40 -0
- package/apps/server/src/backend/db/repository.ts +1762 -0
- package/apps/server/src/backend/db/schema.ts +113 -0
- package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
- package/apps/server/src/backend/db/wipe.ts +13 -0
- package/apps/server/src/backend/defaults.ts +32 -0
- package/apps/server/src/backend/env.ts +48 -0
- package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
- package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
- package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
- package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
- package/apps/server/src/backend/heartbeat/service.ts +176 -0
- package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
- package/apps/server/src/backend/heartbeat/state.ts +167 -0
- package/apps/server/src/backend/heartbeat/types.ts +54 -0
- package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
- package/apps/server/src/backend/http/boundedQueue.ts +92 -0
- package/apps/server/src/backend/http/parsers.ts +40 -0
- package/apps/server/src/backend/http/router.ts +61 -0
- package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
- package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
- package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
- package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
- package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
- package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
- package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
- package/apps/server/src/backend/http/routes/index.ts +101 -0
- package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
- package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
- package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
- package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
- package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
- package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
- package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
- package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
- package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
- package/apps/server/src/backend/http/schemas.ts +64 -0
- package/apps/server/src/backend/http/sse.ts +144 -0
- package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
- package/apps/server/src/backend/logging/logger.ts +64 -0
- package/apps/server/src/backend/mcp/service.ts +326 -0
- package/apps/server/src/backend/memory/cli.ts +170 -0
- package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
- package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
- package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
- package/apps/server/src/backend/memory/qmdPort.ts +61 -0
- package/apps/server/src/backend/memory/records.test.ts +66 -0
- package/apps/server/src/backend/memory/records.ts +229 -0
- package/apps/server/src/backend/memory/service.ts +2012 -0
- package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
- package/apps/server/src/backend/memory/types.ts +104 -0
- package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
- package/apps/server/src/backend/opencode/client.ts +98 -0
- package/apps/server/src/backend/opencode/models.ts +41 -0
- package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
- package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
- package/apps/server/src/backend/paths.ts +57 -0
- package/apps/server/src/backend/prompts/service.ts +100 -0
- package/apps/server/src/backend/queue/queue.test.ts +189 -0
- package/apps/server/src/backend/queue/service.ts +177 -0
- package/apps/server/src/backend/queue/types.ts +39 -0
- package/apps/server/src/backend/run/service.ts +576 -0
- package/apps/server/src/backend/run/storage.ts +47 -0
- package/apps/server/src/backend/run/types.ts +44 -0
- package/apps/server/src/backend/runtime/errors.ts +61 -0
- package/apps/server/src/backend/runtime/index.ts +72 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
- package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
- package/apps/server/src/backend/skills/service.ts +442 -0
- package/apps/server/src/backend/workspace/resolve.ts +27 -0
- package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
- package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
- package/apps/server/src/cli/runtime-assets.mjs +269 -0
- package/apps/server/src/cli/runtime-assets.test.ts +52 -0
- package/apps/server/src/cli/runtime-layout.mjs +75 -0
- package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
- package/apps/server/src/cli/standaloneBuild.ts +19 -0
- package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
- package/apps/server/src/index.ts +178 -0
- package/apps/server/tsconfig.json +12 -0
- package/backlog.md +5 -0
- package/bin/agent-mockingbird +2522 -0
- package/bin/runtime-layout.mjs +75 -0
- package/build-bin.ts +34 -0
- package/build-cli.mjs +37 -0
- package/build.ts +40 -0
- package/bun-env.d.ts +11 -0
- package/bun.lock +888 -0
- package/bunfig.toml +2 -0
- package/components.json +21 -0
- package/config.json +130 -0
- package/deploy/RELEASE_INSTALL.md +112 -0
- package/deploy/docker-compose.yml +42 -0
- package/deploy/systemd/README.md +46 -0
- package/deploy/systemd/agent-mockingbird.service +28 -0
- package/deploy/systemd/opencode.service +25 -0
- package/docs/legacy-config-ui-reference.md +51 -0
- package/docs/memory-e2e-trace-2026-03-04.md +63 -0
- package/docs/memory-ops.md +96 -0
- package/docs/memory-runtime-contract.md +42 -0
- package/docs/memory-tuning-remote-2026-03-04.md +59 -0
- package/docs/opencode-rebase-workflow-plan.md +614 -0
- package/docs/opencode-startup-sync-plan.md +94 -0
- package/docs/vendor-opencode.md +41 -0
- package/drizzle/0000_famous_turbo.sql +49 -0
- package/drizzle/0001_cron_memory_aux.sql +160 -0
- package/drizzle/0002_runtime_session_bindings.sql +28 -0
- package/drizzle/0003_background_runs.sql +27 -0
- package/drizzle/0004_memory_open_write.sql +63 -0
- package/drizzle/0005_signal_channel.sql +47 -0
- package/drizzle/0006_usage_event_dimensions.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +341 -0
- package/drizzle/meta/_journal.json +55 -0
- package/drizzle.config.ts +14 -0
- package/eslint.config.mjs +77 -0
- package/knip.json +18 -0
- package/memory/2026-03-04.md +4 -0
- package/opencode.lock.json +16 -0
- package/package.json +67 -0
- package/packages/agent-mockingbird-installer/README.md +31 -0
- package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
- package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
- package/packages/agent-mockingbird-installer/package.json +23 -0
- package/packages/contracts/package.json +19 -0
- package/packages/contracts/src/agentTypes.ts +122 -0
- package/packages/contracts/src/cron.ts +146 -0
- package/packages/contracts/src/dashboard.ts +378 -0
- package/packages/contracts/src/index.ts +3 -0
- package/packages/contracts/tsconfig.json +4 -0
- package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
- package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
- package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
- package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
- package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
- package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
- package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
- package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
- package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
- package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
- package/runtime-assets/opencode-config/opencode.jsonc +25 -0
- package/runtime-assets/opencode-config/package.json +5 -0
- package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
- package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
- package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
- package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
- package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
- package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
- package/runtime-assets/workspace/AGENTS.md +56 -0
- package/runtime-assets/workspace/MEMORY.md +4 -0
- package/scripts/build-release-bundle.sh +66 -0
- package/scripts/check-ship.ts +383 -0
- package/scripts/dev-opencode.sh +17 -0
- package/scripts/dev-stack-opencode.sh +15 -0
- package/scripts/dev-stack.sh +61 -0
- package/scripts/install-systemd.sh +87 -0
- package/scripts/memory-e2e.sh +76 -0
- package/scripts/memory-trace-e2e.sh +141 -0
- package/scripts/migrate-opencode-env.ts +108 -0
- package/scripts/onboard/bootstrap.sh +32 -0
- package/scripts/opencode-swap.ts +78 -0
- package/scripts/opencode-sync.ts +715 -0
- package/scripts/runtime-assets-sync.mjs +83 -0
- package/scripts/setup-git-hooks.ts +39 -0
- package/tsconfig.json +45 -0
- package/tui.json +98 -0
- package/turbo.json +36 -0
- package/vendor/OPENCODE_VENDOR.md +13 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { deleteLegacyHeartbeatJobs } from "./defaultJob";
|
|
2
|
+
import { executeHeartbeat, parseInterval } from "./service";
|
|
3
|
+
import { getHeartbeatRuntimeState, patchHeartbeatRuntimeState } from "./state";
|
|
4
|
+
import type { HeartbeatRuntimeConfig, HeartbeatStatus } from "./types";
|
|
5
|
+
import { getConfigSnapshot } from "../config/service";
|
|
6
|
+
import { getSessionById, setSessionModel, setSessionTitle } from "../db/repository";
|
|
7
|
+
import { getRuntime } from "../runtime";
|
|
8
|
+
|
|
9
|
+
const HEARTBEAT_SESSION_TITLE = "Heartbeat";
|
|
10
|
+
const TICK_MS = 1_000;
|
|
11
|
+
|
|
12
|
+
function nowMs() {
|
|
13
|
+
return Date.now();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toIso(value: number | null) {
|
|
17
|
+
return value == null ? null : new Date(value).toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadHeartbeatConfig(): HeartbeatRuntimeConfig {
|
|
21
|
+
return getConfigSnapshot().config.runtime.heartbeat;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HeartbeatRuntimeService {
|
|
25
|
+
private timer: Timer | null = null;
|
|
26
|
+
private inFlight: Promise<HeartbeatStatus> | null = null;
|
|
27
|
+
|
|
28
|
+
start() {
|
|
29
|
+
deleteLegacyHeartbeatJobs();
|
|
30
|
+
void this.ensureHeartbeatSession();
|
|
31
|
+
this.timer = setInterval(() => {
|
|
32
|
+
void this.tick();
|
|
33
|
+
}, TICK_MS);
|
|
34
|
+
void this.tick();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
stop() {
|
|
38
|
+
if (this.timer) clearInterval(this.timer);
|
|
39
|
+
this.timer = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getStatus(): HeartbeatStatus {
|
|
43
|
+
const config = loadHeartbeatConfig();
|
|
44
|
+
const state = getHeartbeatRuntimeState();
|
|
45
|
+
const sessionTitle = state.sessionId ? getSessionById(state.sessionId)?.title ?? null : null;
|
|
46
|
+
return {
|
|
47
|
+
config,
|
|
48
|
+
state,
|
|
49
|
+
sessionTitle,
|
|
50
|
+
nextDueAt: this.computeNextDueAt(config.interval, state.lastRunAt, state.running, config.enabled),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async runNow() {
|
|
55
|
+
return this.runHeartbeat({ manual: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async tick() {
|
|
59
|
+
const config = loadHeartbeatConfig();
|
|
60
|
+
if (!config.enabled) return;
|
|
61
|
+
const state = getHeartbeatRuntimeState();
|
|
62
|
+
const nextDueAt = this.computeNextDueAt(config.interval, state.lastRunAt, state.running, config.enabled);
|
|
63
|
+
if (!nextDueAt) {
|
|
64
|
+
await this.runHeartbeat();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (Date.parse(nextDueAt) <= nowMs()) {
|
|
68
|
+
await this.runHeartbeat();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private computeNextDueAt(
|
|
73
|
+
interval: string,
|
|
74
|
+
lastRunAtIso: string | null,
|
|
75
|
+
running: boolean,
|
|
76
|
+
enabled: boolean,
|
|
77
|
+
) {
|
|
78
|
+
if (!enabled || running) return null;
|
|
79
|
+
if (!lastRunAtIso) return null;
|
|
80
|
+
const lastRunAt = Date.parse(lastRunAtIso);
|
|
81
|
+
if (!Number.isFinite(lastRunAt)) return null;
|
|
82
|
+
return toIso(lastRunAt + parseInterval(interval));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async ensureHeartbeatSession() {
|
|
86
|
+
const config = loadHeartbeatConfig();
|
|
87
|
+
const state = getHeartbeatRuntimeState();
|
|
88
|
+
let session = state.sessionId ? getSessionById(state.sessionId) : null;
|
|
89
|
+
if (!session) {
|
|
90
|
+
const mainSession = getSessionById("main");
|
|
91
|
+
if (!mainSession) {
|
|
92
|
+
throw new Error("Main session not available");
|
|
93
|
+
}
|
|
94
|
+
const runtime = getRuntime();
|
|
95
|
+
if (!runtime?.spawnBackgroundSession) {
|
|
96
|
+
throw new Error("Runtime does not support heartbeat child sessions");
|
|
97
|
+
}
|
|
98
|
+
const spawned = await runtime.spawnBackgroundSession({
|
|
99
|
+
parentSessionId: "main",
|
|
100
|
+
title: HEARTBEAT_SESSION_TITLE,
|
|
101
|
+
requestedBy: "heartbeat",
|
|
102
|
+
prompt: "",
|
|
103
|
+
});
|
|
104
|
+
const sessionId = spawned.childSessionId?.trim();
|
|
105
|
+
if (!sessionId) {
|
|
106
|
+
throw new Error("Failed to create heartbeat child session");
|
|
107
|
+
}
|
|
108
|
+
session = getSessionById(sessionId);
|
|
109
|
+
if (!session) {
|
|
110
|
+
throw new Error(`Failed to load heartbeat child session ${sessionId}`);
|
|
111
|
+
}
|
|
112
|
+
patchHeartbeatRuntimeState({
|
|
113
|
+
sessionId,
|
|
114
|
+
backgroundRunId: spawned.runId,
|
|
115
|
+
parentSessionId: spawned.parentSessionId,
|
|
116
|
+
externalSessionId: spawned.childExternalSessionId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (session.title !== HEARTBEAT_SESSION_TITLE) {
|
|
121
|
+
setSessionTitle(session.id, HEARTBEAT_SESSION_TITLE);
|
|
122
|
+
}
|
|
123
|
+
if (session.model !== config.model) {
|
|
124
|
+
setSessionModel(session.id, config.model);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return getSessionById(session.id) ?? session;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async runHeartbeat(input?: { manual?: boolean }) {
|
|
131
|
+
if (this.inFlight) return this.inFlight;
|
|
132
|
+
|
|
133
|
+
const run = (async () => {
|
|
134
|
+
let sessionIdForState: string | null = getHeartbeatRuntimeState().sessionId;
|
|
135
|
+
try {
|
|
136
|
+
const config = loadHeartbeatConfig();
|
|
137
|
+
const session = await this.ensureHeartbeatSession();
|
|
138
|
+
sessionIdForState = session.id;
|
|
139
|
+
patchHeartbeatRuntimeState({
|
|
140
|
+
sessionId: session.id,
|
|
141
|
+
running: true,
|
|
142
|
+
lastError: null,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await executeHeartbeat(this.resolveAgentId(config), session.id, {
|
|
146
|
+
enabled: input?.manual ? true : config.enabled,
|
|
147
|
+
interval: config.interval,
|
|
148
|
+
activeHours: config.activeHours,
|
|
149
|
+
prompt: config.prompt,
|
|
150
|
+
ackMaxChars: config.ackMaxChars,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
patchHeartbeatRuntimeState({
|
|
154
|
+
sessionId: session.id,
|
|
155
|
+
running: false,
|
|
156
|
+
lastRunAt: nowMs(),
|
|
157
|
+
lastResult: result.error
|
|
158
|
+
? "error"
|
|
159
|
+
: result.skipped
|
|
160
|
+
? "skipped"
|
|
161
|
+
: result.acknowledged
|
|
162
|
+
? "acknowledged"
|
|
163
|
+
: "attention",
|
|
164
|
+
lastResponse: result.response ?? null,
|
|
165
|
+
lastError: result.error ?? null,
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
patchHeartbeatRuntimeState({
|
|
169
|
+
sessionId: sessionIdForState,
|
|
170
|
+
running: false,
|
|
171
|
+
lastRunAt: nowMs(),
|
|
172
|
+
lastResult: "error",
|
|
173
|
+
lastResponse: null,
|
|
174
|
+
lastError: error instanceof Error ? error.message : "Unknown heartbeat error",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return this.getStatus();
|
|
178
|
+
})().finally(() => {
|
|
179
|
+
this.inFlight = null;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
this.inFlight = run;
|
|
183
|
+
return run;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private resolveAgentId(config: HeartbeatRuntimeConfig) {
|
|
187
|
+
const requested = config.agentId.trim();
|
|
188
|
+
return requested || "build";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { isActiveHours } from "./activeHours";
|
|
5
|
+
import type { HeartbeatConfig, HeartbeatContext, HeartbeatResult } from "./types";
|
|
6
|
+
import { getConfigSnapshot } from "../config/service";
|
|
7
|
+
import { getRuntime } from "../runtime";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_HEARTBEAT_PROMPT =
|
|
10
|
+
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
|
|
11
|
+
|
|
12
|
+
export function parseInterval(interval: string): number {
|
|
13
|
+
const match = interval.match(/^(\d+)([mhd])$/);
|
|
14
|
+
if (!match) throw new Error(`Invalid interval: ${interval}`);
|
|
15
|
+
|
|
16
|
+
const value = match[1];
|
|
17
|
+
const unit = match[2];
|
|
18
|
+
const num = parseInt(value ?? "0", 10);
|
|
19
|
+
|
|
20
|
+
switch (unit) {
|
|
21
|
+
case "m":
|
|
22
|
+
return num * 60 * 1000;
|
|
23
|
+
case "h":
|
|
24
|
+
return num * 60 * 60 * 1000;
|
|
25
|
+
case "d":
|
|
26
|
+
return num * 24 * 60 * 60 * 1000;
|
|
27
|
+
default:
|
|
28
|
+
throw new Error(`Unknown unit: ${unit}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildHeartbeatPrompt(config: HeartbeatConfig, context: HeartbeatContext): string {
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
|
|
35
|
+
const workspaceDir = getConfigSnapshot().config.runtime.opencode.directory;
|
|
36
|
+
if (workspaceDir) {
|
|
37
|
+
const heartbeatPath = path.join(workspaceDir, "HEARTBEAT.md");
|
|
38
|
+
if (existsSync(heartbeatPath)) {
|
|
39
|
+
const content = readFileSync(heartbeatPath, "utf8").trim();
|
|
40
|
+
if (content) {
|
|
41
|
+
parts.push(`# Heartbeat Checklist\n\n${content}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
parts.push(`## Heartbeat Prompt
|
|
47
|
+
|
|
48
|
+
${config.prompt?.trim() || DEFAULT_HEARTBEAT_PROMPT}`);
|
|
49
|
+
|
|
50
|
+
parts.push(`## Heartbeat Context
|
|
51
|
+
- Agent: ${context.agentId}
|
|
52
|
+
- Main session: main
|
|
53
|
+
- Thread kind: heartbeat
|
|
54
|
+
- Current time: ${context.now}
|
|
55
|
+
- Scheduled for: ${context.scheduledFor}
|
|
56
|
+
${context.lastHeartbeat ? `- Last heartbeat: ${context.lastHeartbeat}` : ""}
|
|
57
|
+
|
|
58
|
+
**Instructions:** If everything is fine and nothing needs attention, reply with "HEARTBEAT_OK" (optionally with brief status, max ${config.ackMaxChars} chars). If something needs attention, describe what and why.`);
|
|
59
|
+
|
|
60
|
+
return parts.join("\n\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isHeartbeatAck(response: string, ackMaxChars: number): boolean {
|
|
64
|
+
const trimmed = response.trim();
|
|
65
|
+
if (!trimmed) return false;
|
|
66
|
+
|
|
67
|
+
const stripped = stripHeartbeatTokenAtEdges(trimmed);
|
|
68
|
+
if (!stripped.didStrip) return false;
|
|
69
|
+
|
|
70
|
+
const remaining = stripped.text.trim();
|
|
71
|
+
return remaining.length <= ackMaxChars;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stripHeartbeatTokenAtEdges(response: string): { didStrip: boolean; text: string } {
|
|
75
|
+
let text = response.trim();
|
|
76
|
+
let didStrip = false;
|
|
77
|
+
const token = "HEARTBEAT_OK";
|
|
78
|
+
const isWordChar = (value: string | undefined) => Boolean(value && /[A-Za-z0-9_]/.test(value));
|
|
79
|
+
|
|
80
|
+
while (text) {
|
|
81
|
+
const prefixNext = text[token.length];
|
|
82
|
+
if (text.startsWith(token) && !isWordChar(prefixNext)) {
|
|
83
|
+
text = text.slice(token.length).trimStart();
|
|
84
|
+
didStrip = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const index = text.lastIndexOf(token);
|
|
89
|
+
const beforeToken = index > 0 ? text[index - 1] : undefined;
|
|
90
|
+
const afterToken = index >= 0 ? text[index + token.length] : undefined;
|
|
91
|
+
if (
|
|
92
|
+
index >= 0 &&
|
|
93
|
+
!isWordChar(beforeToken) &&
|
|
94
|
+
!isWordChar(afterToken) &&
|
|
95
|
+
text.slice(index + token.length).replace(/[^\w]/g, "").length === 0
|
|
96
|
+
) {
|
|
97
|
+
const before = text.slice(0, index).trimEnd();
|
|
98
|
+
const after = text.slice(index + token.length).trimStart();
|
|
99
|
+
text = `${before}${after}`.trim();
|
|
100
|
+
didStrip = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { didStrip, text };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function executeHeartbeat(
|
|
111
|
+
agentId: string,
|
|
112
|
+
sessionId: string,
|
|
113
|
+
config: HeartbeatConfig,
|
|
114
|
+
): Promise<HeartbeatResult> {
|
|
115
|
+
if (!isActiveHours(config)) {
|
|
116
|
+
return {
|
|
117
|
+
acknowledged: false,
|
|
118
|
+
skipped: true,
|
|
119
|
+
suppressed: true,
|
|
120
|
+
response: "Skipped: outside active hours",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const runtime = getRuntime();
|
|
125
|
+
if (!runtime) {
|
|
126
|
+
return {
|
|
127
|
+
acknowledged: false,
|
|
128
|
+
suppressed: false,
|
|
129
|
+
error: "Runtime not available",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const context: HeartbeatContext = {
|
|
134
|
+
agentId,
|
|
135
|
+
sessionId,
|
|
136
|
+
scheduledFor: new Date().toISOString(),
|
|
137
|
+
now: new Date().toISOString(),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const prompt = buildHeartbeatPrompt(config, context);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const ack = await runtime.sendUserMessage({
|
|
144
|
+
sessionId,
|
|
145
|
+
content: prompt,
|
|
146
|
+
agent: agentId,
|
|
147
|
+
metadata: { heartbeat: true },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const lastMessage = [...ack.messages].reverse().find(m => m.role === "assistant");
|
|
151
|
+
|
|
152
|
+
const response = lastMessage?.content ?? "";
|
|
153
|
+
const acknowledged = isHeartbeatAck(response, config.ackMaxChars);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
acknowledged,
|
|
157
|
+
suppressed: acknowledged,
|
|
158
|
+
response: acknowledged ? undefined : response,
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof Error && (error.name === "RuntimeSessionBusyError" || error.name === "RuntimeSessionQueuedError")) {
|
|
162
|
+
return {
|
|
163
|
+
acknowledged: false,
|
|
164
|
+
skipped: true,
|
|
165
|
+
suppressed: true,
|
|
166
|
+
response: "Skipped: session busy",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
acknowledged: false,
|
|
172
|
+
suppressed: false,
|
|
173
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { patchHeartbeatRuntimeState, isActiveHeartbeatSession } from "./state";
|
|
14
|
+
|
|
15
|
+
const testRoot = mkdtempSync(
|
|
16
|
+
path.join(tmpdir(), "agent-mockingbird-heartbeat-state-test-"),
|
|
17
|
+
);
|
|
18
|
+
const testDbPath = path.join(
|
|
19
|
+
testRoot,
|
|
20
|
+
"agent-mockingbird.heartbeat-state.test.db",
|
|
21
|
+
);
|
|
22
|
+
const testConfigPath = path.join(
|
|
23
|
+
testRoot,
|
|
24
|
+
"agent-mockingbird.heartbeat-state.config.json",
|
|
25
|
+
);
|
|
26
|
+
const testWorkspacePath = path.join(testRoot, "workspace");
|
|
27
|
+
|
|
28
|
+
process.env.NODE_ENV = "test";
|
|
29
|
+
process.env.AGENT_MOCKINGBIRD_DB_PATH = testDbPath;
|
|
30
|
+
process.env.AGENT_MOCKINGBIRD_CONFIG_PATH = testConfigPath;
|
|
31
|
+
process.env.AGENT_MOCKINGBIRD_MEMORY_WORKSPACE_DIR = testWorkspacePath;
|
|
32
|
+
process.env.AGENT_MOCKINGBIRD_MEMORY_ENABLED = "false";
|
|
33
|
+
|
|
34
|
+
let resetDatabaseToDefaults: () => unknown;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
await import("../db/migrate");
|
|
38
|
+
({ resetDatabaseToDefaults } = await import("../db/repository"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
resetDatabaseToDefaults();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("heartbeat state helpers", () => {
|
|
46
|
+
test("identifies the active heartbeat session from runtime state", () => {
|
|
47
|
+
expect(isActiveHeartbeatSession("session-heartbeat")).toBe(false);
|
|
48
|
+
|
|
49
|
+
patchHeartbeatRuntimeState({
|
|
50
|
+
sessionId: "session-heartbeat",
|
|
51
|
+
backgroundRunId: "bg-heartbeat-1",
|
|
52
|
+
parentSessionId: "main",
|
|
53
|
+
externalSessionId: "ext-heartbeat-1",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(isActiveHeartbeatSession("session-heartbeat")).toBe(true);
|
|
57
|
+
expect(isActiveHeartbeatSession("other-session")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
63
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { HeartbeatLastResult, HeartbeatRuntimeState } from "./types";
|
|
2
|
+
import { sqlite } from "../db/client";
|
|
3
|
+
|
|
4
|
+
interface HeartbeatRuntimeStateRow {
|
|
5
|
+
id: string;
|
|
6
|
+
session_id: string | null;
|
|
7
|
+
background_run_id: string | null;
|
|
8
|
+
parent_session_id: string | null;
|
|
9
|
+
external_session_id: string | null;
|
|
10
|
+
running: number;
|
|
11
|
+
last_run_at: number | null;
|
|
12
|
+
last_result: HeartbeatLastResult;
|
|
13
|
+
last_response: string | null;
|
|
14
|
+
last_error: string | null;
|
|
15
|
+
updated_at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATE_ID = "default";
|
|
19
|
+
|
|
20
|
+
function nowMs() {
|
|
21
|
+
return Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toIso(value: number | null) {
|
|
25
|
+
return value == null ? null : new Date(value).toISOString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureHeartbeatStateTable() {
|
|
29
|
+
sqlite.run(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS heartbeat_runtime_state (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
session_id TEXT,
|
|
33
|
+
background_run_id TEXT,
|
|
34
|
+
parent_session_id TEXT,
|
|
35
|
+
external_session_id TEXT,
|
|
36
|
+
running INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
last_run_at INTEGER,
|
|
38
|
+
last_result TEXT NOT NULL DEFAULT 'idle',
|
|
39
|
+
last_response TEXT,
|
|
40
|
+
last_error TEXT,
|
|
41
|
+
updated_at INTEGER NOT NULL
|
|
42
|
+
);
|
|
43
|
+
`);
|
|
44
|
+
const columns = sqlite
|
|
45
|
+
.query("PRAGMA table_info(heartbeat_runtime_state)")
|
|
46
|
+
.all() as Array<{ name: string }>;
|
|
47
|
+
const columnNames = new Set(columns.map(column => column.name));
|
|
48
|
+
if (!columnNames.has("background_run_id")) {
|
|
49
|
+
sqlite.run("ALTER TABLE heartbeat_runtime_state ADD COLUMN background_run_id TEXT");
|
|
50
|
+
}
|
|
51
|
+
if (!columnNames.has("parent_session_id")) {
|
|
52
|
+
sqlite.run("ALTER TABLE heartbeat_runtime_state ADD COLUMN parent_session_id TEXT");
|
|
53
|
+
}
|
|
54
|
+
if (!columnNames.has("external_session_id")) {
|
|
55
|
+
sqlite.run("ALTER TABLE heartbeat_runtime_state ADD COLUMN external_session_id TEXT");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rowToState(row: HeartbeatRuntimeStateRow | null): HeartbeatRuntimeState {
|
|
60
|
+
if (!row) {
|
|
61
|
+
return {
|
|
62
|
+
sessionId: null,
|
|
63
|
+
backgroundRunId: null,
|
|
64
|
+
parentSessionId: null,
|
|
65
|
+
externalSessionId: null,
|
|
66
|
+
running: false,
|
|
67
|
+
lastRunAt: null,
|
|
68
|
+
lastResult: "idle",
|
|
69
|
+
lastResponse: null,
|
|
70
|
+
lastError: null,
|
|
71
|
+
updatedAt: new Date(0).toISOString(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
sessionId: row.session_id,
|
|
77
|
+
backgroundRunId: row.background_run_id,
|
|
78
|
+
parentSessionId: row.parent_session_id,
|
|
79
|
+
externalSessionId: row.external_session_id,
|
|
80
|
+
running: row.running === 1,
|
|
81
|
+
lastRunAt: toIso(row.last_run_at),
|
|
82
|
+
lastResult: row.last_result,
|
|
83
|
+
lastResponse: row.last_response,
|
|
84
|
+
lastError: row.last_error,
|
|
85
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getHeartbeatRuntimeState(): HeartbeatRuntimeState {
|
|
90
|
+
ensureHeartbeatStateTable();
|
|
91
|
+
const row = sqlite
|
|
92
|
+
.query(
|
|
93
|
+
`
|
|
94
|
+
SELECT
|
|
95
|
+
id, session_id, background_run_id, parent_session_id, external_session_id,
|
|
96
|
+
running, last_run_at, last_result, last_response, last_error, updated_at
|
|
97
|
+
FROM heartbeat_runtime_state
|
|
98
|
+
WHERE id = ?1
|
|
99
|
+
LIMIT 1
|
|
100
|
+
`,
|
|
101
|
+
)
|
|
102
|
+
.get(STATE_ID) as HeartbeatRuntimeStateRow | null;
|
|
103
|
+
return rowToState(row);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getReservedHeartbeatSessionId(): string | null {
|
|
107
|
+
return getHeartbeatRuntimeState().sessionId;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isActiveHeartbeatSession(sessionId: string): boolean {
|
|
111
|
+
const normalizedSessionId = sessionId.trim();
|
|
112
|
+
if (!normalizedSessionId) return false;
|
|
113
|
+
return getReservedHeartbeatSessionId() === normalizedSessionId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function patchHeartbeatRuntimeState(
|
|
117
|
+
patch: Partial<{
|
|
118
|
+
sessionId: string | null;
|
|
119
|
+
backgroundRunId: string | null;
|
|
120
|
+
parentSessionId: string | null;
|
|
121
|
+
externalSessionId: string | null;
|
|
122
|
+
running: boolean;
|
|
123
|
+
lastRunAt: number | null;
|
|
124
|
+
lastResult: HeartbeatLastResult;
|
|
125
|
+
lastResponse: string | null;
|
|
126
|
+
lastError: string | null;
|
|
127
|
+
}>,
|
|
128
|
+
) {
|
|
129
|
+
ensureHeartbeatStateTable();
|
|
130
|
+
const existing = getHeartbeatRuntimeState();
|
|
131
|
+
const updatedAt = nowMs();
|
|
132
|
+
sqlite
|
|
133
|
+
.query(
|
|
134
|
+
`
|
|
135
|
+
INSERT INTO heartbeat_runtime_state (
|
|
136
|
+
id, session_id, background_run_id, parent_session_id, external_session_id,
|
|
137
|
+
running, last_run_at, last_result, last_response, last_error, updated_at
|
|
138
|
+
)
|
|
139
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
|
140
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
141
|
+
session_id = excluded.session_id,
|
|
142
|
+
background_run_id = excluded.background_run_id,
|
|
143
|
+
parent_session_id = excluded.parent_session_id,
|
|
144
|
+
external_session_id = excluded.external_session_id,
|
|
145
|
+
running = excluded.running,
|
|
146
|
+
last_run_at = excluded.last_run_at,
|
|
147
|
+
last_result = excluded.last_result,
|
|
148
|
+
last_response = excluded.last_response,
|
|
149
|
+
last_error = excluded.last_error,
|
|
150
|
+
updated_at = excluded.updated_at
|
|
151
|
+
`,
|
|
152
|
+
)
|
|
153
|
+
.run(
|
|
154
|
+
STATE_ID,
|
|
155
|
+
patch.sessionId !== undefined ? patch.sessionId : existing.sessionId,
|
|
156
|
+
patch.backgroundRunId !== undefined ? patch.backgroundRunId : existing.backgroundRunId,
|
|
157
|
+
patch.parentSessionId !== undefined ? patch.parentSessionId : existing.parentSessionId,
|
|
158
|
+
patch.externalSessionId !== undefined ? patch.externalSessionId : existing.externalSessionId,
|
|
159
|
+
(patch.running !== undefined ? patch.running : existing.running) ? 1 : 0,
|
|
160
|
+
patch.lastRunAt !== undefined ? patch.lastRunAt : existing.lastRunAt ? Date.parse(existing.lastRunAt) : null,
|
|
161
|
+
patch.lastResult !== undefined ? patch.lastResult : existing.lastResult,
|
|
162
|
+
patch.lastResponse !== undefined ? patch.lastResponse : existing.lastResponse,
|
|
163
|
+
patch.lastError !== undefined ? patch.lastError : existing.lastError,
|
|
164
|
+
updatedAt,
|
|
165
|
+
);
|
|
166
|
+
return getHeartbeatRuntimeState();
|
|
167
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface HeartbeatConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
interval: string;
|
|
4
|
+
activeHours?: {
|
|
5
|
+
start: string;
|
|
6
|
+
end: string;
|
|
7
|
+
timezone: string;
|
|
8
|
+
} | null;
|
|
9
|
+
prompt: string;
|
|
10
|
+
ackMaxChars: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HeartbeatRuntimeConfig extends HeartbeatConfig {
|
|
14
|
+
agentId: string;
|
|
15
|
+
model: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HeartbeatContext {
|
|
19
|
+
agentId: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
scheduledFor: string;
|
|
22
|
+
now: string;
|
|
23
|
+
lastHeartbeat?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HeartbeatResult {
|
|
27
|
+
acknowledged: boolean;
|
|
28
|
+
skipped?: boolean;
|
|
29
|
+
suppressed: boolean;
|
|
30
|
+
response?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type HeartbeatLastResult = "idle" | "acknowledged" | "attention" | "skipped" | "error";
|
|
35
|
+
|
|
36
|
+
export interface HeartbeatRuntimeState {
|
|
37
|
+
sessionId: string | null;
|
|
38
|
+
backgroundRunId: string | null;
|
|
39
|
+
parentSessionId: string | null;
|
|
40
|
+
externalSessionId: string | null;
|
|
41
|
+
running: boolean;
|
|
42
|
+
lastRunAt: string | null;
|
|
43
|
+
lastResult: HeartbeatLastResult;
|
|
44
|
+
lastResponse: string | null;
|
|
45
|
+
lastError: string | null;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HeartbeatStatus {
|
|
50
|
+
config: HeartbeatRuntimeConfig;
|
|
51
|
+
state: HeartbeatRuntimeState;
|
|
52
|
+
sessionTitle: string | null;
|
|
53
|
+
nextDueAt: string | null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createBoundedQueue } from "./boundedQueue";
|
|
4
|
+
|
|
5
|
+
describe("createBoundedQueue", () => {
|
|
6
|
+
test("overflows when the writer stays blocked", async () => {
|
|
7
|
+
let overflowed = false;
|
|
8
|
+
const queue = createBoundedQueue<string>({
|
|
9
|
+
maxSize: 2,
|
|
10
|
+
drainDelayMs: 5,
|
|
11
|
+
tryWrite: () => false,
|
|
12
|
+
onOverflow: () => {
|
|
13
|
+
overflowed = true;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(queue.enqueue("a")).toBe(true);
|
|
18
|
+
expect(queue.enqueue("b")).toBe(true);
|
|
19
|
+
expect(queue.enqueue("c")).toBe(false);
|
|
20
|
+
expect(overflowed).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("drains queued frames once the writer becomes writable", async () => {
|
|
24
|
+
const written: string[] = [];
|
|
25
|
+
let writable = false;
|
|
26
|
+
const queue = createBoundedQueue<string>({
|
|
27
|
+
maxSize: 4,
|
|
28
|
+
drainDelayMs: 5,
|
|
29
|
+
tryWrite: (value) => {
|
|
30
|
+
if (!writable) return false;
|
|
31
|
+
written.push(value);
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
onOverflow: () => {
|
|
35
|
+
throw new Error("queue should not overflow");
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
queue.enqueue("first");
|
|
40
|
+
queue.enqueue("second");
|
|
41
|
+
expect(queue.size()).toBe(2);
|
|
42
|
+
|
|
43
|
+
writable = true;
|
|
44
|
+
await Bun.sleep(20);
|
|
45
|
+
|
|
46
|
+
expect(written).toEqual(["first", "second"]);
|
|
47
|
+
expect(queue.size()).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|