vellum 0.2.1 → 0.2.2
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/README.md +15 -2
- package/bun.lock +5 -2
- package/package.json +4 -2
- package/scripts/capture-x-graphql.ts +562 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
- package/scripts/test.sh +5 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/asset-materialize-tool.test.ts +16 -15
- package/src/__tests__/asset-search-tool.test.ts +23 -22
- package/src/__tests__/attachments-store.test.ts +56 -127
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
- package/src/__tests__/browser-skill-endstate.test.ts +4 -3
- package/src/__tests__/call-bridge.test.ts +385 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-orchestrator.test.ts +130 -4
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +459 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-store.test.ts +216 -1
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +299 -3
- package/src/__tests__/conflict-store.test.ts +2 -1
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/conversation-store.test.ts +30 -32
- package/src/__tests__/credential-security-invariants.test.ts +4 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/handlers-twitter-config.test.ts +718 -0
- package/src/__tests__/intent-routing.test.ts +64 -57
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +62 -28
- package/src/__tests__/llm-usage-store.test.ts +3 -8
- package/src/__tests__/media-generate-image.test.ts +1 -1
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/playbook-tools.test.ts +342 -0
- package/src/__tests__/profile-compiler.test.ts +2 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
- package/src/__tests__/recurrence-engine.test.ts +69 -0
- package/src/__tests__/recurrence-types.test.ts +71 -0
- package/src/__tests__/registry.test.ts +5 -3
- package/src/__tests__/relay-server.test.ts +633 -0
- package/src/__tests__/reminder-store.test.ts +6 -3
- package/src/__tests__/reminder.test.ts +43 -77
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
- package/src/__tests__/run-orchestrator.test.ts +4 -4
- package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
- package/src/__tests__/runtime-runs-http.test.ts +4 -4
- package/src/__tests__/runtime-runs.test.ts +4 -4
- package/src/__tests__/schedule-store.test.ts +482 -0
- package/src/__tests__/schedule-tools.test.ts +700 -0
- package/src/__tests__/scheduler-recurrence.test.ts +329 -0
- package/src/__tests__/server-history-render.test.ts +14 -13
- package/src/__tests__/session-error.test.ts +28 -0
- package/src/__tests__/session-init.benchmark.test.ts +462 -0
- package/src/__tests__/session-queue.test.ts +71 -48
- package/src/__tests__/session-runtime-assembly.test.ts +161 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/signup-e2e.test.ts +2 -1
- package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/subagent-manager-notify.test.ts +42 -10
- package/src/__tests__/subagent-tools.test.ts +141 -41
- package/src/__tests__/task-compiler.test.ts +2 -1
- package/src/__tests__/task-runner.test.ts +2 -1
- package/src/__tests__/task-scheduler.test.ts +2 -1
- package/src/__tests__/task-tools.test.ts +49 -56
- package/src/__tests__/tool-audit-listener.test.ts +1 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor.test.ts +13 -17
- package/src/__tests__/turn-commit.test.ts +218 -3
- package/src/__tests__/twilio-provider.test.ts +143 -0
- package/src/__tests__/twilio-routes.test.ts +789 -0
- package/src/__tests__/twitter-auth-handler.test.ts +581 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/workspace-git-service.test.ts +186 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +12 -8
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +43 -5
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +43 -17
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-store.ts +192 -5
- package/src/calls/relay-server.ts +41 -4
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-provider.ts +10 -6
- package/src/calls/twilio-routes.ts +90 -76
- package/src/calls/types.ts +1 -1
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +251 -1
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/map.ts +246 -0
- package/src/cli/twitter.ts +575 -0
- package/src/cli.ts +7 -5
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
- package/src/config/bundled-skills/document/SKILL.md +18 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +82 -23
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/twitter/SKILL.md +134 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/defaults.ts +33 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +161 -1
- package/src/config/system-prompt.ts +61 -16
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +4 -4
- package/src/daemon/assistant-attachments.ts +10 -0
- package/src/daemon/classifier.ts +3 -1
- package/src/daemon/computer-use-session.ts +3 -1
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +16 -1
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +7 -1
- package/src/daemon/handlers/config.ts +163 -5
- package/src/daemon/handlers/diagnostics.ts +5 -1
- package/src/daemon/handlers/documents.ts +18 -29
- package/src/daemon/handlers/home-base.ts +5 -1
- package/src/daemon/handlers/index.ts +40 -277
- package/src/daemon/handlers/misc.ts +9 -1
- package/src/daemon/handlers/publish.ts +6 -1
- package/src/daemon/handlers/sessions.ts +65 -12
- package/src/daemon/handlers/shared.ts +36 -1
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +20 -6
- package/src/daemon/handlers/subagents.ts +8 -3
- package/src/daemon/handlers/twitter-auth.ts +169 -0
- package/src/daemon/handlers/work-items.ts +384 -68
- package/src/daemon/ipc-contract-inventory.json +28 -4
- package/src/daemon/ipc-contract.ts +133 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +21 -0
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +74 -10
- package/src/daemon/server.ts +143 -26
- package/src/daemon/session-agent-loop.ts +887 -0
- package/src/daemon/session-attachments.ts +28 -5
- package/src/daemon/session-error.ts +24 -3
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +164 -0
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-queue-manager.ts +1 -0
- package/src/daemon/session-runtime-assembly.ts +52 -0
- package/src/daemon/session-skill-tools.ts +124 -5
- package/src/daemon/session-slash.ts +3 -0
- package/src/daemon/session-surfaces.ts +77 -2
- package/src/daemon/session-tool-setup.ts +216 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1404
- package/src/daemon/video-thumbnail.ts +60 -0
- package/src/doordash/client.ts +121 -27
- package/src/doordash/queries.ts +1 -2
- package/src/export/formatter.ts +3 -1
- package/src/followups/followup-store.ts +4 -2
- package/src/followups/types.ts +6 -0
- package/src/hooks/templates.ts +1 -1
- package/src/index.ts +32 -1153
- package/src/memory/attachments-store.ts +28 -83
- package/src/memory/channel-delivery-store.ts +7 -21
- package/src/memory/clarification-resolver.ts +6 -5
- package/src/memory/contradiction-checker.ts +3 -2
- package/src/memory/conversation-key-store.ts +10 -29
- package/src/memory/conversation-store.ts +2 -1
- package/src/memory/db.ts +96 -2
- package/src/memory/entity-extractor.ts +6 -3
- package/src/memory/items-extractor.ts +5 -4
- package/src/memory/jobs-store.ts +3 -2
- package/src/memory/llm-usage-store.ts +1 -2
- package/src/memory/runs-store.ts +1 -2
- package/src/memory/schema.ts +23 -2
- package/src/messaging/style-analyzer.ts +3 -2
- package/src/messaging/thread-summarizer.ts +8 -12
- package/src/messaging/triage-engine.ts +4 -2
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/registry.ts +8 -0
- package/src/runtime/http-server.ts +108 -20
- package/src/runtime/routes/attachment-routes.ts +2 -3
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +5 -10
- package/src/runtime/routes/conversation-routes.ts +5 -5
- package/src/runtime/routes/run-routes.ts +2 -2
- package/src/runtime/run-orchestrator.ts +9 -3
- package/src/schedule/recurrence-engine.ts +138 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +102 -57
- package/src/schedule/scheduler.ts +9 -6
- package/src/security/oauth2.ts +29 -4
- package/src/security/secret-allowlist.ts +46 -0
- package/src/skills/clawhub.ts +1 -1
- package/src/subagent/manager.ts +40 -8
- package/src/swarm/backend-claude-code.ts +64 -9
- package/src/swarm/worker-prompts.ts +2 -1
- package/src/tasks/SPEC.md +34 -28
- package/src/tasks/ephemeral-permissions.ts +16 -7
- package/src/tasks/task-compiler.ts +5 -4
- package/src/tasks/task-runner.ts +10 -5
- package/src/tasks/task-scheduler.ts +1 -1
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/assets/search.ts +4 -4
- package/src/tools/browser/api-map.ts +220 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +2 -1
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/browser/network-recorder.ts +5 -4
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +17 -67
- package/src/tools/calls/call-start.ts +24 -85
- package/src/tools/calls/call-status.ts +35 -51
- package/src/tools/claude-code/claude-code.ts +77 -11
- package/src/tools/contacts/contact-merge.ts +46 -78
- package/src/tools/contacts/contact-search.ts +35 -79
- package/src/tools/contacts/contact-upsert.ts +35 -108
- package/src/tools/credentials/vault.ts +20 -4
- package/src/tools/document/document-tool.ts +71 -144
- package/src/tools/executor.ts +129 -10
- package/src/tools/followups/followup_create.ts +46 -88
- package/src/tools/followups/followup_list.ts +34 -74
- package/src/tools/followups/followup_resolve.ts +31 -66
- package/src/tools/host-terminal/cli-discover.ts +2 -1
- package/src/tools/host-terminal/host-shell.ts +10 -0
- package/src/tools/memory/handlers.ts +5 -4
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/web-fetch.ts +18 -6
- package/src/tools/playbooks/index.ts +4 -5
- package/src/tools/playbooks/playbook-create.ts +3 -47
- package/src/tools/playbooks/playbook-delete.ts +1 -25
- package/src/tools/playbooks/playbook-list.ts +1 -28
- package/src/tools/playbooks/playbook-update.ts +3 -51
- package/src/tools/reminder/reminder.ts +5 -78
- package/src/tools/schedule/create.ts +69 -74
- package/src/tools/schedule/delete.ts +21 -47
- package/src/tools/schedule/list.ts +55 -74
- package/src/tools/schedule/update.ts +77 -84
- package/src/tools/subagent/abort.ts +29 -58
- package/src/tools/subagent/message.ts +30 -63
- package/src/tools/subagent/read.ts +53 -84
- package/src/tools/subagent/spawn.ts +43 -82
- package/src/tools/subagent/status.ts +42 -71
- package/src/tools/swarm/delegate.ts +2 -1
- package/src/tools/tasks/index.ts +8 -8
- package/src/tools/tasks/task-delete.ts +60 -88
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +72 -108
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +183 -215
- package/src/tools/tasks/work-item-list.ts +33 -63
- package/src/tools/tasks/work-item-remove.ts +45 -97
- package/src/tools/tasks/work-item-update.ts +91 -163
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +0 -62
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/tools/watch/screen-watch.ts +3 -1
- package/src/tools/watcher/create.ts +52 -98
- package/src/tools/watcher/delete.ts +20 -46
- package/src/tools/watcher/digest.ts +36 -70
- package/src/tools/watcher/list.ts +49 -79
- package/src/tools/watcher/update.ts +45 -91
- package/src/twitter/client.ts +690 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/types.ts +0 -1
- package/src/util/truncate.ts +6 -0
- package/src/watcher/providers/slack.ts +2 -1
- package/src/watcher/watcher-store.ts +3 -2
- package/src/work-items/work-item-store.ts +27 -2
- package/src/workspace/commit-message-enrichment-service.ts +31 -7
- package/src/workspace/git-service.ts +87 -22
- package/src/workspace/provider-commit-message-generator.ts +242 -0
- package/src/workspace/turn-commit.ts +62 -3
- package/src/tools/contacts/index.ts +0 -4
- package/src/tools/document/index.ts +0 -5
- package/src/tools/followups/index.ts +0 -3
- package/src/tools/subagent/index.ts +0 -5
- /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, test, expect, afterAll, mock } from 'bun:test';
|
|
3
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'view-image-test-')));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
getRootDir: () => testDir,
|
|
12
|
+
isMacOS: () => process.platform === 'darwin',
|
|
13
|
+
isLinux: () => process.platform === 'linux',
|
|
14
|
+
isWindows: () => process.platform === 'win32',
|
|
15
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
16
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
17
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
18
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
19
|
+
ensureDataDir: () => {},
|
|
20
|
+
migrateToDataLayout: () => {},
|
|
21
|
+
migrateToWorkspaceLayout: () => {},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module('../util/logger.js', () => ({
|
|
25
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module('../config/loader.js', () => ({
|
|
31
|
+
getConfig: () => ({ memory: {} }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
await import('../tools/filesystem/view-image.js');
|
|
35
|
+
|
|
36
|
+
import { getTool } from '../tools/registry.js';
|
|
37
|
+
import type { ToolContext } from '../tools/types.js';
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const tool = getTool('view_image')!;
|
|
44
|
+
|
|
45
|
+
function makeContext(workingDir: string = testDir): ToolContext {
|
|
46
|
+
return {
|
|
47
|
+
workingDir,
|
|
48
|
+
sessionId: 'test-session',
|
|
49
|
+
conversationId: 'test-conversation',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Minimal valid JPEG: FF D8 FF E0 header + enough bytes
|
|
54
|
+
const JPEG_HEADER = Buffer.from([
|
|
55
|
+
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
|
|
56
|
+
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
|
|
57
|
+
0x00, 0x01, 0x00, 0x00,
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Minimal valid PNG header
|
|
61
|
+
const PNG_HEADER = Buffer.from([
|
|
62
|
+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
|
63
|
+
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Minimal valid GIF header
|
|
67
|
+
const GIF_HEADER = Buffer.from([
|
|
68
|
+
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00,
|
|
69
|
+
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// Minimal valid WebP header (RIFF....WEBP)
|
|
73
|
+
const WEBP_HEADER = Buffer.from([
|
|
74
|
+
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00,
|
|
75
|
+
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20,
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// ── Success cases ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('view_image tool', () => {
|
|
81
|
+
test('loads a JPEG file', async () => {
|
|
82
|
+
const imgPath = join(testDir, 'test.jpg');
|
|
83
|
+
writeFileSync(imgPath, JPEG_HEADER);
|
|
84
|
+
|
|
85
|
+
const result = await tool.execute({ path: 'test.jpg' }, makeContext());
|
|
86
|
+
|
|
87
|
+
expect(result.isError).toBe(false);
|
|
88
|
+
expect(result.content).toContain('Image loaded');
|
|
89
|
+
expect(result.content).toContain('image/jpeg');
|
|
90
|
+
expect((result as any).contentBlocks).toBeDefined();
|
|
91
|
+
expect((result as any).contentBlocks[0].type).toBe('image');
|
|
92
|
+
expect((result as any).contentBlocks[0].source.media_type).toBe('image/jpeg');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('loads a PNG file', async () => {
|
|
96
|
+
const imgPath = join(testDir, 'test.png');
|
|
97
|
+
writeFileSync(imgPath, PNG_HEADER);
|
|
98
|
+
|
|
99
|
+
const result = await tool.execute({ path: 'test.png' }, makeContext());
|
|
100
|
+
|
|
101
|
+
expect(result.isError).toBe(false);
|
|
102
|
+
expect(result.content).toContain('image/png');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('loads a GIF file', async () => {
|
|
106
|
+
const imgPath = join(testDir, 'test.gif');
|
|
107
|
+
writeFileSync(imgPath, GIF_HEADER);
|
|
108
|
+
|
|
109
|
+
const result = await tool.execute({ path: 'test.gif' }, makeContext());
|
|
110
|
+
|
|
111
|
+
expect(result.isError).toBe(false);
|
|
112
|
+
expect(result.content).toContain('image/gif');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('loads a WebP file', async () => {
|
|
116
|
+
const imgPath = join(testDir, 'test.webp');
|
|
117
|
+
writeFileSync(imgPath, WEBP_HEADER);
|
|
118
|
+
|
|
119
|
+
const result = await tool.execute({ path: 'test.webp' }, makeContext());
|
|
120
|
+
|
|
121
|
+
expect(result.isError).toBe(false);
|
|
122
|
+
expect(result.content).toContain('image/webp');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('loads image with absolute path', async () => {
|
|
126
|
+
const imgPath = join(testDir, 'absolute.jpg');
|
|
127
|
+
writeFileSync(imgPath, JPEG_HEADER);
|
|
128
|
+
|
|
129
|
+
const result = await tool.execute({ path: imgPath }, makeContext());
|
|
130
|
+
|
|
131
|
+
expect(result.isError).toBe(false);
|
|
132
|
+
expect(result.content).toContain('Image loaded');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('returns base64-encoded data in content blocks', async () => {
|
|
136
|
+
const imgPath = join(testDir, 'base64.jpg');
|
|
137
|
+
writeFileSync(imgPath, JPEG_HEADER);
|
|
138
|
+
|
|
139
|
+
const result = await tool.execute({ path: 'base64.jpg' }, makeContext());
|
|
140
|
+
|
|
141
|
+
expect(result.isError).toBe(false);
|
|
142
|
+
const blocks = (result as any).contentBlocks;
|
|
143
|
+
expect(blocks[0].source.type).toBe('base64');
|
|
144
|
+
expect(blocks[0].source.data.length).toBeGreaterThan(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── Error cases ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('view_image error handling', () => {
|
|
151
|
+
test('rejects missing path', async () => {
|
|
152
|
+
const result = await tool.execute({}, makeContext());
|
|
153
|
+
|
|
154
|
+
expect(result.isError).toBe(true);
|
|
155
|
+
expect(result.content).toContain('path is required');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('rejects unsupported file extension', async () => {
|
|
159
|
+
const txtPath = join(testDir, 'readme.txt');
|
|
160
|
+
writeFileSync(txtPath, 'not an image');
|
|
161
|
+
|
|
162
|
+
const result = await tool.execute({ path: 'readme.txt' }, makeContext());
|
|
163
|
+
|
|
164
|
+
expect(result.isError).toBe(true);
|
|
165
|
+
expect(result.content).toContain('unsupported image format');
|
|
166
|
+
expect(result.content).toContain('.txt');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('rejects nonexistent file', async () => {
|
|
170
|
+
const result = await tool.execute({ path: 'nonexistent.jpg' }, makeContext());
|
|
171
|
+
|
|
172
|
+
expect(result.isError).toBe(true);
|
|
173
|
+
expect(result.content).toContain('file not found');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('rejects directory path', async () => {
|
|
177
|
+
const dirPath = join(testDir, 'subdir');
|
|
178
|
+
mkdirSync(dirPath, { recursive: true });
|
|
179
|
+
// Create a .jpg-named directory
|
|
180
|
+
const fakePath = join(testDir, 'fakedir.jpg');
|
|
181
|
+
mkdirSync(fakePath, { recursive: true });
|
|
182
|
+
|
|
183
|
+
const result = await tool.execute({ path: 'fakedir.jpg' }, makeContext());
|
|
184
|
+
|
|
185
|
+
expect(result.isError).toBe(true);
|
|
186
|
+
expect(result.content).toContain('is not a file');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('blocks path traversal outside working directory', async () => {
|
|
190
|
+
const result = await tool.execute({ path: '../../etc/passwd.jpg' }, makeContext());
|
|
191
|
+
|
|
192
|
+
expect(result.isError).toBe(true);
|
|
193
|
+
expect(result.content).toContain('outside the working directory');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('rejects file with unrecognizable magic bytes', async () => {
|
|
197
|
+
const imgPath = join(testDir, 'corrupt.jpg');
|
|
198
|
+
writeFileSync(imgPath, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]));
|
|
199
|
+
|
|
200
|
+
const result = await tool.execute({ path: 'corrupt.jpg' }, makeContext());
|
|
201
|
+
|
|
202
|
+
expect(result.isError).toBe(true);
|
|
203
|
+
expect(result.content).toContain('could not detect image format');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('detects JPEG format from magic bytes regardless of extension', async () => {
|
|
207
|
+
// Write JPEG magic bytes to a .png file
|
|
208
|
+
const imgPath = join(testDir, 'misnamed.png');
|
|
209
|
+
writeFileSync(imgPath, JPEG_HEADER);
|
|
210
|
+
|
|
211
|
+
const result = await tool.execute({ path: 'misnamed.png' }, makeContext());
|
|
212
|
+
|
|
213
|
+
expect(result.isError).toBe(false);
|
|
214
|
+
// Should detect as JPEG from magic bytes, not PNG from extension
|
|
215
|
+
expect(result.content).toContain('image/jpeg');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
_resetGitServiceRegistry,
|
|
10
10
|
_resetBreaker,
|
|
11
11
|
_getConsecutiveFailures,
|
|
12
|
+
_resetInitBreaker,
|
|
13
|
+
_getInitConsecutiveFailures,
|
|
12
14
|
isDeadlineExpired,
|
|
13
15
|
} from '../workspace/git-service.js';
|
|
14
16
|
|
|
@@ -945,6 +947,190 @@ describe('WorkspaceGitService', () => {
|
|
|
945
947
|
});
|
|
946
948
|
});
|
|
947
949
|
|
|
950
|
+
describe('commitIfDirty diff error handling', () => {
|
|
951
|
+
test('non-1 exit code from git diff --cached --quiet is treated as an error', async () => {
|
|
952
|
+
const service = new WorkspaceGitService(testDir);
|
|
953
|
+
await service.ensureInitialized();
|
|
954
|
+
|
|
955
|
+
// Create a file so the workspace is dirty and commitIfDirty will
|
|
956
|
+
// proceed past the "clean" early-return.
|
|
957
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
958
|
+
|
|
959
|
+
// Access the private execGit method and wrap it so that
|
|
960
|
+
// 'git diff --cached --quiet' throws with exit code 2 (simulating
|
|
961
|
+
// a real git error rather than the expected exit code 1 for
|
|
962
|
+
// "staged changes exist").
|
|
963
|
+
const proto = Object.getPrototypeOf(service);
|
|
964
|
+
const originalExecGit = proto.execGit;
|
|
965
|
+
proto.execGit = async function (this: unknown, args: string[]) {
|
|
966
|
+
if (args[0] === 'diff' && args[1] === '--cached' && args[2] === '--quiet') {
|
|
967
|
+
const err = new Error(
|
|
968
|
+
'Git command failed: git diff --cached --quiet\nError: simulated error\nStderr: ',
|
|
969
|
+
) as Error & { code?: number };
|
|
970
|
+
err.code = 2;
|
|
971
|
+
throw err;
|
|
972
|
+
}
|
|
973
|
+
return originalExecGit.call(this, args);
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
// commitIfDirty should propagate the error (not treat code 2 as
|
|
978
|
+
// "staged changes exist")
|
|
979
|
+
await expect(
|
|
980
|
+
service.commitIfDirty(() => ({ message: 'should not commit' })),
|
|
981
|
+
).rejects.toThrow();
|
|
982
|
+
} finally {
|
|
983
|
+
// Restore the original method
|
|
984
|
+
proto.execGit = originalExecGit;
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
describe('init circuit breaker', () => {
|
|
990
|
+
test('init breaker opens after consecutive failures', async () => {
|
|
991
|
+
// Use a directory that doesn't exist so init fails
|
|
992
|
+
const badDir = '/nonexistent/path/that/does/not/exist';
|
|
993
|
+
const service = new WorkspaceGitService(badDir);
|
|
994
|
+
|
|
995
|
+
// First failure — breaker does NOT open (requires 2+ failures)
|
|
996
|
+
await expect(service.ensureInitialized()).rejects.toThrow();
|
|
997
|
+
expect(_getInitConsecutiveFailures(service)).toBe(1);
|
|
998
|
+
|
|
999
|
+
// Second failure — expire the backoff window from the first failure
|
|
1000
|
+
// so the attempt actually runs (not blocked by breaker).
|
|
1001
|
+
const internal = service as unknown as {
|
|
1002
|
+
initNextAllowedAttemptMs: number;
|
|
1003
|
+
};
|
|
1004
|
+
internal.initNextAllowedAttemptMs = Date.now() - 1;
|
|
1005
|
+
|
|
1006
|
+
await expect(service.ensureInitialized()).rejects.toThrow();
|
|
1007
|
+
expect(_getInitConsecutiveFailures(service)).toBe(2);
|
|
1008
|
+
|
|
1009
|
+
// Third attempt within the backoff window — breaker is now open
|
|
1010
|
+
// (2+ consecutive failures) so the attempt is skipped.
|
|
1011
|
+
await expect(service.ensureInitialized()).rejects.toThrow(
|
|
1012
|
+
'Init circuit breaker open: backing off after repeated failures',
|
|
1013
|
+
);
|
|
1014
|
+
// Failure count should NOT increase (the breaker prevented the attempt)
|
|
1015
|
+
expect(_getInitConsecutiveFailures(service)).toBe(2);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('init breaker skips init attempts during backoff window', async () => {
|
|
1019
|
+
const service = new WorkspaceGitService(testDir);
|
|
1020
|
+
|
|
1021
|
+
// Force the init breaker open
|
|
1022
|
+
const internal = service as unknown as {
|
|
1023
|
+
initConsecutiveFailures: number;
|
|
1024
|
+
initNextAllowedAttemptMs: number;
|
|
1025
|
+
};
|
|
1026
|
+
internal.initConsecutiveFailures = 3;
|
|
1027
|
+
internal.initNextAllowedAttemptMs = Date.now() + 60_000; // far in the future
|
|
1028
|
+
|
|
1029
|
+
// ensureInitialized should throw with circuit breaker message
|
|
1030
|
+
await expect(service.ensureInitialized()).rejects.toThrow(
|
|
1031
|
+
'Init circuit breaker open: backing off after repeated failures',
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
// Failure count should NOT increase (the breaker prevented the attempt)
|
|
1035
|
+
expect(_getInitConsecutiveFailures(service)).toBe(3);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test('init breaker resets on success', async () => {
|
|
1039
|
+
const service = new WorkspaceGitService(testDir);
|
|
1040
|
+
|
|
1041
|
+
// Simulate prior init failures
|
|
1042
|
+
const internal = service as unknown as {
|
|
1043
|
+
initConsecutiveFailures: number;
|
|
1044
|
+
initNextAllowedAttemptMs: number;
|
|
1045
|
+
};
|
|
1046
|
+
internal.initConsecutiveFailures = 3;
|
|
1047
|
+
// Set backoff in the past so the breaker is closed and allows retry
|
|
1048
|
+
internal.initNextAllowedAttemptMs = Date.now() - 1;
|
|
1049
|
+
|
|
1050
|
+
// This should succeed and reset the init breaker
|
|
1051
|
+
await service.ensureInitialized();
|
|
1052
|
+
|
|
1053
|
+
expect(_getInitConsecutiveFailures(service)).toBe(0);
|
|
1054
|
+
expect(service.isInitialized()).toBe(true);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test('init breaker allows retry after backoff window expires', async () => {
|
|
1058
|
+
const service = new WorkspaceGitService(testDir);
|
|
1059
|
+
|
|
1060
|
+
// Simulate prior init failures with expired backoff
|
|
1061
|
+
const internal = service as unknown as {
|
|
1062
|
+
initConsecutiveFailures: number;
|
|
1063
|
+
initNextAllowedAttemptMs: number;
|
|
1064
|
+
};
|
|
1065
|
+
internal.initConsecutiveFailures = 5;
|
|
1066
|
+
internal.initNextAllowedAttemptMs = Date.now() - 1; // expired
|
|
1067
|
+
|
|
1068
|
+
// Breaker should be closed (backoff expired), allowing init to proceed
|
|
1069
|
+
await service.ensureInitialized();
|
|
1070
|
+
|
|
1071
|
+
expect(_getInitConsecutiveFailures(service)).toBe(0);
|
|
1072
|
+
expect(service.isInitialized()).toBe(true);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test('init breaker is independent from commit breaker', async () => {
|
|
1076
|
+
const service = new WorkspaceGitService(testDir);
|
|
1077
|
+
await service.ensureInitialized();
|
|
1078
|
+
|
|
1079
|
+
// Force commit breaker open
|
|
1080
|
+
const internal = service as unknown as {
|
|
1081
|
+
consecutiveFailures: number;
|
|
1082
|
+
nextAllowedAttemptMs: number;
|
|
1083
|
+
};
|
|
1084
|
+
internal.consecutiveFailures = 5;
|
|
1085
|
+
internal.nextAllowedAttemptMs = Date.now() + 60_000;
|
|
1086
|
+
|
|
1087
|
+
// Init breaker should still be clean
|
|
1088
|
+
expect(_getInitConsecutiveFailures(service)).toBe(0);
|
|
1089
|
+
|
|
1090
|
+
// Commit breaker should be open
|
|
1091
|
+
expect(_getConsecutiveFailures(service)).toBe(5);
|
|
1092
|
+
|
|
1093
|
+
// Reset commit breaker
|
|
1094
|
+
_resetBreaker(service);
|
|
1095
|
+
|
|
1096
|
+
// Force init breaker open
|
|
1097
|
+
const internal2 = service as unknown as {
|
|
1098
|
+
initConsecutiveFailures: number;
|
|
1099
|
+
initNextAllowedAttemptMs: number;
|
|
1100
|
+
};
|
|
1101
|
+
internal2.initConsecutiveFailures = 3;
|
|
1102
|
+
internal2.initNextAllowedAttemptMs = Date.now() + 60_000;
|
|
1103
|
+
|
|
1104
|
+
// Commit breaker should be clean
|
|
1105
|
+
expect(_getConsecutiveFailures(service)).toBe(0);
|
|
1106
|
+
|
|
1107
|
+
// Init breaker should be open
|
|
1108
|
+
expect(_getInitConsecutiveFailures(service)).toBe(3);
|
|
1109
|
+
|
|
1110
|
+
// Reset init breaker
|
|
1111
|
+
_resetInitBreaker(service);
|
|
1112
|
+
expect(_getInitConsecutiveFailures(service)).toBe(0);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('already initialized service bypasses init breaker check', async () => {
|
|
1116
|
+
const service = new WorkspaceGitService(testDir);
|
|
1117
|
+
await service.ensureInitialized();
|
|
1118
|
+
expect(service.isInitialized()).toBe(true);
|
|
1119
|
+
|
|
1120
|
+
// Force init breaker open
|
|
1121
|
+
const internal = service as unknown as {
|
|
1122
|
+
initConsecutiveFailures: number;
|
|
1123
|
+
initNextAllowedAttemptMs: number;
|
|
1124
|
+
};
|
|
1125
|
+
internal.initConsecutiveFailures = 5;
|
|
1126
|
+
internal.initNextAllowedAttemptMs = Date.now() + 60_000;
|
|
1127
|
+
|
|
1128
|
+
// ensureInitialized should succeed via the fast path (already initialized)
|
|
1129
|
+
// without hitting the breaker
|
|
1130
|
+
await service.ensureInitialized();
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
948
1134
|
describe('isDeadlineExpired helper', () => {
|
|
949
1135
|
test('returns false when deadlineMs is undefined', () => {
|
|
950
1136
|
expect(isDeadlineExpired(undefined)).toBe(false);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from 'bun:test';
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import type { CommitMessageProvider, CommitContext, CommitMessageResult } from '../workspace/commit-message-provider.js';
|
|
7
|
+
|
|
6
8
|
import {
|
|
7
9
|
WorkspaceGitService,
|
|
8
10
|
_resetGitServiceRegistry,
|
|
@@ -11,7 +13,7 @@ import {
|
|
|
11
13
|
HeartbeatService,
|
|
12
14
|
_resetHeartbeatState,
|
|
13
15
|
} from '../workspace/heartbeat-service.js';
|
|
14
|
-
import
|
|
16
|
+
import { _resetEnrichmentService, getEnrichmentService } from '../workspace/commit-message-enrichment-service.js';
|
|
15
17
|
|
|
16
18
|
describe('HeartbeatService', () => {
|
|
17
19
|
let testDir: string;
|
|
@@ -31,12 +33,20 @@ describe('HeartbeatService', () => {
|
|
|
31
33
|
services.set(testDir, service);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
afterEach(() => {
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
// Shut down any in-flight enrichment work before removing the test directory
|
|
38
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
39
|
+
_resetEnrichmentService();
|
|
35
40
|
if (existsSync(testDir)) {
|
|
36
41
|
rmSync(testDir, { recursive: true, force: true });
|
|
37
42
|
}
|
|
38
43
|
});
|
|
39
44
|
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
47
|
+
_resetEnrichmentService();
|
|
48
|
+
});
|
|
49
|
+
|
|
40
50
|
describe('heartbeat check with age threshold', () => {
|
|
41
51
|
test('does not commit when workspace is clean', async () => {
|
|
42
52
|
const heartbeat = new HeartbeatService({
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { getLogger } from '../util/logger.js';
|
|
3
|
+
import { getWorkspacePromptPath } from '../util/platform.js';
|
|
4
|
+
import { getConfig } from '../config/loader.js';
|
|
5
|
+
import { createConversation } from '../memory/conversation-store.js';
|
|
6
|
+
import type { AgentHeartbeatAlert } from '../daemon/ipc-contract.js';
|
|
7
|
+
|
|
8
|
+
const log = getLogger('agent-heartbeat');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CHECKLIST = `- Check the current weather and note anything notable
|
|
11
|
+
- Review any recent news headlines worth flagging
|
|
12
|
+
- Look for calendar events or reminders coming up soon`;
|
|
13
|
+
|
|
14
|
+
export interface AgentHeartbeatDeps {
|
|
15
|
+
processMessage: (conversationId: string, content: string) => Promise<{ messageId: string }>;
|
|
16
|
+
alerter: (alert: AgentHeartbeatAlert) => void;
|
|
17
|
+
/** Override for current hour (0-23), for testing. */
|
|
18
|
+
getCurrentHour?: () => number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class AgentHeartbeatService {
|
|
22
|
+
private readonly deps: AgentHeartbeatDeps;
|
|
23
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
private activeRun: Promise<void> | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(deps: AgentHeartbeatDeps) {
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
start(): void {
|
|
31
|
+
const config = getConfig().agentHeartbeat;
|
|
32
|
+
if (!config.enabled) {
|
|
33
|
+
log.info('Agent heartbeat disabled by config');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (this.timer) return;
|
|
37
|
+
|
|
38
|
+
log.info({ intervalMs: config.intervalMs }, 'Agent heartbeat service started');
|
|
39
|
+
this.timer = setInterval(() => {
|
|
40
|
+
this.runOnce().catch((err) => {
|
|
41
|
+
log.error({ err }, 'Agent heartbeat runOnce failed');
|
|
42
|
+
});
|
|
43
|
+
}, config.intervalMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async stop(): Promise<void> {
|
|
47
|
+
if (this.timer) {
|
|
48
|
+
clearInterval(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
if (this.activeRun) {
|
|
52
|
+
let timerId: ReturnType<typeof setTimeout>;
|
|
53
|
+
const timeout = new Promise<void>((resolve) => { timerId = setTimeout(resolve, 5_000); });
|
|
54
|
+
await Promise.race([this.activeRun, timeout]);
|
|
55
|
+
clearTimeout(timerId!);
|
|
56
|
+
}
|
|
57
|
+
log.info('Agent heartbeat service stopped');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async runOnce(): Promise<void> {
|
|
61
|
+
const config = getConfig().agentHeartbeat;
|
|
62
|
+
if (!config.enabled) return;
|
|
63
|
+
|
|
64
|
+
// Active hours guard — only applied when both bounds are set.
|
|
65
|
+
// The schema rejects configs where only one bound is provided.
|
|
66
|
+
if (config.activeHoursStart != null && config.activeHoursEnd != null) {
|
|
67
|
+
const hour = this.deps.getCurrentHour?.() ?? new Date().getHours();
|
|
68
|
+
if (!isWithinActiveHours(hour, config.activeHoursStart, config.activeHoursEnd)) {
|
|
69
|
+
log.debug({ hour, activeHoursStart: config.activeHoursStart, activeHoursEnd: config.activeHoursEnd }, 'Outside active hours, skipping');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Overlap prevention
|
|
75
|
+
if (this.activeRun) {
|
|
76
|
+
log.debug('Previous heartbeat run still active, skipping');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const run = this.executeRun();
|
|
81
|
+
this.activeRun = run;
|
|
82
|
+
try {
|
|
83
|
+
await run;
|
|
84
|
+
} finally {
|
|
85
|
+
this.activeRun = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async executeRun(): Promise<void> {
|
|
90
|
+
log.info('Running agent heartbeat');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const checklist = this.readChecklist();
|
|
94
|
+
const prompt = this.buildPrompt(checklist);
|
|
95
|
+
|
|
96
|
+
const conversation = createConversation({
|
|
97
|
+
title: 'Agent Heartbeat',
|
|
98
|
+
threadType: 'background',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await this.deps.processMessage(conversation.id, prompt);
|
|
102
|
+
log.info({ conversationId: conversation.id }, 'Agent heartbeat completed');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.error({ err }, 'Agent heartbeat failed');
|
|
105
|
+
try {
|
|
106
|
+
this.deps.alerter({
|
|
107
|
+
type: 'agent_heartbeat_alert',
|
|
108
|
+
title: 'Agent Heartbeat Failed',
|
|
109
|
+
body: err instanceof Error ? err.message : String(err),
|
|
110
|
+
});
|
|
111
|
+
} catch (alertErr) {
|
|
112
|
+
log.warn({ alertErr }, 'Failed to broadcast heartbeat alert');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private readChecklist(): string {
|
|
118
|
+
const heartbeatPath = getWorkspacePromptPath('HEARTBEAT.md');
|
|
119
|
+
if (existsSync(heartbeatPath)) {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(heartbeatPath, 'utf-8');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log.warn({ err, heartbeatPath }, 'Failed to read HEARTBEAT.md, using default checklist');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return DEFAULT_CHECKLIST;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @internal Exposed for testing. */
|
|
130
|
+
buildPrompt(checklist: string): string {
|
|
131
|
+
return `You are running a periodic heartbeat check. Review the following checklist and take any necessary actions.
|
|
132
|
+
|
|
133
|
+
<heartbeat-checklist>
|
|
134
|
+
${checklist}
|
|
135
|
+
</heartbeat-checklist>
|
|
136
|
+
|
|
137
|
+
<heartbeat-disposition>
|
|
138
|
+
After completing your review, end your response with one of:
|
|
139
|
+
- HEARTBEAT_OK — if everything looks good, no action needed
|
|
140
|
+
- HEARTBEAT_ALERT — if you found issues that need attention (describe them before this marker)
|
|
141
|
+
</heartbeat-disposition>`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if the given hour falls within the active window.
|
|
147
|
+
* Handles overnight windows (e.g. start=22, end=6).
|
|
148
|
+
*/
|
|
149
|
+
function isWithinActiveHours(hour: number, start: number, end: number): boolean {
|
|
150
|
+
if (start <= end) {
|
|
151
|
+
return hour >= start && hour < end;
|
|
152
|
+
}
|
|
153
|
+
// Overnight window: e.g. 22-6 means 22,23,0,1,2,3,4,5
|
|
154
|
+
return hour >= start || hour < end;
|
|
155
|
+
}
|
|
@@ -25,6 +25,8 @@ const bundlerLog = getLogger('app-bundler');
|
|
|
25
25
|
import { APP_VERSION } from '../version.js';
|
|
26
26
|
const PACKAGE_VERSION = APP_VERSION;
|
|
27
27
|
|
|
28
|
+
const SHORT_HASH_LENGTH = 8;
|
|
29
|
+
const HASH_DISPLAY_LENGTH = 12;
|
|
28
30
|
const MAX_BUNDLE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
29
31
|
const ASSET_FETCH_TIMEOUT_MS = 10_000;
|
|
30
32
|
|
|
@@ -35,7 +37,7 @@ interface FetchedAsset {
|
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Extract all remote (http/https) URLs from HTML content.
|
|
38
|
-
* Looks in src=, href= on
|
|
40
|
+
* Looks in src=, href= on all elements except <a> tags, and CSS url() references.
|
|
39
41
|
*/
|
|
40
42
|
export function extractRemoteUrls(html: string): string[] {
|
|
41
43
|
const urls = new Set<string>();
|
|
@@ -50,11 +52,13 @@ export function extractRemoteUrls(html: string): string[] {
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
// Match href="..."
|
|
54
|
-
// Captures
|
|
55
|
-
const
|
|
56
|
-
while ((m =
|
|
57
|
-
const
|
|
55
|
+
// Match href="..." on any element except navigation/resolution tags (not assets).
|
|
56
|
+
// Captures the tag name and href value so we can skip them.
|
|
57
|
+
const hrefRe = /<(\w+)\b[^>]*?\bhref\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))[^>]*?\/?>/gi;
|
|
58
|
+
while ((m = hrefRe.exec(html)) !== null) {
|
|
59
|
+
const tagName = m[1];
|
|
60
|
+
if (['a', 'base', 'area'].includes(tagName.toLowerCase())) continue;
|
|
61
|
+
const url = m[2] ?? m[3] ?? m[4];
|
|
58
62
|
if (url && /^https?:\/\//i.test(url)) {
|
|
59
63
|
urls.add(url);
|
|
60
64
|
}
|
|
@@ -77,7 +81,7 @@ export function extractRemoteUrls(html: string): string[] {
|
|
|
77
81
|
* Uses a hash of the URL to avoid collisions, preserving the original extension.
|
|
78
82
|
*/
|
|
79
83
|
function assetFilename(url: string): string {
|
|
80
|
-
const hash = createHash('sha256').update(url).digest('hex').slice(0,
|
|
84
|
+
const hash = createHash('sha256').update(url).digest('hex').slice(0, HASH_DISPLAY_LENGTH);
|
|
81
85
|
let ext = '';
|
|
82
86
|
try {
|
|
83
87
|
const parsed = new URL(url);
|
|
@@ -213,7 +217,7 @@ export async function packageApp(
|
|
|
213
217
|
const allAssets = [...allAssetsMap.values()];
|
|
214
218
|
|
|
215
219
|
// Create the zip archive
|
|
216
|
-
const bundleFilename = `${app.name.replace(/[^a-zA-Z0-9_-]/g, '_')}-${randomUUID().slice(0,
|
|
220
|
+
const bundleFilename = `${app.name.replace(/[^a-zA-Z0-9_-]/g, '_')}-${randomUUID().slice(0, SHORT_HASH_LENGTH)}.vellumapp`;
|
|
217
221
|
const bundlePath = join(tmpdir(), bundleFilename);
|
|
218
222
|
|
|
219
223
|
await new Promise<void>((resolve, reject) => {
|