vellum 0.2.0 → 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 +161 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- 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 +5 -8
- 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 +454 -0
- 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-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +691 -0
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
- 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 +348 -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__/doordash-session.test.ts +9 -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 +96 -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 +17 -10
- 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 +222 -0
- package/src/__tests__/run-orchestrator.test.ts +7 -7
- package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
- package/src/__tests__/runtime-runs-http.test.ts +5 -23
- package/src/__tests__/runtime-runs.test.ts +11 -11
- 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 +89 -16
- 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 +273 -2
- 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 +403 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +35 -14
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +48 -0
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +390 -0
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +416 -0
- package/src/calls/relay-server.ts +335 -0
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +173 -0
- package/src/calls/twilio-routes.ts +250 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +256 -25
- 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 +163 -0
- 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.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
- 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 +44 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +218 -1
- package/src/config/system-prompt.ts +100 -6
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +5 -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 +192 -4
- 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 -271
- 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 +495 -39
- package/src/daemon/ipc-contract-inventory.json +40 -4
- package/src/daemon/ipc-contract.ts +185 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +48 -5
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +74 -10
- package/src/daemon/server.ts +144 -29
- 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 +222 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1365
- 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 -1151
- package/src/media/gemini-image-service.ts +1 -1
- 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 +362 -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 +65 -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 +277 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +5 -6
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +12 -19
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +39 -6
- 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 +67 -0
- package/src/tools/calls/call-start.ts +73 -0
- package/src/tools/calls/call-status.ts +81 -0
- 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 +21 -5
- 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/registry.ts +2 -4
- 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 -6
- package/src/tools/tasks/task-delete.ts +69 -56
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +74 -102
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +192 -134
- package/src/tools/tasks/work-item-list.ts +33 -78
- package/src/tools/tasks/work-item-remove.ts +60 -0
- package/src/tools/tasks/work-item-update.ts +114 -0
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +20 -74
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +6 -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 +236 -2
- package/src/workspace/commit-message-enrichment-service.ts +284 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +272 -52
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/provider-commit-message-generator.ts +242 -0
- package/src/workspace/turn-commit.ts +100 -51
- 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
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { getLogger } from '../util/logger.js';
|
|
6
|
+
import { getConfig } from '../config/loader.js';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
const log = getLogger('workspace-git');
|
|
@@ -51,15 +52,6 @@ const WORKSPACE_GITIGNORE_RULES = [
|
|
|
51
52
|
'http-token',
|
|
52
53
|
];
|
|
53
54
|
|
|
54
|
-
/**
|
|
55
|
-
* Rules that were used in older versions but have been superseded.
|
|
56
|
-
* These are removed from existing .gitignore files during normalization
|
|
57
|
-
* to prevent them from overriding the newer, more selective rules.
|
|
58
|
-
*/
|
|
59
|
-
const DEPRECATED_GITIGNORE_RULES = [
|
|
60
|
-
'data/',
|
|
61
|
-
];
|
|
62
|
-
|
|
63
55
|
/** Properties added by Node's child_process errors. */
|
|
64
56
|
interface ExecError extends Error {
|
|
65
57
|
killed?: boolean;
|
|
@@ -142,12 +134,88 @@ export class WorkspaceGitService {
|
|
|
142
134
|
private readonly mutex: Mutex;
|
|
143
135
|
private initialized = false;
|
|
144
136
|
private initPromise: Promise<void> | null = null;
|
|
137
|
+
private consecutiveFailures = 0;
|
|
138
|
+
private nextAllowedAttemptMs = 0;
|
|
139
|
+
private initConsecutiveFailures = 0;
|
|
140
|
+
private initNextAllowedAttemptMs = 0;
|
|
145
141
|
|
|
146
142
|
constructor(workspaceDir: string) {
|
|
147
143
|
this.workspaceDir = workspaceDir;
|
|
148
144
|
this.mutex = new Mutex();
|
|
149
145
|
}
|
|
150
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Check if the circuit breaker is open (too many recent failures).
|
|
149
|
+
* When open, commit attempts are skipped until the backoff window expires.
|
|
150
|
+
*/
|
|
151
|
+
private isBreakerOpen(): boolean {
|
|
152
|
+
if (this.consecutiveFailures === 0) return false;
|
|
153
|
+
return Date.now() < this.nextAllowedAttemptMs;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private recordSuccess(): void {
|
|
157
|
+
if (this.consecutiveFailures > 0) {
|
|
158
|
+
log.info(
|
|
159
|
+
{ workspaceDir: this.workspaceDir, previousFailures: this.consecutiveFailures },
|
|
160
|
+
'Circuit breaker closed: commit succeeded after failures',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
this.consecutiveFailures = 0;
|
|
164
|
+
this.nextAllowedAttemptMs = 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private recordFailure(): void {
|
|
168
|
+
const config = getConfig();
|
|
169
|
+
const failureBackoffBaseMs = config.workspaceGit?.failureBackoffBaseMs ?? 2000;
|
|
170
|
+
const failureBackoffMaxMs = config.workspaceGit?.failureBackoffMaxMs ?? 60000;
|
|
171
|
+
this.consecutiveFailures++;
|
|
172
|
+
const delay = Math.min(
|
|
173
|
+
failureBackoffBaseMs * Math.pow(2, this.consecutiveFailures - 1),
|
|
174
|
+
failureBackoffMaxMs,
|
|
175
|
+
);
|
|
176
|
+
this.nextAllowedAttemptMs = Date.now() + delay;
|
|
177
|
+
log.warn(
|
|
178
|
+
{ workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures, backoffMs: delay },
|
|
179
|
+
'Circuit breaker opened: commit failed, backing off',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if the init circuit breaker is open (too many recent init failures).
|
|
185
|
+
* When open, init attempts are skipped until the backoff window expires.
|
|
186
|
+
*/
|
|
187
|
+
private isInitBreakerOpen(): boolean {
|
|
188
|
+
if (this.initConsecutiveFailures < 2) return false;
|
|
189
|
+
return Date.now() < this.initNextAllowedAttemptMs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private recordInitSuccess(): void {
|
|
193
|
+
if (this.initConsecutiveFailures > 0) {
|
|
194
|
+
log.info(
|
|
195
|
+
{ workspaceDir: this.workspaceDir, previousFailures: this.initConsecutiveFailures },
|
|
196
|
+
'Init circuit breaker closed: initialization succeeded after failures',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
this.initConsecutiveFailures = 0;
|
|
200
|
+
this.initNextAllowedAttemptMs = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private recordInitFailure(): void {
|
|
204
|
+
const config = getConfig();
|
|
205
|
+
const failureBackoffBaseMs = config.workspaceGit?.failureBackoffBaseMs ?? 2000;
|
|
206
|
+
const failureBackoffMaxMs = config.workspaceGit?.failureBackoffMaxMs ?? 60000;
|
|
207
|
+
this.initConsecutiveFailures++;
|
|
208
|
+
const delay = Math.min(
|
|
209
|
+
failureBackoffBaseMs * Math.pow(2, this.initConsecutiveFailures - 1),
|
|
210
|
+
failureBackoffMaxMs,
|
|
211
|
+
);
|
|
212
|
+
this.initNextAllowedAttemptMs = Date.now() + delay;
|
|
213
|
+
log.warn(
|
|
214
|
+
{ workspaceDir: this.workspaceDir, consecutiveFailures: this.initConsecutiveFailures, backoffMs: delay },
|
|
215
|
+
'Init circuit breaker opened: initialization failed, backing off',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
151
219
|
/**
|
|
152
220
|
* Ensure the git repository is initialized.
|
|
153
221
|
* Idempotent: safe to call multiple times.
|
|
@@ -172,6 +240,14 @@ export class WorkspaceGitService {
|
|
|
172
240
|
return this.initPromise;
|
|
173
241
|
}
|
|
174
242
|
|
|
243
|
+
// Circuit breaker: skip if multiple recent init attempts have been failing.
|
|
244
|
+
// Checked AFTER initPromise so callers waiting on in-progress init aren't
|
|
245
|
+
// blocked, and only activates after 2+ consecutive failures so that a
|
|
246
|
+
// single transient failure allows immediate retry.
|
|
247
|
+
if (this.isInitBreakerOpen()) {
|
|
248
|
+
throw new Error('Init circuit breaker open: backing off after repeated failures');
|
|
249
|
+
}
|
|
250
|
+
|
|
175
251
|
// Start initialization
|
|
176
252
|
this.initPromise = this.mutex.withLock(async () => {
|
|
177
253
|
// Double-check after acquiring lock
|
|
@@ -264,6 +340,7 @@ export class WorkspaceGitService {
|
|
|
264
340
|
await this.ensureCommitIdentityLocked();
|
|
265
341
|
await this.ensureOnMainLocked();
|
|
266
342
|
this.initialized = true;
|
|
343
|
+
this.recordInitSuccess();
|
|
267
344
|
return;
|
|
268
345
|
}
|
|
269
346
|
}
|
|
@@ -298,12 +375,14 @@ export class WorkspaceGitService {
|
|
|
298
375
|
await this.execGit(['commit', '-m', message, '--allow-empty']);
|
|
299
376
|
|
|
300
377
|
this.initialized = true;
|
|
378
|
+
this.recordInitSuccess();
|
|
301
379
|
});
|
|
302
380
|
|
|
303
381
|
// If initialization fails, clear the cached promise so subsequent
|
|
304
382
|
// calls can retry instead of permanently returning the rejected promise.
|
|
305
383
|
this.initPromise.catch(() => {
|
|
306
384
|
this.initPromise = null;
|
|
385
|
+
this.recordInitFailure();
|
|
307
386
|
});
|
|
308
387
|
|
|
309
388
|
return this.initPromise;
|
|
@@ -344,47 +423,119 @@ export class WorkspaceGitService {
|
|
|
344
423
|
*
|
|
345
424
|
* @param decide - Called with the current status. Return an object with `message`
|
|
346
425
|
* (and optional `metadata`) to commit, or `null` to skip.
|
|
426
|
+
* @param options.bypassBreaker - Skip circuit breaker checks (used for shutdown commits).
|
|
427
|
+
* @param options.deadlineMs - Absolute timestamp (Date.now()) after which the commit
|
|
428
|
+
* should be skipped. Checked before lock acquisition, after lock acquisition, and
|
|
429
|
+
* before git add/commit to prevent stale queued attempts from doing expensive work.
|
|
347
430
|
* @returns Whether a commit was created and the status at check time.
|
|
348
431
|
*/
|
|
349
432
|
async commitIfDirty(
|
|
350
433
|
decide: (status: GitStatus) => { message: string; metadata?: GitCommitMetadata } | null,
|
|
434
|
+
options?: { bypassBreaker?: boolean; deadlineMs?: number },
|
|
351
435
|
): Promise<{ committed: boolean; status: GitStatus }> {
|
|
436
|
+
const emptyStatus: GitStatus = { staged: [], modified: [], untracked: [], clean: false };
|
|
437
|
+
|
|
438
|
+
// Circuit breaker: skip expensive git work if recent attempts have been failing.
|
|
439
|
+
// Shutdown commits bypass the breaker because the process is about to exit and
|
|
440
|
+
// this is the last chance to persist workspace state.
|
|
441
|
+
if (!options?.bypassBreaker && this.isBreakerOpen()) {
|
|
442
|
+
log.debug(
|
|
443
|
+
{ workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
|
|
444
|
+
'Circuit breaker open, skipping commit attempt',
|
|
445
|
+
);
|
|
446
|
+
return { committed: false, status: emptyStatus };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Deadline fast-path: bail before acquiring the lock if already past deadline.
|
|
450
|
+
if (isDeadlineExpired(options?.deadlineMs)) {
|
|
451
|
+
log.debug(
|
|
452
|
+
{ workspaceDir: this.workspaceDir },
|
|
453
|
+
'Deadline expired before lock acquisition, skipping commit',
|
|
454
|
+
);
|
|
455
|
+
return { committed: false, status: emptyStatus };
|
|
456
|
+
}
|
|
457
|
+
|
|
352
458
|
await this.ensureInitialized();
|
|
353
459
|
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
460
|
+
try {
|
|
461
|
+
const result = await this.mutex.withLock(async () => {
|
|
462
|
+
// Re-check breaker under lock: a queued call that started before the
|
|
463
|
+
// breaker opened should not proceed with expensive git work now that
|
|
464
|
+
// the breaker is open.
|
|
465
|
+
if (!options?.bypassBreaker && this.isBreakerOpen()) {
|
|
466
|
+
log.debug(
|
|
467
|
+
{ workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
|
|
468
|
+
'Circuit breaker open after lock acquisition, skipping commit',
|
|
469
|
+
);
|
|
470
|
+
return { committed: false, status: emptyStatus, didRunGit: false as const };
|
|
471
|
+
}
|
|
359
472
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
473
|
+
// Re-check deadline after lock acquisition: the call may have waited
|
|
474
|
+
// in the mutex queue past its deadline.
|
|
475
|
+
if (isDeadlineExpired(options?.deadlineMs)) {
|
|
476
|
+
log.debug(
|
|
477
|
+
{ workspaceDir: this.workspaceDir },
|
|
478
|
+
'Deadline expired after lock acquisition, skipping commit',
|
|
479
|
+
);
|
|
480
|
+
return { committed: false, status: emptyStatus, didRunGit: false as const };
|
|
481
|
+
}
|
|
364
482
|
|
|
365
|
-
|
|
483
|
+
const status = await this.getStatusInternal();
|
|
484
|
+
if (status.clean) {
|
|
485
|
+
return { committed: false, status, didRunGit: true as const };
|
|
486
|
+
}
|
|
366
487
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
await this.execGit(['diff', '--cached', '--quiet']);
|
|
372
|
-
// Exit code 0 means nothing staged — nothing to commit
|
|
373
|
-
return { committed: false, status };
|
|
374
|
-
} catch {
|
|
375
|
-
// Exit code 1 means there ARE staged changes — proceed
|
|
376
|
-
}
|
|
488
|
+
const decision = decide(status);
|
|
489
|
+
if (!decision) {
|
|
490
|
+
return { committed: false, status, didRunGit: true as const };
|
|
491
|
+
}
|
|
377
492
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
493
|
+
// Check deadline before expensive git add/commit operations.
|
|
494
|
+
if (isDeadlineExpired(options?.deadlineMs)) {
|
|
495
|
+
log.debug(
|
|
496
|
+
{ workspaceDir: this.workspaceDir },
|
|
497
|
+
'Deadline expired before git add/commit, skipping commit',
|
|
498
|
+
);
|
|
499
|
+
return { committed: false, status, didRunGit: true as const };
|
|
500
|
+
}
|
|
384
501
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
502
|
+
await this.execGit(['add', '-A']);
|
|
503
|
+
|
|
504
|
+
// Verify something was actually staged. Another service instance
|
|
505
|
+
// (or external process) could have committed between our status
|
|
506
|
+
// check and the add, leaving the index clean.
|
|
507
|
+
try {
|
|
508
|
+
await this.execGit(['diff', '--cached', '--quiet']);
|
|
509
|
+
// Exit code 0 means nothing staged — nothing to commit
|
|
510
|
+
return { committed: false, status, didRunGit: true as const };
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// git diff --cached --quiet exits with code 1 when there are staged changes.
|
|
513
|
+
// Any other error (timeout, permission, etc.) should be treated as a failure.
|
|
514
|
+
const execErr = err as ExecError;
|
|
515
|
+
if (execErr.code !== 1) {
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
// Exit code 1 = staged changes exist — proceed with commit
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let fullMessage = decision.message;
|
|
522
|
+
if (decision.metadata && Object.keys(decision.metadata).length > 0) {
|
|
523
|
+
fullMessage += '\n\n' + Object.entries(decision.metadata)
|
|
524
|
+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
|
525
|
+
.join('\n');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await this.execGit(['commit', '-m', fullMessage]);
|
|
529
|
+
return { committed: true, status, didRunGit: true as const };
|
|
530
|
+
});
|
|
531
|
+
if (result.didRunGit) {
|
|
532
|
+
this.recordSuccess();
|
|
533
|
+
}
|
|
534
|
+
return { committed: result.committed, status: result.status };
|
|
535
|
+
} catch (err) {
|
|
536
|
+
this.recordFailure();
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
388
539
|
}
|
|
389
540
|
|
|
390
541
|
/**
|
|
@@ -439,7 +590,6 @@ export class WorkspaceGitService {
|
|
|
439
590
|
/**
|
|
440
591
|
* Ensure .gitignore contains all required workspace exclusion rules.
|
|
441
592
|
* Idempotent: checks for missing rules and only appends what's needed.
|
|
442
|
-
* Also removes deprecated rules that have been superseded by newer ones.
|
|
443
593
|
* Must be called with the mutex lock held.
|
|
444
594
|
*/
|
|
445
595
|
private ensureGitignoreRulesLocked(): void {
|
|
@@ -447,18 +597,28 @@ export class WorkspaceGitService {
|
|
|
447
597
|
if (existsSync(gitignorePath)) {
|
|
448
598
|
let content = readFileSync(gitignorePath, 'utf-8');
|
|
449
599
|
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
600
|
+
// Migrate legacy broad ignore rule to selective data subdirectory rules.
|
|
601
|
+
// This keeps user-tracked files under data/ visible to git.
|
|
602
|
+
const lines = content.split('\n');
|
|
603
|
+
const hadLegacyDataRule = lines.some(line => line.trim() === 'data/');
|
|
604
|
+
if (hadLegacyDataRule) {
|
|
605
|
+
content = lines
|
|
606
|
+
.filter(line => line.trim() !== 'data/')
|
|
607
|
+
.join('\n');
|
|
608
|
+
if (!content.endsWith('\n')) {
|
|
609
|
+
content += '\n';
|
|
610
|
+
}
|
|
455
611
|
}
|
|
456
612
|
|
|
457
613
|
const missingRules = WORKSPACE_GITIGNORE_RULES.filter(rule => !content.includes(rule));
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
614
|
+
if (hadLegacyDataRule || missingRules.length > 0) {
|
|
615
|
+
let updated = content;
|
|
616
|
+
if (missingRules.length > 0) {
|
|
617
|
+
if (!updated.endsWith('\n')) {
|
|
618
|
+
updated += '\n';
|
|
619
|
+
}
|
|
620
|
+
updated += '# Vellum runtime state (auto-added)\n' + missingRules.join('\n') + '\n';
|
|
621
|
+
}
|
|
462
622
|
writeFileSync(gitignorePath, updated, 'utf-8');
|
|
463
623
|
}
|
|
464
624
|
} else {
|
|
@@ -532,16 +692,21 @@ export class WorkspaceGitService {
|
|
|
532
692
|
|
|
533
693
|
/**
|
|
534
694
|
* Execute a git command in the workspace directory.
|
|
535
|
-
*
|
|
536
|
-
* (e.g. stale git lock files).
|
|
695
|
+
* Uses the configurable interactiveGitTimeoutMs (default 10 000 ms) to
|
|
696
|
+
* prevent hung operations (e.g. stale git lock files). The timeout is
|
|
697
|
+
* intentionally short for interactive workspace operations — background
|
|
698
|
+
* enrichment jobs use their own dedicated timeout.
|
|
537
699
|
*/
|
|
538
|
-
private async execGit(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
700
|
+
private async execGit(args: string[], options?: { signal?: AbortSignal }): Promise<{ stdout: string; stderr: string }> {
|
|
701
|
+
const config = getConfig();
|
|
702
|
+
const timeoutMs = config.workspaceGit?.interactiveGitTimeoutMs ?? 10_000;
|
|
539
703
|
try {
|
|
540
704
|
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
541
705
|
cwd: this.workspaceDir,
|
|
542
706
|
encoding: 'utf-8',
|
|
543
|
-
timeout:
|
|
707
|
+
timeout: timeoutMs,
|
|
544
708
|
env: cleanGitEnv(this.workspaceDir),
|
|
709
|
+
signal: options?.signal,
|
|
545
710
|
});
|
|
546
711
|
return { stdout, stderr };
|
|
547
712
|
} catch (err) {
|
|
@@ -567,6 +732,23 @@ export class WorkspaceGitService {
|
|
|
567
732
|
}
|
|
568
733
|
}
|
|
569
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Get the commit hash of the current HEAD.
|
|
737
|
+
* This is a lightweight read-only operation that does not require the mutex.
|
|
738
|
+
*/
|
|
739
|
+
async getHeadHash(): Promise<string> {
|
|
740
|
+
const { stdout } = await this.execGit(['rev-parse', 'HEAD']);
|
|
741
|
+
return stdout.trim();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Write a git note to a specific commit.
|
|
746
|
+
* Uses the 'vellum' notes ref to avoid conflicts with default notes.
|
|
747
|
+
*/
|
|
748
|
+
async writeNote(commitHash: string, noteContent: string, signal?: AbortSignal): Promise<void> {
|
|
749
|
+
await this.execGit(['notes', '--ref=vellum', 'add', '-f', '-m', noteContent, commitHash], { signal });
|
|
750
|
+
}
|
|
751
|
+
|
|
570
752
|
/**
|
|
571
753
|
* Check if the workspace has a git repository initialized.
|
|
572
754
|
* This is a non-blocking check that doesn't trigger initialization.
|
|
@@ -583,6 +765,14 @@ export class WorkspaceGitService {
|
|
|
583
765
|
}
|
|
584
766
|
}
|
|
585
767
|
|
|
768
|
+
/**
|
|
769
|
+
* Check whether a deadline has expired.
|
|
770
|
+
* Returns true when `deadlineMs` is provided and `Date.now()` has reached or passed it.
|
|
771
|
+
*/
|
|
772
|
+
export function isDeadlineExpired(deadlineMs?: number): boolean {
|
|
773
|
+
return deadlineMs !== undefined && Date.now() >= deadlineMs;
|
|
774
|
+
}
|
|
775
|
+
|
|
586
776
|
/**
|
|
587
777
|
* Singleton registry for workspace git services.
|
|
588
778
|
* Ensures one service instance per workspace directory.
|
|
@@ -618,3 +808,33 @@ export function getAllWorkspaceGitServices(): ReadonlyMap<string, WorkspaceGitSe
|
|
|
618
808
|
export function _resetGitServiceRegistry(): void {
|
|
619
809
|
serviceRegistry.clear();
|
|
620
810
|
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* @internal Test-only: reset circuit breaker state for a service instance
|
|
814
|
+
*/
|
|
815
|
+
export function _resetBreaker(service: WorkspaceGitService): void {
|
|
816
|
+
(service as unknown as { consecutiveFailures: number }).consecutiveFailures = 0;
|
|
817
|
+
(service as unknown as { nextAllowedAttemptMs: number }).nextAllowedAttemptMs = 0;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* @internal Test-only: get consecutive failure count
|
|
822
|
+
*/
|
|
823
|
+
export function _getConsecutiveFailures(service: WorkspaceGitService): number {
|
|
824
|
+
return (service as unknown as { consecutiveFailures: number }).consecutiveFailures;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* @internal Test-only: reset init circuit breaker state for a service instance
|
|
829
|
+
*/
|
|
830
|
+
export function _resetInitBreaker(service: WorkspaceGitService): void {
|
|
831
|
+
(service as unknown as { initConsecutiveFailures: number }).initConsecutiveFailures = 0;
|
|
832
|
+
(service as unknown as { initNextAllowedAttemptMs: number }).initNextAllowedAttemptMs = 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* @internal Test-only: get init consecutive failure count
|
|
837
|
+
*/
|
|
838
|
+
export function _getInitConsecutiveFailures(service: WorkspaceGitService): number {
|
|
839
|
+
return (service as unknown as { initConsecutiveFailures: number }).initConsecutiveFailures;
|
|
840
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { getLogger } from '../util/logger.js';
|
|
2
2
|
import { getAllWorkspaceGitServices, type WorkspaceGitService } from './git-service.js';
|
|
3
|
+
import {
|
|
4
|
+
DefaultCommitMessageProvider,
|
|
5
|
+
type CommitContext,
|
|
6
|
+
type CommitMessageProvider,
|
|
7
|
+
} from './commit-message-provider.js';
|
|
8
|
+
import { getEnrichmentService } from './commit-message-enrichment-service.js';
|
|
3
9
|
|
|
4
10
|
const log = getLogger('heartbeat');
|
|
5
11
|
|
|
@@ -23,6 +29,8 @@ export interface HeartbeatServiceOptions {
|
|
|
23
29
|
getServices?: () => ReadonlyMap<string, WorkspaceGitService>;
|
|
24
30
|
/** Override for getting the current timestamp (for testing). */
|
|
25
31
|
now?: () => number;
|
|
32
|
+
/** Custom commit message provider. */
|
|
33
|
+
commitMessageProvider?: CommitMessageProvider;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/**
|
|
@@ -61,6 +69,7 @@ export class HeartbeatService {
|
|
|
61
69
|
private readonly intervalMs: number;
|
|
62
70
|
private readonly getServices: () => ReadonlyMap<string, WorkspaceGitService>;
|
|
63
71
|
private readonly now: () => number;
|
|
72
|
+
private readonly commitMessageProvider: CommitMessageProvider;
|
|
64
73
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
65
74
|
/** Tracks the currently in-flight check to prevent overlapping runs and allow clean shutdown. */
|
|
66
75
|
private activeCheck: Promise<HeartbeatCheckResult> | null = null;
|
|
@@ -71,6 +80,7 @@ export class HeartbeatService {
|
|
|
71
80
|
this.intervalMs = options?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
72
81
|
this.getServices = options?.getServices ?? getAllWorkspaceGitServices;
|
|
73
82
|
this.now = options?.now ?? Date.now;
|
|
83
|
+
this.commitMessageProvider = options?.commitMessageProvider ?? new DefaultCommitMessageProvider();
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
/**
|
|
@@ -139,7 +149,7 @@ export class HeartbeatService {
|
|
|
139
149
|
result.checked++;
|
|
140
150
|
|
|
141
151
|
try {
|
|
142
|
-
const committed = await this.checkWorkspace(workspaceDir, service
|
|
152
|
+
const committed = await this.checkWorkspace(workspaceDir, service);
|
|
143
153
|
if (committed) {
|
|
144
154
|
result.committed++;
|
|
145
155
|
} else {
|
|
@@ -189,18 +199,39 @@ export class HeartbeatService {
|
|
|
189
199
|
|
|
190
200
|
try {
|
|
191
201
|
const now = this.now();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
let shutdownFiles: string[] = [];
|
|
203
|
+
const { committed } = await service.commitIfDirty((st) => {
|
|
204
|
+
const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
|
|
205
|
+
shutdownFiles = uniqueFiles;
|
|
206
|
+
log.info({ workspaceDir, totalChanges: uniqueFiles.length }, 'Committing pending changes on shutdown');
|
|
207
|
+
|
|
208
|
+
const ctx: CommitContext = {
|
|
209
|
+
workspaceDir,
|
|
210
|
+
trigger: 'shutdown',
|
|
211
|
+
changedFiles: uniqueFiles,
|
|
212
|
+
timestampMs: now,
|
|
198
213
|
};
|
|
199
|
-
|
|
214
|
+
|
|
215
|
+
return this.commitMessageProvider.buildImmediateMessage(ctx);
|
|
216
|
+
}, { bypassBreaker: true });
|
|
200
217
|
|
|
201
218
|
if (committed) {
|
|
202
219
|
firstSeenDirty.delete(workspaceDir);
|
|
203
220
|
result.committed++;
|
|
221
|
+
|
|
222
|
+
// Fire-and-forget enrichment
|
|
223
|
+
try {
|
|
224
|
+
const commitHash = await service.getHeadHash();
|
|
225
|
+
const shutdownCtx: CommitContext = {
|
|
226
|
+
workspaceDir,
|
|
227
|
+
trigger: 'shutdown',
|
|
228
|
+
changedFiles: shutdownFiles,
|
|
229
|
+
timestampMs: this.now(),
|
|
230
|
+
};
|
|
231
|
+
getEnrichmentService().enqueue({ workspaceDir, commitHash, context: shutdownCtx, gitService: service });
|
|
232
|
+
} catch (enrichErr) {
|
|
233
|
+
log.debug({ enrichErr }, 'Failed to enqueue shutdown enrichment (non-fatal)');
|
|
234
|
+
}
|
|
204
235
|
} else {
|
|
205
236
|
result.skipped++;
|
|
206
237
|
}
|
|
@@ -225,13 +256,15 @@ export class HeartbeatService {
|
|
|
225
256
|
private async checkWorkspace(
|
|
226
257
|
workspaceDir: string,
|
|
227
258
|
service: WorkspaceGitService,
|
|
228
|
-
trigger: string,
|
|
229
259
|
): Promise<boolean> {
|
|
230
260
|
const now = this.now();
|
|
261
|
+
let heartbeatFiles: string[] = [];
|
|
262
|
+
let heartbeatReason: string | undefined;
|
|
231
263
|
|
|
232
264
|
// Atomic status check + conditional commit within a single mutex lock.
|
|
233
265
|
const { committed, status } = await service.commitIfDirty((st) => {
|
|
234
|
-
const
|
|
266
|
+
const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
|
|
267
|
+
const totalChanges = uniqueFiles.length;
|
|
235
268
|
|
|
236
269
|
// Track when we first saw this workspace as dirty
|
|
237
270
|
if (!firstSeenDirty.has(workspaceDir)) {
|
|
@@ -256,19 +289,43 @@ export class HeartbeatService {
|
|
|
256
289
|
? `changes older than ${Math.round(dirtyAge / 1000)}s`
|
|
257
290
|
: `${totalChanges} files changed (threshold: ${this.fileThreshold})`;
|
|
258
291
|
|
|
292
|
+
heartbeatFiles = uniqueFiles;
|
|
293
|
+
heartbeatReason = reason;
|
|
294
|
+
|
|
259
295
|
log.info(
|
|
260
296
|
{ workspaceDir, totalChanges, dirtyAgeMs: dirtyAge, reason },
|
|
261
297
|
'Heartbeat auto-committing workspace changes',
|
|
262
298
|
);
|
|
263
299
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
300
|
+
const ctx: CommitContext = {
|
|
301
|
+
workspaceDir,
|
|
302
|
+
trigger: 'heartbeat',
|
|
303
|
+
changedFiles: uniqueFiles,
|
|
304
|
+
timestampMs: now,
|
|
305
|
+
reason,
|
|
267
306
|
};
|
|
307
|
+
|
|
308
|
+
return this.commitMessageProvider.buildImmediateMessage(ctx);
|
|
268
309
|
});
|
|
269
310
|
|
|
270
311
|
if (committed) {
|
|
271
312
|
firstSeenDirty.delete(workspaceDir);
|
|
313
|
+
|
|
314
|
+
// Fire-and-forget enrichment
|
|
315
|
+
try {
|
|
316
|
+
const commitHash = await service.getHeadHash();
|
|
317
|
+
const hbCtx: CommitContext = {
|
|
318
|
+
workspaceDir,
|
|
319
|
+
trigger: 'heartbeat',
|
|
320
|
+
changedFiles: heartbeatFiles,
|
|
321
|
+
timestampMs: now,
|
|
322
|
+
reason: heartbeatReason,
|
|
323
|
+
};
|
|
324
|
+
getEnrichmentService().enqueue({ workspaceDir, commitHash, context: hbCtx, gitService: service });
|
|
325
|
+
} catch (enrichErr) {
|
|
326
|
+
log.debug({ enrichErr }, 'Failed to enqueue heartbeat enrichment (non-fatal)');
|
|
327
|
+
}
|
|
328
|
+
|
|
272
329
|
return true;
|
|
273
330
|
}
|
|
274
331
|
|