@vellumai/assistant 0.11.1 → 0.11.2-staging.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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -38,6 +38,14 @@ interface BaseTransportMetadata {
|
|
|
38
38
|
* inference, which keys off `interfaceId`.
|
|
39
39
|
*/
|
|
40
40
|
clientOs?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Id of the app the client currently has open on screen (the app viewer or
|
|
43
|
+
* the app-editing split). Rendered as the `visible_app:` line in the per-turn
|
|
44
|
+
* context so the assistant knows which app "the app" refers to without the
|
|
45
|
+
* user naming it. View state only: it never affects transport or tool
|
|
46
|
+
* gating, and is absent whenever no app is in view.
|
|
47
|
+
*/
|
|
48
|
+
visibleAppId?: string;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
/**
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
SurfaceDataByType,
|
|
22
22
|
SurfaceType,
|
|
23
23
|
TableSurfaceData,
|
|
24
|
+
VisualSurfaceData,
|
|
24
25
|
WorkResultSurfaceData,
|
|
25
26
|
} from "../../api/surfaces.js";
|
|
26
27
|
|
|
@@ -52,6 +53,7 @@ export {
|
|
|
52
53
|
type TableColumn,
|
|
53
54
|
type TableRow,
|
|
54
55
|
TableSurfaceDataSchema,
|
|
56
|
+
VisualSurfaceDataSchema,
|
|
55
57
|
type WorkResultDiff,
|
|
56
58
|
type WorkResultItem,
|
|
57
59
|
type WorkResultMetadata,
|
|
@@ -78,6 +80,7 @@ export type {
|
|
|
78
80
|
SurfaceDataByType,
|
|
79
81
|
SurfaceType,
|
|
80
82
|
TableSurfaceData,
|
|
83
|
+
VisualSurfaceData,
|
|
81
84
|
WorkResultSurfaceData,
|
|
82
85
|
};
|
|
83
86
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* without going through the HTTP layer.
|
|
7
7
|
*/
|
|
8
8
|
import { rawAll, rawGet, rawRun } from "../persistence/raw-query.js";
|
|
9
|
+
import { writeWorkspaceFile } from "../runtime/routes/workspace-utils.js";
|
|
9
10
|
import { getLogger } from "../util/logger.js";
|
|
10
11
|
|
|
11
12
|
const log = getLogger("document-store");
|
|
@@ -23,6 +24,22 @@ export interface DocumentRecord {
|
|
|
23
24
|
wordCount: number;
|
|
24
25
|
createdAt: number;
|
|
25
26
|
updatedAt: number;
|
|
27
|
+
/**
|
|
28
|
+
* Workspace-relative path of the markdown file this document is bound to,
|
|
29
|
+
* or `null` when the document has no file behind it.
|
|
30
|
+
*/
|
|
31
|
+
workspacePath: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The listing projection: neither the body nor the file binding, which the
|
|
36
|
+
* list and search queries deliberately leave out of their SELECTs.
|
|
37
|
+
*/
|
|
38
|
+
export type DocumentSummary = Omit<DocumentRecord, "content" | "workspacePath">;
|
|
39
|
+
|
|
40
|
+
/** Words in a markdown body, the count the `word_count` column stores. */
|
|
41
|
+
function countWords(content: string): number {
|
|
42
|
+
return content.split(/\s+/).filter((word) => word.length > 0).length;
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
// ---------------------------------------------------------------------------
|
|
@@ -55,9 +72,10 @@ interface DocumentRow {
|
|
|
55
72
|
word_count: number;
|
|
56
73
|
created_at: number;
|
|
57
74
|
updated_at: number;
|
|
75
|
+
workspace_path: string | null;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
|
-
type DocumentListRow = Omit<DocumentRow, "content">;
|
|
78
|
+
type DocumentListRow = Omit<DocumentRow, "content" | "workspace_path">;
|
|
61
79
|
|
|
62
80
|
function escapeSqlLikePattern(value: string): string {
|
|
63
81
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
@@ -72,6 +90,7 @@ function mapRowToRecord(row: DocumentRow): DocumentRecord {
|
|
|
72
90
|
wordCount: row.word_count,
|
|
73
91
|
createdAt: row.created_at,
|
|
74
92
|
updatedAt: row.updated_at,
|
|
93
|
+
workspacePath: row.workspace_path,
|
|
75
94
|
};
|
|
76
95
|
}
|
|
77
96
|
|
|
@@ -80,7 +99,7 @@ export function getDocumentById(surfaceId: string): DocumentRecord | null {
|
|
|
80
99
|
try {
|
|
81
100
|
const row = rawGet<DocumentRow>(
|
|
82
101
|
"documents:getDocumentById",
|
|
83
|
-
/*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at
|
|
102
|
+
/*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path
|
|
84
103
|
FROM documents
|
|
85
104
|
WHERE surface_id = ?`,
|
|
86
105
|
surfaceId,
|
|
@@ -99,6 +118,29 @@ export function getDocumentById(surfaceId: string): DocumentRecord | null {
|
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Look up the document bound to a workspace file. `workspacePath` must be the
|
|
123
|
+
* normalized workspace-relative path stored on the row. Returns `null` when no
|
|
124
|
+
* document is bound to that file.
|
|
125
|
+
*/
|
|
126
|
+
export function getDocumentByWorkspacePath(
|
|
127
|
+
workspacePath: string,
|
|
128
|
+
): DocumentRecord | null {
|
|
129
|
+
try {
|
|
130
|
+
const row = rawGet<DocumentRow>(
|
|
131
|
+
"documents:getDocumentByWorkspacePath",
|
|
132
|
+
/*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path
|
|
133
|
+
FROM documents
|
|
134
|
+
WHERE workspace_path = ?`,
|
|
135
|
+
workspacePath,
|
|
136
|
+
);
|
|
137
|
+
return row ? mapRowToRecord(row) : null;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
log.error({ err: error, workspacePath }, "Load-by-workspace-path error");
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
102
144
|
/** Return true when a document is associated with a conversation. */
|
|
103
145
|
export function isDocumentAssociatedWithConversation(
|
|
104
146
|
surfaceId: string,
|
|
@@ -132,7 +174,7 @@ export function isDocumentAssociatedWithConversation(
|
|
|
132
174
|
*/
|
|
133
175
|
export function getDocumentsForConversation(
|
|
134
176
|
conversationId: string,
|
|
135
|
-
):
|
|
177
|
+
): DocumentSummary[] {
|
|
136
178
|
try {
|
|
137
179
|
const rows = rawAll<DocumentListRow>(
|
|
138
180
|
"documents:getDocumentsForConversation",
|
|
@@ -174,7 +216,7 @@ export function getDocumentsForConversation(
|
|
|
174
216
|
export function searchDocumentsByTitle(
|
|
175
217
|
query: string,
|
|
176
218
|
options: { conversationId?: string } = {},
|
|
177
|
-
):
|
|
219
|
+
): DocumentSummary[] {
|
|
178
220
|
try {
|
|
179
221
|
const pattern = `%${escapeSqlLikePattern(query)}%`;
|
|
180
222
|
const rows = options.conversationId
|
|
@@ -387,6 +429,202 @@ export function findInDocument(
|
|
|
387
429
|
}
|
|
388
430
|
}
|
|
389
431
|
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Workspace file binding
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Mirror `content` onto the workspace file a document is bound to.
|
|
438
|
+
*
|
|
439
|
+
* This is the single write-through choke point: every path that persists
|
|
440
|
+
* document content — {@link saveDocument} (client save and `document_create`),
|
|
441
|
+
* {@link updateDocumentContent} (`document_update`), and
|
|
442
|
+
* {@link replaceInDocument} (`document_replace_text`) — calls it immediately
|
|
443
|
+
* before its own row write, so a file-backed document's markdown on disk always
|
|
444
|
+
* matches the stored row.
|
|
445
|
+
*
|
|
446
|
+
* The file is written first and a failure throws: a rejected write aborts the
|
|
447
|
+
* update before the row changes rather than leaving the two silently diverged.
|
|
448
|
+
* Documents with no file behind them are a no-op.
|
|
449
|
+
*/
|
|
450
|
+
function writeThroughToWorkspaceFile(surfaceId: string, content: string): void {
|
|
451
|
+
const row = rawGet<{ workspace_path: string | null }>(
|
|
452
|
+
"documents:writeThroughToWorkspaceFile:getPath",
|
|
453
|
+
/*sql*/ `SELECT workspace_path FROM documents WHERE surface_id = ?`,
|
|
454
|
+
surfaceId,
|
|
455
|
+
);
|
|
456
|
+
const workspacePath = row?.workspace_path;
|
|
457
|
+
if (!workspacePath) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
writeWorkspaceFile(workspacePath, Buffer.from(content, "utf-8"));
|
|
463
|
+
log.info({ surfaceId, workspacePath }, "Wrote document through to file");
|
|
464
|
+
} catch (error) {
|
|
465
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Failed to write file-backed document to ${workspacePath}: ${message}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Create a document seeded from a workspace markdown file and bind it to that
|
|
474
|
+
* file. The returned surface ID is the file's document identity — comments,
|
|
475
|
+
* assistant iteration, and PDF export all key off it.
|
|
476
|
+
*
|
|
477
|
+
* The partial unique index on `workspace_path` makes a concurrent second
|
|
478
|
+
* create fail rather than produce a duplicate binding; callers re-read by path
|
|
479
|
+
* on failure.
|
|
480
|
+
*/
|
|
481
|
+
export function createFileBackedDocument(params: {
|
|
482
|
+
surfaceId: string;
|
|
483
|
+
conversationId: string;
|
|
484
|
+
title: string;
|
|
485
|
+
content: string;
|
|
486
|
+
workspacePath: string;
|
|
487
|
+
}): { success: true } | { success: false; error: string } {
|
|
488
|
+
try {
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
rawRun(
|
|
491
|
+
"documents:createFileBackedDocument",
|
|
492
|
+
/*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, workspace_path)
|
|
493
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
494
|
+
params.surfaceId,
|
|
495
|
+
params.conversationId,
|
|
496
|
+
params.title,
|
|
497
|
+
params.content,
|
|
498
|
+
countWords(params.content),
|
|
499
|
+
now,
|
|
500
|
+
now,
|
|
501
|
+
params.workspacePath,
|
|
502
|
+
);
|
|
503
|
+
addDocumentConversation(params.surfaceId, params.conversationId);
|
|
504
|
+
log.info(
|
|
505
|
+
{ surfaceId: params.surfaceId, workspacePath: params.workspacePath },
|
|
506
|
+
"Created file-backed document",
|
|
507
|
+
);
|
|
508
|
+
return { success: true };
|
|
509
|
+
} catch (error) {
|
|
510
|
+
log.error(
|
|
511
|
+
{ err: error, workspacePath: params.workspacePath },
|
|
512
|
+
"File-backed document create error",
|
|
513
|
+
);
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Move file-backed documents onto the path a workspace rename moved their file
|
|
523
|
+
* to. `oldPath` and `newPath` are canonical workspace-relative paths, the same
|
|
524
|
+
* form {@link createFileBackedDocument} stores.
|
|
525
|
+
*
|
|
526
|
+
* A document bound to `oldPath` itself moves, and so does every document nested
|
|
527
|
+
* under it (`oldPath + "/"` prefix), which is what a directory rename produces.
|
|
528
|
+
*
|
|
529
|
+
* The rename on disk has already happened by the time this runs, so it is best
|
|
530
|
+
* effort and never throws: the caller has no way to undo the rename, and a
|
|
531
|
+
* document left on a stale path is recoverable while a failed rename response
|
|
532
|
+
* is not. Rows move one statement at a time so a single row that cannot move
|
|
533
|
+
* does not strand its siblings.
|
|
534
|
+
*
|
|
535
|
+
* A row whose destination is already taken by another binding is one such case:
|
|
536
|
+
* the partial unique index on `workspace_path` rejects the update, and the
|
|
537
|
+
* losing row keeps pointing at the old path. That is deliberate. Overwriting
|
|
538
|
+
* the winner would drop a binding that a client may have open, whereas the
|
|
539
|
+
* stale binding self-heals: opening the file refreshes the row's content from
|
|
540
|
+
* disk, and a document whose file is gone is reported as gone rather than
|
|
541
|
+
* rewritten from the row.
|
|
542
|
+
*/
|
|
543
|
+
export function rebindDocumentsToRenamedPath(params: {
|
|
544
|
+
oldPath: string;
|
|
545
|
+
newPath: string;
|
|
546
|
+
}): void {
|
|
547
|
+
const { oldPath, newPath } = params;
|
|
548
|
+
if (!oldPath || !newPath || oldPath === newPath) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const rows = rawAll<{ surface_id: string; workspace_path: string }>(
|
|
554
|
+
"documents:rebindRenamedPath:select",
|
|
555
|
+
/*sql*/ `SELECT surface_id, workspace_path
|
|
556
|
+
FROM documents
|
|
557
|
+
WHERE workspace_path = ? OR workspace_path LIKE ? ESCAPE '\\'`,
|
|
558
|
+
oldPath,
|
|
559
|
+
`${escapeSqlLikePattern(oldPath)}/%`,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
for (const row of rows) {
|
|
563
|
+
const rebound =
|
|
564
|
+
row.workspace_path === oldPath
|
|
565
|
+
? newPath
|
|
566
|
+
: newPath + row.workspace_path.slice(oldPath.length);
|
|
567
|
+
try {
|
|
568
|
+
rawRun(
|
|
569
|
+
"documents:rebindRenamedPath:update",
|
|
570
|
+
/*sql*/ `UPDATE documents SET workspace_path = ? WHERE surface_id = ?`,
|
|
571
|
+
rebound,
|
|
572
|
+
row.surface_id,
|
|
573
|
+
);
|
|
574
|
+
log.info(
|
|
575
|
+
{
|
|
576
|
+
surfaceId: row.surface_id,
|
|
577
|
+
from: row.workspace_path,
|
|
578
|
+
to: rebound,
|
|
579
|
+
},
|
|
580
|
+
"Rebound file-backed document to renamed path",
|
|
581
|
+
);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
log.warn(
|
|
584
|
+
{
|
|
585
|
+
err: error,
|
|
586
|
+
surfaceId: row.surface_id,
|
|
587
|
+
from: row.workspace_path,
|
|
588
|
+
to: rebound,
|
|
589
|
+
},
|
|
590
|
+
"Left file-backed document on its old path after a rename",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
log.error({ err: error, oldPath, newPath }, "Path rebind error");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Replace a document's stored content with the current text of the file it is
|
|
601
|
+
* bound to. The file is the source of truth when a document is opened, so this
|
|
602
|
+
* deliberately skips the write-through step — disk and row already agree.
|
|
603
|
+
*/
|
|
604
|
+
export function refreshDocumentContentFromFile(
|
|
605
|
+
surfaceId: string,
|
|
606
|
+
content: string,
|
|
607
|
+
): { success: true } | { success: false; error: string } {
|
|
608
|
+
try {
|
|
609
|
+
rawRun(
|
|
610
|
+
"documents:refreshDocumentContentFromFile",
|
|
611
|
+
/*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
|
|
612
|
+
content,
|
|
613
|
+
countWords(content),
|
|
614
|
+
Date.now(),
|
|
615
|
+
surfaceId,
|
|
616
|
+
);
|
|
617
|
+
log.info({ surfaceId }, "Refreshed document content from file");
|
|
618
|
+
return { success: true };
|
|
619
|
+
} catch (error) {
|
|
620
|
+
log.error({ err: error, surfaceId }, "Document refresh error");
|
|
621
|
+
return {
|
|
622
|
+
success: false,
|
|
623
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
390
628
|
// ---------------------------------------------------------------------------
|
|
391
629
|
// Document persistence
|
|
392
630
|
// ---------------------------------------------------------------------------
|
|
@@ -399,6 +637,7 @@ export function saveDocument(params: {
|
|
|
399
637
|
wordCount: number;
|
|
400
638
|
}): { success: true; surfaceId: string } | { success: false; error: string } {
|
|
401
639
|
try {
|
|
640
|
+
writeThroughToWorkspaceFile(params.surfaceId, params.content);
|
|
402
641
|
const now = Date.now();
|
|
403
642
|
rawRun(
|
|
404
643
|
"documents:saveDocument",
|
|
@@ -534,9 +773,8 @@ export function replaceInDocument(
|
|
|
534
773
|
replacementsMade = totalMatches;
|
|
535
774
|
}
|
|
536
775
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
.filter((w) => w.length > 0).length;
|
|
776
|
+
writeThroughToWorkspaceFile(surfaceId, newContent);
|
|
777
|
+
const wordCount = countWords(newContent);
|
|
540
778
|
rawRun(
|
|
541
779
|
"documents:replaceInDocument:update",
|
|
542
780
|
/*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
|
|
@@ -579,9 +817,8 @@ export function updateDocumentContent(
|
|
|
579
817
|
const sep = mode === "append" && existing.content.length > 0 ? "\n\n" : "";
|
|
580
818
|
const newContent =
|
|
581
819
|
mode === "append" ? existing.content + sep + markdown : markdown;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
.filter((w) => w.length > 0).length;
|
|
820
|
+
writeThroughToWorkspaceFile(surfaceId, newContent);
|
|
821
|
+
const wordCount = countWords(newContent);
|
|
585
822
|
rawRun(
|
|
586
823
|
"documents:updateDocumentContent:update",
|
|
587
824
|
/*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
|
|
@@ -257,14 +257,49 @@ describe("parseFeedFile", () => {
|
|
|
257
257
|
).toThrow();
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
test("throws when
|
|
261
|
-
expect(() =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
260
|
+
test("throws when `items` is missing entirely", () => {
|
|
261
|
+
expect(() => parseFeedFile({ version: 2, updatedAt: NOW_ISO })).toThrow();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("throws when `updatedAt` is missing", () => {
|
|
265
|
+
expect(() => parseFeedFile({ version: 2, items: [] })).toThrow();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("drops an invalid item but keeps the valid ones", () => {
|
|
269
|
+
const parsed = parseFeedFile({
|
|
270
|
+
version: 2,
|
|
271
|
+
items: [
|
|
272
|
+
minimalNotification(),
|
|
273
|
+
{ ...minimalNotification(), id: "notif-bad", priority: 999 },
|
|
274
|
+
],
|
|
275
|
+
updatedAt: NOW_ISO,
|
|
276
|
+
});
|
|
277
|
+
expect(parsed.items).toHaveLength(1);
|
|
278
|
+
expect(parsed.items[0]?.id).toBe("notif-1");
|
|
279
|
+
expect(parsed.droppedCount).toBe(1);
|
|
280
|
+
expect(parsed.updatedAt).toBe(NOW_ISO);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("reports droppedCount 0 for a fully valid file", () => {
|
|
284
|
+
const parsed = parseFeedFile({
|
|
285
|
+
version: 2,
|
|
286
|
+
items: [minimalNotification(), notificationWithActions()],
|
|
287
|
+
updatedAt: NOW_ISO,
|
|
288
|
+
});
|
|
289
|
+
expect(parsed.items).toHaveLength(2);
|
|
290
|
+
expect(parsed.droppedCount).toBe(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("returns an empty item list when every item is invalid", () => {
|
|
294
|
+
const parsed = parseFeedFile({
|
|
295
|
+
version: 2,
|
|
296
|
+
items: [{ ...minimalNotification(), type: "banner" }, null, "nope"],
|
|
297
|
+
updatedAt: NOW_ISO,
|
|
298
|
+
});
|
|
299
|
+
expect(parsed.items).toEqual([]);
|
|
300
|
+
expect(parsed.droppedCount).toBe(3);
|
|
301
|
+
expect(parsed.version).toBe(2);
|
|
302
|
+
expect(parsed.updatedAt).toBe(NOW_ISO);
|
|
268
303
|
});
|
|
269
304
|
|
|
270
305
|
test("accepts a file with a noteworthy item", () => {
|
|
@@ -50,6 +50,7 @@ const {
|
|
|
50
50
|
bulkSetFeedItemStatus,
|
|
51
51
|
clearAllConversationIds,
|
|
52
52
|
getHomeFeedPath,
|
|
53
|
+
patchFeedItemContent,
|
|
53
54
|
patchFeedItemStatus,
|
|
54
55
|
readHomeFeed,
|
|
55
56
|
stripConversationIds,
|
|
@@ -199,6 +200,23 @@ describe("feed-writer", () => {
|
|
|
199
200
|
const feed = readHomeFeed();
|
|
200
201
|
expect(feed.items).toEqual([]);
|
|
201
202
|
});
|
|
203
|
+
|
|
204
|
+
test("keeps the valid items when a single row is malformed", () => {
|
|
205
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
206
|
+
const file = {
|
|
207
|
+
version: 2,
|
|
208
|
+
updatedAt: "2026-04-14T12:00:00.000Z",
|
|
209
|
+
items: [
|
|
210
|
+
makeItem({ id: "good" }),
|
|
211
|
+
{ ...makeItem({ id: "bad" }), priority: 999 },
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
writeFileSync(getHomeFeedPath(), JSON.stringify(file, null, 2), "utf-8");
|
|
215
|
+
|
|
216
|
+
const feed = readHomeFeed();
|
|
217
|
+
expect(feed.items).toHaveLength(1);
|
|
218
|
+
expect(feed.items[0]!.id).toBe("good");
|
|
219
|
+
});
|
|
202
220
|
});
|
|
203
221
|
|
|
204
222
|
describe("appendFeedItem", () => {
|
|
@@ -406,6 +424,47 @@ describe("feed-writer", () => {
|
|
|
406
424
|
});
|
|
407
425
|
});
|
|
408
426
|
|
|
427
|
+
describe("patchFeedItemContent", () => {
|
|
428
|
+
test("trims and applies a non-empty title", async () => {
|
|
429
|
+
await appendFeedItem(makeItem({ id: "item-1", title: "Original" }));
|
|
430
|
+
|
|
431
|
+
const result = await patchFeedItemContent("item-1", {
|
|
432
|
+
title: " Renamed ",
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(result!.title).toBe("Renamed");
|
|
436
|
+
expect(readFileJson().items[0]!.title).toBe("Renamed");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("ignores an empty or whitespace-only title instead of clearing it", async () => {
|
|
440
|
+
await appendFeedItem(makeItem({ id: "item-1", title: "Original" }));
|
|
441
|
+
|
|
442
|
+
const empty = await patchFeedItemContent("item-1", { title: "" });
|
|
443
|
+
expect(empty!.title).toBe("Original");
|
|
444
|
+
|
|
445
|
+
const blank = await patchFeedItemContent("item-1", { title: " \t" });
|
|
446
|
+
expect(blank!.title).toBe("Original");
|
|
447
|
+
|
|
448
|
+
expect(readFileJson().items[0]!.title).toBe("Original");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a summary-only patch leaves the title untouched", async () => {
|
|
452
|
+
await appendFeedItem(makeItem({ id: "item-1", title: "Original" }));
|
|
453
|
+
|
|
454
|
+
const result = await patchFeedItemContent("item-1", {
|
|
455
|
+
summary: "Fresh summary",
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(result!.title).toBe("Original");
|
|
459
|
+
expect(result!.summary).toBe("Fresh summary");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("returns null for an unknown id", async () => {
|
|
463
|
+
await appendFeedItem(makeItem({ id: "known" }));
|
|
464
|
+
expect(await patchFeedItemContent("unknown", { title: "x" })).toBeNull();
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
409
468
|
describe("concurrency", () => {
|
|
410
469
|
test("10 concurrent appends with distinct ids all land", async () => {
|
|
411
470
|
const items = Array.from({ length: 10 }, (_, i) =>
|
package/src/home/feed-types.ts
CHANGED
|
@@ -55,10 +55,13 @@ export interface HomeFeedFile {
|
|
|
55
55
|
updatedAt: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Envelope schema for the on-disk `home-feed.json` file. Items stay
|
|
60
|
+
* unvalidated here so `parseFeedFile` can validate them one at a time.
|
|
61
|
+
*/
|
|
62
|
+
const homeFeedEnvelopeSchema = z.object({
|
|
60
63
|
version: z.literal(2),
|
|
61
|
-
items: z.array(
|
|
64
|
+
items: z.array(z.unknown()),
|
|
62
65
|
updatedAt: z.string(),
|
|
63
66
|
});
|
|
64
67
|
|
|
@@ -66,9 +69,33 @@ const homeFeedFileSchema = z.object({
|
|
|
66
69
|
* Parse and validate a raw value read from `home-feed.json`.
|
|
67
70
|
*
|
|
68
71
|
* Used by the writer on read-back and by the HTTP route when serving the
|
|
69
|
-
* feed. Throws a `ZodError`
|
|
70
|
-
*
|
|
72
|
+
* feed. Throws a `ZodError` when the envelope itself is invalid (wrong
|
|
73
|
+
* `version`, non-object input, missing `updatedAt`). Callers are
|
|
74
|
+
* expected to log + recover (e.g. treat the file as empty). Individual
|
|
75
|
+
* items that fail validation are dropped and counted in `droppedCount`,
|
|
76
|
+
* an in-memory-only field on the return value that is never persisted,
|
|
77
|
+
* so one malformed row cannot erase the rest of the feed.
|
|
71
78
|
*/
|
|
72
|
-
export function parseFeedFile(
|
|
73
|
-
|
|
79
|
+
export function parseFeedFile(
|
|
80
|
+
raw: unknown,
|
|
81
|
+
): HomeFeedFile & { droppedCount: number } {
|
|
82
|
+
const envelope = homeFeedEnvelopeSchema.parse(raw);
|
|
83
|
+
|
|
84
|
+
const items: z.infer<typeof FeedItemSchema>[] = [];
|
|
85
|
+
let droppedCount = 0;
|
|
86
|
+
for (const rawItem of envelope.items) {
|
|
87
|
+
const result = FeedItemSchema.safeParse(rawItem);
|
|
88
|
+
if (result.success) {
|
|
89
|
+
items.push(result.data);
|
|
90
|
+
} else {
|
|
91
|
+
droppedCount++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
version: envelope.version,
|
|
97
|
+
items,
|
|
98
|
+
updatedAt: envelope.updatedAt,
|
|
99
|
+
droppedCount,
|
|
100
|
+
};
|
|
74
101
|
}
|
package/src/home/feed-writer.ts
CHANGED
|
@@ -68,7 +68,9 @@ export function getHomeFeedPath(): string {
|
|
|
68
68
|
* Read the on-disk feed file, applying the stateless TTL filter.
|
|
69
69
|
*
|
|
70
70
|
* Returns an empty `HomeFeedFile` when the file is missing, unreadable,
|
|
71
|
-
* or
|
|
71
|
+
* or has an invalid envelope. Callers never see a throw from this path.
|
|
72
|
+
* Individual items that fail validation are dropped and warn-logged
|
|
73
|
+
* while the rest of the feed survives.
|
|
72
74
|
* Items whose `expiresAt` is in the past are dropped from the returned
|
|
73
75
|
* `items` array but are NOT rewritten to disk; the next append cycle
|
|
74
76
|
* will persist the post-filter view naturally.
|
|
@@ -93,7 +95,7 @@ export function readHomeFeed(): HomeFeedFile {
|
|
|
93
95
|
return empty;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
let parsed:
|
|
98
|
+
let parsed: ReturnType<typeof parseFeedFile>;
|
|
97
99
|
try {
|
|
98
100
|
parsed = parseFeedFile(raw);
|
|
99
101
|
} catch (err) {
|
|
@@ -104,6 +106,13 @@ export function readHomeFeed(): HomeFeedFile {
|
|
|
104
106
|
return empty;
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
if (parsed.droppedCount > 0) {
|
|
110
|
+
log.warn(
|
|
111
|
+
{ path, droppedCount: parsed.droppedCount },
|
|
112
|
+
"Dropped invalid items from home-feed.json",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
const now = Date.now();
|
|
108
117
|
const items = parsed.items.filter((item) => !isExpired(item, now));
|
|
109
118
|
return {
|
|
@@ -165,7 +174,8 @@ export async function patchFeedItemStatus(
|
|
|
165
174
|
*
|
|
166
175
|
* Only fields explicitly present on `patch` are touched. Pass an empty
|
|
167
176
|
* object and the call is a no-op that returns the existing item (or
|
|
168
|
-
* `null` if the id isn't on disk).
|
|
177
|
+
* `null` if the id isn't on disk). A `title` that trims to empty is
|
|
178
|
+
* ignored, so no edit path can strip a title off an existing item.
|
|
169
179
|
*/
|
|
170
180
|
export interface FeedItemContentPatch {
|
|
171
181
|
title?: string;
|
|
@@ -363,9 +373,8 @@ async function runWrite(): Promise<void> {
|
|
|
363
373
|
const updated: FeedItem = { ...existing };
|
|
364
374
|
if (patch.title !== undefined) {
|
|
365
375
|
const trimmed = patch.title.trim();
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
} else {
|
|
376
|
+
// Blank titles are ignored: an item keeps its title once it has one.
|
|
377
|
+
if (trimmed.length > 0) {
|
|
369
378
|
updated.title = trimmed;
|
|
370
379
|
}
|
|
371
380
|
}
|