From 21f8650fe3d4e6aa42c966887e4196b3d6caed19 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Wed, 6 May 2026 20:05:30 -0400 Subject: [PATCH 1/8] Add local action execution substrate --- .gitignore | 6 + .npmrc | 1 + .../Sources/CuedNative/AppRuntime.swift | 2 +- package.json | 4 + scripts/build-cued-daemon-app.sh | 4 +- scripts/smoke-actions-local.ts | 212 ++++ scripts/smoke-actions-personal.ts | 269 ++++++ scripts/smoke-actions-plugin-local.ts | 200 ++++ scripts/smoke-actions-sandbox.ts | 217 +++++ scripts/validate-cued-release-artifact.sh | 31 +- skills/cued/SKILL.md | 291 ++++-- .../actions/contact-enrichment-recommend.cjs | 30 + .../actions/contact-followup-recommend.cjs | 29 + .../contact-introduction-recommend.cjs | 37 + skills/cued/actions/contact-memory-add.cjs | 26 + skills/cued/actions/contact-memory-stale.cjs | 20 + skills/cued/actions/contact-merge.cjs | 126 +++ skills/cued/actions/contact-message-draft.cjs | 30 + .../actions/contact.enrichment.recommend.json | 19 + .../actions/contact.followup.recommend.json | 18 + .../contact.introduction.recommend.json | 19 + skills/cued/actions/contact.memory.add.json | 19 + skills/cued/actions/contact.memory.stale.json | 13 + skills/cued/actions/contact.merge.json | 19 + .../cued/actions/contact.message.draft.json | 19 + .../conversation-followup-recommend.cjs | 29 + .../actions/conversation-summary-draft.cjs | 30 + .../conversation.followup.recommend.json | 18 + .../actions/conversation.summary.draft.json | 19 + skills/cued/evals/evals.json | 84 +- src/actions/contact-merge-effects.ts | 34 + src/actions/execution.ts | 139 +++ src/actions/executor-loader.ts | 33 + src/actions/registry.test.ts | 300 ++++++ src/actions/registry.ts | 328 +++++++ src/cli-actions.test.ts | 255 +++++ src/cli-contacts-memory.test.ts | 160 ++- src/cli.ts | 439 +++++++-- src/core/identity.ts | 7 - src/db/database.ts | 795 ++++++++++----- src/db/schema-bootstrap.ts | 914 ++++++++++++++++++ src/db/schema.ts | 88 +- src/macos/install.test.ts | 90 +- src/macos/install.ts | 104 +- src/platforms/core/auth/service.test.ts | 2 +- src/platforms/core/invocation.test.ts | 16 +- src/platforms/core/invocation.ts | 44 +- src/platforms/core/secrets/keychain.ts | 6 +- .../core/state/integration-state.test.ts | 108 +-- .../slack-desktop-import-removal.test.ts | 2 +- src/platforms/core/types.ts | 28 + src/platforms/discord/sync/bundle.ts | 14 +- src/platforms/discord/sync/worker.ts | 2 +- src/platforms/gmail/oauth/client.ts | 2 +- src/platforms/gmail/sync/worker.ts | 2 +- src/platforms/imessage/worker.ts | 3 +- src/platforms/linkedin/sync/bundle.ts | 10 +- src/platforms/linkedin/sync/worker.ts | 7 +- src/platforms/signal/cli/client.test.ts | 11 +- src/platforms/signal/sync/bundle.test.ts | 2 +- src/platforms/signal/sync/bundle.ts | 12 +- src/platforms/signal/sync/worker.ts | 7 +- src/platforms/slack/e2e.test.ts | 10 +- src/platforms/slack/sync/bundle.test.ts | 2 +- src/platforms/slack/sync/worker.ts | 6 +- src/platforms/whatsapp/diagnostics.test.ts | 2 +- src/runtime/attachments.test.ts | 2 +- src/runtime/daemon/server.test.ts | 183 ++++ src/runtime/daemon/server.ts | 155 ++- src/runtime/doctor-status.test.ts | 2 +- src/runtime/doctor.test.ts | 2 +- src/runtime/hooks.test.ts | 7 + src/runtime/ipc.ts | 38 +- src/runtime/onboarding.test.ts | 2 +- src/runtime/perf/run.ts | 8 +- src/runtime/projection/events.test.ts | 86 +- src/runtime/projection/events.ts | 216 +---- src/runtime/projection/projector.ts | 5 +- src/runtime/projection/replay.test.ts | 2 +- src/runtime/run-queue.ts | 61 +- src/runtime/updater/service.test.ts | 3 +- src/skills/install.test.ts | 98 ++ src/skills/install.ts | 133 ++- 83 files changed, 5571 insertions(+), 1257 deletions(-) create mode 100644 .npmrc create mode 100644 scripts/smoke-actions-local.ts create mode 100644 scripts/smoke-actions-personal.ts create mode 100644 scripts/smoke-actions-plugin-local.ts create mode 100644 scripts/smoke-actions-sandbox.ts create mode 100644 skills/cued/actions/contact-enrichment-recommend.cjs create mode 100644 skills/cued/actions/contact-followup-recommend.cjs create mode 100644 skills/cued/actions/contact-introduction-recommend.cjs create mode 100644 skills/cued/actions/contact-memory-add.cjs create mode 100644 skills/cued/actions/contact-memory-stale.cjs create mode 100644 skills/cued/actions/contact-merge.cjs create mode 100644 skills/cued/actions/contact-message-draft.cjs create mode 100644 skills/cued/actions/contact.enrichment.recommend.json create mode 100644 skills/cued/actions/contact.followup.recommend.json create mode 100644 skills/cued/actions/contact.introduction.recommend.json create mode 100644 skills/cued/actions/contact.memory.add.json create mode 100644 skills/cued/actions/contact.memory.stale.json create mode 100644 skills/cued/actions/contact.merge.json create mode 100644 skills/cued/actions/contact.message.draft.json create mode 100644 skills/cued/actions/conversation-followup-recommend.cjs create mode 100644 skills/cued/actions/conversation-summary-draft.cjs create mode 100644 skills/cued/actions/conversation.followup.recommend.json create mode 100644 skills/cued/actions/conversation.summary.draft.json create mode 100644 src/actions/contact-merge-effects.ts create mode 100644 src/actions/execution.ts create mode 100644 src/actions/executor-loader.ts create mode 100644 src/actions/registry.test.ts create mode 100644 src/actions/registry.ts create mode 100644 src/cli-actions.test.ts create mode 100644 src/db/schema-bootstrap.ts diff --git a/.gitignore b/.gitignore index 13e02c33..3d745a08 100644 --- a/.gitignore +++ b/.gitignore @@ -77,13 +77,19 @@ Temporary Items # Environment and secrets .env +**/.env .env.local +**/.env.local .env.*.local +**/.env.*.local *.pem *.key credentials.json # Testing and coverage +__pycache__/ +**/__pycache__/ +*.py[cod] **/coverage/ **/test-results/ **/playwright-report/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..eccf7d8c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +inject-workspace-packages=true diff --git a/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift b/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift index 03cf3003..65734bce 100644 --- a/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift +++ b/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift @@ -1748,7 +1748,7 @@ final class MenuBarAppController: NSObject, NSApplicationDelegate { alert.alertStyle = .informational alert.messageText = "Update Available: v\(targetVersion)" alert.informativeText = - "Current version: v\(status.currentVersion)\nInstalling the update will restart Cued and migrate the local database if needed." + "Current version: v\(status.currentVersion)\nInstalling the update will restart Cued and initialize the local database schema if needed." alert.addButton(withTitle: "Install and Restart") alert.addButton(withTitle: "Later") if status.releaseUrl != nil { diff --git a/package.json b/package.json index 25e5ead2..ae5e1aa6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "check:ci-local": "sh scripts/check-ci-local.sh", "monitor:upstreams": "node scripts/monitor-upstreams.mjs", "smoke:auth-lifecycle": "tsx scripts/smoke-auth-lifecycle.ts", + "smoke:actions-local": "tsx scripts/smoke-actions-local.ts", + "smoke:actions-personal": "tsx scripts/smoke-actions-personal.ts", + "smoke:actions-sandbox": "tsx scripts/smoke-actions-sandbox.ts", + "smoke:actions-plugin-local": "tsx scripts/smoke-actions-plugin-local.ts", "bootstrap:signal:macos": "bash scripts/fetch-signal-cli-macos.sh", "check:native:macos": "swift build --package-path native/macos/CuedNative -c release", "check:biome": "biome check .", diff --git a/scripts/build-cued-daemon-app.sh b/scripts/build-cued-daemon-app.sh index ee8e6636..b867405a 100644 --- a/scripts/build-cued-daemon-app.sh +++ b/scripts/build-cued-daemon-app.sh @@ -240,7 +240,7 @@ mkdir -p "$(dirname "$SLACK_HELPER_SOURCE")" (cd "$ROOT_DIR/native/helpers/slack-go" && GOWORK=off go build -o "$SLACK_HELPER_SOURCE" .) >/dev/null mkdir -p "$(dirname "$WHATSAPP_HELPER_SOURCE")" (cd "$ROOT_DIR/native/helpers/whatsapp-go" && GOWORK=off go build -o "$WHATSAPP_HELPER_SOURCE" .) >/dev/null -npm_config_ignore_scripts=true pnpm --dir "$ROOT_DIR" --filter . deploy --legacy --prod "$DEPLOY_STAGING_DIR" >/dev/null +npm_config_ignore_scripts=true pnpm --dir "$ROOT_DIR" --filter . deploy --prod "$DEPLOY_STAGING_DIR" >/dev/null copy_better_sqlite3_binary rm -rf "$APP_BUNDLE" @@ -285,7 +285,7 @@ cp "$BETTER_SQLITE3_BINDING_SOURCE" "$BETTER_SQLITE3_RUNTIME_DIR/build/Release/b # Remove symlinks that escape the bundled runtime or no longer resolve after deploy. "$NODE_PATH" "$RUNTIME_SYMLINK_PRUNER" "$RUNTIME_DIR" >/dev/null -# `pnpm deploy --legacy --prod` can still leave a handful of dangling package links behind. +# `pnpm deploy --prod` can still leave a handful of dangling package links behind. find -L "$RUNTIME_DIR" -type l -exec rm -f {} + rm -rf "$RUNTIME_DIR/node_modules/cued" "$RUNTIME_DIR/node_modules/@cued/app" # The bundled CLI only needs compiled JS, package metadata, and production diff --git a/scripts/smoke-actions-local.ts b/scripts/smoke-actions-local.ts new file mode 100644 index 00000000..1b8ad28c --- /dev/null +++ b/scripts/smoke-actions-local.ts @@ -0,0 +1,212 @@ +import { listContactMergeAliases } from "../src/actions/contact-merge-effects.js"; +import { loadActionExecutor } from "../src/actions/executor-loader.js"; +import { ActionDefinitionRegistry } from "../src/actions/registry.js"; +import { openCuedDatabaseReadOnly } from "../src/db/database.js"; + +type ContactRow = { + id: string; + name: string | null; +}; + +type ConversationRow = { + id: string; + message_count: number; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +const registry = ActionDefinitionRegistry.load(); +const definitions = registry.list(); +assert(definitions.length > 0, "Expected at least one action definition."); +for (const definition of definitions) { + assert( + loadActionExecutor(definition), + `Missing executor for ${definition.type}@${definition.version}`, + ); +} + +const db = openCuedDatabaseReadOnly(); +try { + const contacts = db.executeReadOnlySql(` + SELECT id, name + FROM contacts + WHERE archived = 0 + ORDER BY updated_at DESC, created_at DESC + LIMIT 2 + `) as ContactRow[]; + + const memoryValidation = + contacts[0] != null + ? registry.validatePayload("contact.memory.add", "1", { + contactId: contacts[0].id, + body: "Local action smoke validation only. Do not write.", + sourceKind: "smoke", + }) + : null; + assert( + memoryValidation == null || memoryValidation.ok, + `contact.memory.add payload failed validation: ${memoryValidation?.errors.join("; ")}`, + ); + + const mergeValidation = + contacts.length >= 2 + ? registry.validatePayload("contact.merge", "1", { + primaryContactId: contacts[0]!.id, + secondaryContactId: contacts[1]!.id, + reason: "Local action smoke validation only. Do not write.", + }) + : null; + assert( + mergeValidation == null || mergeValidation.ok, + `contact.merge payload failed validation: ${mergeValidation?.errors.join("; ")}`, + ); + + const followupValidation = + contacts[0] != null + ? registry.validatePayload("contact.followup.recommend", "1", { + contactId: contacts[0].id, + reason: "Local action smoke validation only. Do not write.", + suggestedMessage: "Local action smoke validation only.", + evidence: { source: "smoke-actions-local" }, + }) + : null; + assert( + followupValidation == null || followupValidation.ok, + `contact.followup.recommend payload failed validation: ${followupValidation?.errors.join( + "; ", + )}`, + ); + + const enrichmentValidation = + contacts[0] != null + ? registry.validatePayload("contact.enrichment.recommend", "1", { + contactId: contacts[0].id, + field: "profile_url", + value: "Local action smoke validation only. Do not write.", + sourceKind: "smoke", + evidence: { source: "smoke-actions-local" }, + }) + : null; + assert( + enrichmentValidation == null || enrichmentValidation.ok, + `contact.enrichment.recommend payload failed validation: ${enrichmentValidation?.errors.join( + "; ", + )}`, + ); + + const introductionValidation = + contacts.length >= 2 + ? registry.validatePayload("contact.introduction.recommend", "1", { + fromContactId: contacts[0]!.id, + toContactId: contacts[1]!.id, + reason: "Local action smoke validation only. Do not write.", + suggestedIntro: "Local action smoke validation only.", + evidence: { source: "smoke-actions-local" }, + }) + : null; + assert( + introductionValidation == null || introductionValidation.ok, + `contact.introduction.recommend payload failed validation: ${introductionValidation?.errors.join( + "; ", + )}`, + ); + + const messageDraftValidation = + contacts[0] != null + ? registry.validatePayload("contact.message.draft", "1", { + contactId: contacts[0].id, + body: "Local action smoke validation only. Do not send.", + reason: "Local action smoke validation only. Do not write.", + channelHint: "smoke", + evidence: { source: "smoke-actions-local" }, + }) + : null; + assert( + messageDraftValidation == null || messageDraftValidation.ok, + `contact.message.draft payload failed validation: ${messageDraftValidation?.errors.join("; ")}`, + ); + + const aliases = listContactMergeAliases(db); + const conversations = db.executeReadOnlySql(` + SELECT c.id, COUNT(m.id) AS message_count + FROM conversations c + JOIN messages m ON m.conversation_id = c.id + WHERE c.is_active = 1 + AND m.is_deleted = 0 + GROUP BY c.id + ORDER BY MAX(m.sent_at) DESC + LIMIT 1 + `) as ConversationRow[]; + const summaryDraftValidation = + conversations[0] != null + ? registry.validatePayload("conversation.summary.draft", "1", { + conversationId: conversations[0].id, + summary: "Local action smoke validation only. Do not write.", + reason: "Local action smoke validation only. Do not write.", + timeWindow: "recent", + evidence: { + source: "smoke-actions-local", + messageCount: conversations[0].message_count, + }, + }) + : null; + assert( + summaryDraftValidation == null || summaryDraftValidation.ok, + `conversation.summary.draft payload failed validation: ${summaryDraftValidation?.errors.join( + "; ", + )}`, + ); + const conversationFollowupValidation = + conversations[0] != null + ? registry.validatePayload("conversation.followup.recommend", "1", { + conversationId: conversations[0].id, + reason: "Local action smoke validation only. Do not write.", + suggestedNextStep: "Local action smoke validation only.", + evidence: { + source: "smoke-actions-local", + messageCount: conversations[0].message_count, + }, + }) + : null; + assert( + conversationFollowupValidation == null || conversationFollowupValidation.ok, + `conversation.followup.recommend payload failed validation: ${conversationFollowupValidation?.errors.join( + "; ", + )}`, + ); + const recentActions = db.executeReadOnlySql(` + SELECT action_type, status, approval_status, execution_status, queued_at + FROM actions + ORDER BY queued_at DESC + LIMIT 5 + `); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + readonly: true, + definitions: definitions.map((definition) => ({ + type: definition.type, + version: definition.version, + module: definition.module, + sourcePath: definition.sourcePath, + rebuildProjection: definition.postExecution.rebuildProjection, + })), + sampledContactCount: contacts.length, + sampledContactsHaveNames: contacts.map((contact) => Boolean(contact.name)), + sampledConversationCount: conversations.length, + mergeAliasCount: aliases.length, + recentActionCount: Array.isArray(recentActions) ? recentActions.length : 0, + }, + null, + 2, + )}\n`, + ); +} finally { + db.close(); +} diff --git a/scripts/smoke-actions-personal.ts b/scripts/smoke-actions-personal.ts new file mode 100644 index 00000000..84a213de --- /dev/null +++ b/scripts/smoke-actions-personal.ts @@ -0,0 +1,269 @@ +import { loadActionExecutor } from "../src/actions/executor-loader.js"; +import { ActionDefinitionRegistry } from "../src/actions/registry.js"; +import { openCuedDatabaseReadOnly } from "../src/db/database.js"; + +type FollowupCandidateRow = { + contact_id: string; + has_name: number; + message_count: number; + last_inbound_at: number | null; + last_outbound_at: number | null; +}; + +type EnrichmentCandidateRow = { + contact_id: string; + field: string; + value: string; + source_kind: string; +}; + +type IntroductionCandidateRow = { + first_contact_id: string; + second_contact_id: string; + shared_conversation_count: number; +}; + +type ConversationSummaryCandidateRow = { + conversation_id: string; + message_count: number; + last_message_at: number; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +const registry = ActionDefinitionRegistry.load(); +const definitions = registry.list(); +for (const definition of definitions) { + assert( + loadActionExecutor(definition), + `Missing executor for ${definition.type}@${definition.version}`, + ); +} + +const db = openCuedDatabaseReadOnly(); +try { + const since = Date.now() - 180 * 24 * 60 * 60 * 1000; + const candidates = db.executeReadOnlySql( + ` + WITH contact_message_stats AS ( + SELECT + cp.contact_id, + COUNT(m.id) AS message_count, + MAX(CASE WHEN m.is_from_me = 0 THEN m.sent_at END) AS last_inbound_at, + MAX(CASE WHEN m.is_from_me = 1 THEN m.sent_at END) AS last_outbound_at + FROM conversation_participants cp + JOIN messages m ON m.conversation_id = cp.conversation_id + WHERE cp.is_self = 0 + AND cp.is_active = 1 + AND m.is_deleted = 0 + AND m.sent_at >= ${since} + GROUP BY cp.contact_id + ) + SELECT + c.id AS contact_id, + CASE WHEN c.name IS NULL OR c.name = '' THEN 0 ELSE 1 END AS has_name, + s.message_count, + s.last_inbound_at, + s.last_outbound_at + FROM contact_message_stats s + JOIN contacts c ON c.id = s.contact_id + WHERE c.archived = 0 + AND s.last_inbound_at IS NOT NULL + AND (s.last_outbound_at IS NULL OR s.last_inbound_at >= s.last_outbound_at) + ORDER BY s.last_inbound_at DESC + LIMIT 5 + `, + ) as FollowupCandidateRow[]; + + const followupResults = candidates.map((candidate) => + registry.validatePayload("contact.followup.recommend", "1", { + contactId: candidate.contact_id, + reason: "Recent inbound message has no newer outbound reply.", + suggestedMessage: "Following up on our last thread.", + dueAt: Date.now(), + evidence: { + source: "smoke-actions-personal", + messageCount: candidate.message_count, + lastInboundAt: candidate.last_inbound_at, + lastOutboundAt: candidate.last_outbound_at, + }, + }), + ); + for (const result of followupResults) { + assert(result.ok, `Invalid follow-up payload: ${result.errors.join("; ")}`); + } + const draftResults = candidates.map((candidate) => + registry.validatePayload("contact.message.draft", "1", { + contactId: candidate.contact_id, + body: "Following up on our last thread.", + reason: "Recent inbound message has no newer outbound reply.", + channelHint: "local", + evidence: { + source: "smoke-actions-personal", + messageCount: candidate.message_count, + lastInboundAt: candidate.last_inbound_at, + lastOutboundAt: candidate.last_outbound_at, + }, + confidence: 60, + }), + ); + for (const result of draftResults) { + assert(result.ok, `Invalid message draft payload: ${result.errors.join("; ")}`); + } + + const enrichmentCandidates = db.executeReadOnlySql(` + SELECT + c.id AS contact_id, + 'profile_url' AS field, + cs.profile_url AS value, + cs.platform AS source_kind + FROM contacts c + JOIN contact_sources cs ON cs.contact_id = c.id + WHERE c.archived = 0 + AND cs.profile_url IS NOT NULL + AND cs.profile_url != '' + ORDER BY cs.last_seen_at DESC + LIMIT 5 + `) as EnrichmentCandidateRow[]; + const enrichmentResults = enrichmentCandidates.map((candidate) => + registry.validatePayload("contact.enrichment.recommend", "1", { + contactId: candidate.contact_id, + field: candidate.field, + value: candidate.value, + sourceKind: candidate.source_kind, + evidence: { source: "smoke-actions-personal" }, + confidence: 80, + }), + ); + for (const result of enrichmentResults) { + assert(result.ok, `Invalid enrichment payload: ${result.errors.join("; ")}`); + } + + const introductionCandidates = db.executeReadOnlySql(` + WITH active_pairs AS ( + SELECT + cp1.contact_id AS first_contact_id, + cp2.contact_id AS second_contact_id, + COUNT(DISTINCT cp1.conversation_id) AS shared_conversation_count + FROM conversation_participants cp1 + JOIN conversation_participants cp2 + ON cp2.conversation_id = cp1.conversation_id + AND cp2.contact_id > cp1.contact_id + JOIN contacts c1 ON c1.id = cp1.contact_id + JOIN contacts c2 ON c2.id = cp2.contact_id + WHERE cp1.is_self = 0 + AND cp2.is_self = 0 + AND cp1.is_active = 1 + AND cp2.is_active = 1 + AND c1.archived = 0 + AND c2.archived = 0 + AND c1.name IS NOT NULL + AND c1.name != '' + AND c2.name IS NOT NULL + AND c2.name != '' + GROUP BY cp1.contact_id, cp2.contact_id + ORDER BY shared_conversation_count DESC + LIMIT 3 + ) + SELECT first_contact_id, second_contact_id, shared_conversation_count + FROM active_pairs + `) as IntroductionCandidateRow[]; + const introductionResults = introductionCandidates.map((candidate) => + registry.validatePayload("contact.introduction.recommend", "1", { + fromContactId: candidate.first_contact_id, + toContactId: candidate.second_contact_id, + reason: "They share conversation context in local Cued data.", + suggestedIntro: "You may want to connect these two people around the shared thread.", + evidence: { + source: "smoke-actions-personal", + sharedConversationCount: candidate.shared_conversation_count, + }, + confidence: 50, + }), + ); + for (const result of introductionResults) { + assert(result.ok, `Invalid introduction payload: ${result.errors.join("; ")}`); + } + + const summaryCandidates = db.executeReadOnlySql(` + SELECT + c.id AS conversation_id, + COUNT(m.id) AS message_count, + MAX(m.sent_at) AS last_message_at + FROM conversations c + JOIN messages m ON m.conversation_id = c.id + WHERE c.is_active = 1 + AND m.is_deleted = 0 + GROUP BY c.id + HAVING message_count >= 2 + ORDER BY last_message_at DESC + LIMIT 5 + `) as ConversationSummaryCandidateRow[]; + const summaryResults = summaryCandidates.map((candidate) => + registry.validatePayload("conversation.summary.draft", "1", { + conversationId: candidate.conversation_id, + summary: "Recent conversation summary draft placeholder.", + reason: "Recent active conversation found in local Cued data.", + timeWindow: "recent", + evidence: { + source: "smoke-actions-personal", + messageCount: candidate.message_count, + lastMessageAt: candidate.last_message_at, + }, + confidence: 50, + }), + ); + for (const result of summaryResults) { + assert(result.ok, `Invalid conversation summary payload: ${result.errors.join("; ")}`); + } + const conversationFollowupResults = summaryCandidates.map((candidate) => + registry.validatePayload("conversation.followup.recommend", "1", { + conversationId: candidate.conversation_id, + reason: "Recent active conversation may need a next step.", + suggestedNextStep: "Review this conversation for follow-up.", + evidence: { + source: "smoke-actions-personal", + messageCount: candidate.message_count, + lastMessageAt: candidate.last_message_at, + }, + confidence: 50, + }), + ); + for (const result of conversationFollowupResults) { + assert(result.ok, `Invalid conversation follow-up payload: ${result.errors.join("; ")}`); + } + + const recentActionRows = db.executeReadOnlySql(` + SELECT action_type, status, approval_status, execution_status + FROM actions + ORDER BY queued_at DESC + LIMIT 20 + `); + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + readonly: true, + loadedActionTypes: definitions.map((definition) => definition.type), + followupCandidateCount: candidates.length, + namedFollowupCandidateCount: candidates.filter((candidate) => candidate.has_name === 1) + .length, + messageDraftCandidateCount: candidates.length, + enrichmentCandidateCount: enrichmentCandidates.length, + introductionCandidateCount: introductionCandidates.length, + conversationSummaryCandidateCount: summaryCandidates.length, + conversationFollowupCandidateCount: summaryCandidates.length, + recentActionCount: Array.isArray(recentActionRows) ? recentActionRows.length : 0, + }, + null, + 2, + )}\n`, + ); +} finally { + db.close(); +} diff --git a/scripts/smoke-actions-plugin-local.ts b/scripts/smoke-actions-plugin-local.ts new file mode 100644 index 00000000..f3439fa3 --- /dev/null +++ b/scripts/smoke-actions-plugin-local.ts @@ -0,0 +1,200 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ActionDefinitionRegistry } from "../src/actions/registry.js"; +import { CuedDatabase, openCuedDatabaseReadOnly } from "../src/db/database.js"; +import { installLocalCuedSkill } from "../src/skills/install.js"; + +type ContactRow = { + id: string; + name: string | null; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function sqlite(db: CuedDatabase) { + return ( + db as unknown as { + sqlite: { + prepare: (sql: string) => { + run: (...params: unknown[]) => void; + }; + }; + } + ).sqlite; +} + +function insertContact(db: CuedDatabase, contact: ContactRow): void { + const timestamp = Date.now(); + sqlite(db) + .prepare( + ` + INSERT INTO contacts (id, kind, name, photo_url, company, archived, created_at, updated_at) + VALUES (?, 'person', ?, NULL, NULL, 0, ?, ?) + `, + ) + .run(contact.id, contact.name, timestamp, timestamp); +} + +function writeSmokeExecutor(actionsRoot: string, effectType: string): void { + writeFileSync( + join(actionsRoot, "smoke-contact-note.cjs"), + ` +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const contactId = helpers.requiredStringPayload(payload, "contactId", action); + if (!db.contactExists(contactId)) { + throw new Error(\`Contact not found: \${contactId}\`); + } + const note = { + contactId, + note: helpers.requiredStringPayload(payload, "note", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "${effectType}", + targetTable: "contacts", + targetId: contactId, + payload: note, + }); + return { result: { note }, effects: [effect] }; +} + +module.exports = { execute }; +`.trimStart(), + "utf8", + ); +} + +function writeLocalSkill(parentDir: string): string { + const skillRoot = join(parentDir, "smoke-local"); + const actionsRoot = join(skillRoot, "actions"); + mkdirSync(actionsRoot, { recursive: true }); + writeFileSync( + join(skillRoot, "SKILL.md"), + "---\nname: smoke-local\ndescription: Local action smoke skill\n---\n", + "utf8", + ); + writeFileSync( + join(actionsRoot, "smoke.contact.note.json"), + JSON.stringify( + { + type: "smoke.contact.note", + version: "1", + description: "Record a smoke-only contact note effect", + module: "actions/smoke-contact-note.cjs", + requiresApprovalDefault: false, + payload: { + required: { + contactId: "string", + note: "string", + }, + optional: { + evidence: "object", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + writeSmokeExecutor(actionsRoot, "smoke.contact.note.recorded"); + return skillRoot; +} + +const originalCuedHome = process.env.CUED_HOME; +const tempDir = mkdtempSync(join(tmpdir(), "cued-actions-plugin-local-")); +const cuedHome = join(tempDir, "home"); +const sandboxPath = join(tempDir, "local.db"); +const realDb = openCuedDatabaseReadOnly(); +let sandboxDb: CuedDatabase | null = null; + +try { + const contacts = realDb.executeReadOnlySql(` + SELECT id, name + FROM contacts + WHERE archived = 0 + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `) as ContactRow[]; + assert(contacts.length === 1, "Expected at least one real contact for plugin smoke."); + + const sourceSkillRoot = writeLocalSkill(join(tempDir, "source-skills")); + process.env.CUED_HOME = cuedHome; + const installed = installLocalCuedSkill(sourceSkillRoot); + assert(installed.ok, installed.error ?? "Expected local smoke skill install to succeed."); + + const registry = ActionDefinitionRegistry.load(); + const definition = registry.get("smoke.contact.note"); + assert(definition, "Expected local smoke action definition to load."); + assert( + definition.skillRoot === installed.installedPath, + "Expected action definition to resolve from local skill.", + ); + + sandboxDb = new CuedDatabase(sandboxPath); + sandboxDb.initializeSchema(); + insertContact(sandboxDb, contacts[0]!); + + const firstAction = sandboxDb.createAction({ + actionType: "smoke.contact.note", + payload: { + contactId: contacts[0]!.id, + note: "Local plugin smoke. Temp DB only.", + evidence: { source: "smoke-actions-plugin-local" }, + }, + sourceSkill: definition.skillName, + createdBy: "plugin-smoke", + requiresApproval: false, + }); + const firstExecuted = sandboxDb.executeApprovedAction(firstAction.id, "plugin-smoke"); + + writeSmokeExecutor(join(installed.installedPath, "actions"), "smoke.contact.note.modified"); + const secondAction = sandboxDb.createAction({ + actionType: "smoke.contact.note", + payload: { + contactId: contacts[0]!.id, + note: "Modified local plugin smoke. Temp DB only.", + evidence: { source: "smoke-actions-plugin-local", modified: true }, + }, + sourceSkill: definition.skillName, + createdBy: "plugin-smoke", + requiresApproval: false, + }); + const secondExecuted = sandboxDb.executeApprovedAction(secondAction.id, "plugin-smoke"); + const effects = [ + ...sandboxDb.listActionEffects(firstAction.id), + ...sandboxDb.listActionEffects(secondAction.id), + ]; + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + realDatabaseReadonly: true, + sourceSkillRoot, + localSkillRoot: installed.installedPath, + loadedActionType: definition.type, + executedActionStatuses: [firstExecuted.action.status, secondExecuted.action.status], + effectTypes: effects.map((effect) => effect.effect_type), + }, + null, + 2, + )}\n`, + ); +} finally { + sandboxDb?.close(); + realDb.close(); + if (originalCuedHome === undefined) { + delete process.env.CUED_HOME; + } else { + process.env.CUED_HOME = originalCuedHome; + } + rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/smoke-actions-sandbox.ts b/scripts/smoke-actions-sandbox.ts new file mode 100644 index 00000000..3e17a775 --- /dev/null +++ b/scripts/smoke-actions-sandbox.ts @@ -0,0 +1,217 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CuedDatabase, openCuedDatabaseReadOnly } from "../src/db/database.js"; + +type ContactRow = { + id: string; + name: string | null; +}; + +type ConversationRow = { + id: string; + name: string | null; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function sqlite(db: CuedDatabase) { + return ( + db as unknown as { + sqlite: { + prepare: (sql: string) => { + run: (...params: unknown[]) => void; + get: (...params: unknown[]) => unknown; + }; + }; + } + ).sqlite; +} + +function insertContact(db: CuedDatabase, contact: ContactRow): void { + const timestamp = Date.now(); + sqlite(db) + .prepare( + ` + INSERT INTO contacts (id, kind, name, photo_url, company, archived, created_at, updated_at) + VALUES (?, 'person', ?, NULL, NULL, 0, ?, ?) + `, + ) + .run(contact.id, contact.name, timestamp, timestamp); +} + +function insertConversation(db: CuedDatabase, conversation: ConversationRow): void { + const timestamp = Date.now(); + sqlite(db) + .prepare( + ` + INSERT INTO conversations ( + id, platform, account_key, source_conversation_key, native_conversation_key, type, + is_active, removal_reason, service, name, topic, participant_names, last_message_id, + last_message_at, last_message_preview, unread_count, created_at, updated_at + ) VALUES (?, 'imessage', 'default', ?, NULL, 'dm', 1, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, 0, ?, ?) + `, + ) + .run(conversation.id, `sandbox:${conversation.id}`, conversation.name, timestamp, timestamp); +} + +function executeAutoApproved( + db: CuedDatabase, + actionType: string, + payload: Record, +) { + const action = db.createAction({ + actionType, + payload, + requiresApproval: false, + sourceSkill: "cued", + createdBy: "sandbox-smoke", + }); + return db.executeApprovedAction(action.id, "sandbox-smoke"); +} + +const realDb = openCuedDatabaseReadOnly(); +const tempDir = mkdtempSync(join(tmpdir(), "cued-actions-sandbox-")); +let sandboxDb: CuedDatabase | null = null; + +try { + const contacts = realDb.executeReadOnlySql(` + SELECT id, name + FROM contacts + WHERE archived = 0 + ORDER BY updated_at DESC, created_at DESC + LIMIT 2 + `) as ContactRow[]; + const conversations = realDb.executeReadOnlySql(` + SELECT id, name + FROM conversations + WHERE is_active = 1 + ORDER BY updated_at DESC, created_at DESC + LIMIT 1 + `) as ConversationRow[]; + + assert(contacts.length >= 2, "Expected at least two real contacts for sandbox smoke."); + assert(conversations.length >= 1, "Expected at least one real conversation for sandbox smoke."); + + sandboxDb = new CuedDatabase(join(tempDir, "local.db")); + sandboxDb.initializeSchema(); + for (const contact of contacts) { + insertContact(sandboxDb, contact); + } + insertConversation(sandboxDb, conversations[0]!); + + const contactId = contacts[0]!.id; + const otherContactId = contacts[1]!.id; + const conversationId = conversations[0]!.id; + const memoryAdded = executeAutoApproved(sandboxDb, "contact.memory.add", { + contactId, + body: "Sandbox smoke memory. Temp DB only.", + sourceKind: "sandbox_smoke", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }); + const memoryId = + typeof memoryAdded.result === "object" && + memoryAdded.result !== null && + "memory" in memoryAdded.result && + typeof memoryAdded.result.memory === "object" && + memoryAdded.result.memory !== null && + "id" in memoryAdded.result.memory && + typeof memoryAdded.result.memory.id === "string" + ? memoryAdded.result.memory.id + : null; + assert(memoryId, "Expected contact.memory.add smoke to return a memory id."); + + const results = [ + memoryAdded, + executeAutoApproved(sandboxDb, "contact.memory.stale", { + memoryId, + }), + executeAutoApproved(sandboxDb, "contact.merge", { + primaryContactId: contactId, + secondaryContactId: otherContactId, + reason: "Sandbox smoke merge. Temp DB only.", + }), + executeAutoApproved(sandboxDb, "contact.memory.add", { + contactId, + body: "Sandbox smoke post-merge memory. Temp DB only.", + sourceKind: "sandbox_smoke", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + executeAutoApproved(sandboxDb, "contact.followup.recommend", { + contactId, + reason: "Sandbox smoke follow-up. Temp DB only.", + suggestedMessage: "Sandbox smoke draft.", + evidence: { source: "smoke-actions-sandbox" }, + }), + executeAutoApproved(sandboxDb, "contact.enrichment.recommend", { + contactId, + field: "profile_url", + value: "https://example.invalid/sandbox", + sourceKind: "sandbox_smoke", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + executeAutoApproved(sandboxDb, "contact.introduction.recommend", { + fromContactId: contactId, + toContactId: otherContactId, + reason: "Sandbox smoke introduction. Temp DB only.", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + executeAutoApproved(sandboxDb, "contact.message.draft", { + contactId, + body: "Sandbox smoke draft. Do not send.", + reason: "Sandbox smoke message draft. Temp DB only.", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + executeAutoApproved(sandboxDb, "conversation.summary.draft", { + conversationId, + summary: "Sandbox smoke conversation summary. Temp DB only.", + reason: "Sandbox smoke summary draft.", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + executeAutoApproved(sandboxDb, "conversation.followup.recommend", { + conversationId, + reason: "Sandbox smoke conversation follow-up. Temp DB only.", + suggestedNextStep: "Review in sandbox only.", + evidence: { source: "smoke-actions-sandbox" }, + confidence: 50, + }), + ]; + + const effects = sandboxDb.executeReadOnlySql(` + SELECT effect_type + FROM action_effects + ORDER BY applied_at ASC, id ASC + `) as Array<{ effect_type: string }>; + const sandboxActionCount = ( + sqlite(sandboxDb).prepare("SELECT COUNT(*) AS count FROM actions").get() as { count: number } + ).count; + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + sandbox: true, + realDatabaseReadonly: true, + executedActionCount: results.length, + sandboxActionCount, + effectTypes: effects.map((effect) => effect.effect_type), + }, + null, + 2, + )}\n`, + ); +} finally { + sandboxDb?.close(); + realDb.close(); + rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/validate-cued-release-artifact.sh b/scripts/validate-cued-release-artifact.sh index 17d0c252..9dc5cc96 100755 --- a/scripts/validate-cued-release-artifact.sh +++ b/scripts/validate-cued-release-artifact.sh @@ -9,6 +9,7 @@ INFO_PLIST="$APP_BUNDLE/Contents/Info.plist" RUNTIME_PATH="$APP_BUNDLE/Contents/Resources/cued-runtime" RESOURCES_PATH="$APP_BUNDLE/Contents/Resources" SKILL_PATH="$APP_BUNDLE/Contents/Resources/skills/cued/SKILL.md" +SKILL_ACTIONS_DIR="$APP_BUNDLE/Contents/Resources/skills/cued/actions" EXPECTED_VERSION="${CUED_RELEASE_VERSION:-$(node -p "require(process.argv[1]).version" "$ROOT_DIR/package.json")}" EXPECTED_TAG="${CUED_RELEASE_TAG:-v$EXPECTED_VERSION}" @@ -32,6 +33,34 @@ if [[ ! -d "$RUNTIME_PATH/dist" || ! -d "$RUNTIME_PATH/node_modules" ]]; then exit 1 fi +REQUIRED_SKILL_ACTION_FILES=( + contact.enrichment.recommend.json + contact-enrichment-recommend.cjs + contact.followup.recommend.json + contact-followup-recommend.cjs + contact.introduction.recommend.json + contact-introduction-recommend.cjs + contact.message.draft.json + contact-message-draft.cjs + contact.merge.json + contact-merge.cjs + contact.memory.add.json + contact-memory-add.cjs + contact.memory.stale.json + contact-memory-stale.cjs + conversation.followup.recommend.json + conversation-followup-recommend.cjs + conversation.summary.draft.json + conversation-summary-draft.cjs +) + +for file in "${REQUIRED_SKILL_ACTION_FILES[@]}"; do + if [[ ! -f "$SKILL_ACTIONS_DIR/$file" ]]; then + echo "Bundled Cued action file missing at $SKILL_ACTIONS_DIR/$file" >&2 + exit 1 + fi +done + for executable_path in \ "$RESOURCES_PATH/scripts/request-macos-access.sh" \ "$RESOURCES_PATH/helpers/cued-native-helper" \ @@ -89,7 +118,7 @@ if [[ "$BUNDLE_VERSION" != "$EXPECTED_VERSION" ]]; then fi HELP_OUTPUT="$("$CLI_PATH" help)" -if [[ "$HELP_OUTPUT" != *"cued skill install-global|status"* ]]; then +if [[ "$HELP_OUTPUT" != *"cued skill install-global|install-local [skill-root]|status|status-local [skill-name]"* ]]; then echo "Bundled cued-cli help is missing the skill command" >&2 exit 1 fi diff --git a/skills/cued/SKILL.md b/skills/cued/SKILL.md index 0758d445..6824c1ec 100644 --- a/skills/cued/SKILL.md +++ b/skills/cued/SKILL.md @@ -1,94 +1,185 @@ --- name: cued -description: Queries Cued through the local `cued` CLI for the user's real contacts, conversations, and messages synced from iMessage, Slack, WhatsApp, LinkedIn, Gmail, and Signal. ALWAYS use this skill when the user asks anything about their contacts, messages, texts, conversations, or communication - even short queries like "who texted me", "check my messages", "what's John's email", or "what did we talk about on Slack". Covers finding contacts, looking up phone numbers and email addresses, reading message history, follow-up detection, ghosting detection, dormant relationships, network search, unread triage, cross-platform conversation lookup, contact deduplication, attachment lookup/fetch, and relationship analysis. The database has real data - do not tell the user you lack access to their messages or contacts. +description: Queries a local SQLite database at ~/.cued/local.db containing the user's real contacts, conversations, and messages synced from iMessage, Slack, WhatsApp, LinkedIn, and Signal. ALWAYS use this skill when the user asks anything about their contacts, messages, texts, conversations, or communication — even short queries like "who texted me", "check my messages", "what's John's email", or "what did we talk about on Slack". Covers finding contacts, looking up phone numbers and email addresses, reading message history, follow-up detection, ghosting detection, dormant relationships, network search, unread triage, cross-platform conversation lookup, contact deduplication, and relationship analysis. The database has real data — do not tell the user you lack access to their messages or contacts. --- # Cued -Cued is a local encrypted message/contact datastore. Query it through the `cued` CLI, not by opening `~/.cued/local.db` directly with `sqlite3`. Direct SQLite access can fail because the database is encrypted and the key is mediated by Cued/Keychain. - -Use `cued sql ''` for read-only SQL. It returns JSON and applies the correct database access path. - -```bash -cued sql "select count(*) as messages from messages" -``` - -If `cued` is not on `PATH`, try the packaged CLI first: - -```bash -/Applications/Cued.app/Contents/Resources/runtime/node/bin/node /Applications/Cued.app/Contents/Resources/cued-runtime/dist/cli.js sql "select count(*) as messages from messages" -``` - -Do not use `sqlite3 ~/.cued/local.db` unless the user explicitly asks to debug raw database encryption/readability. +Local SQLite database at `~/.cued/local.db`. Read-only by default. **Timestamps are Unix epoch MILLISECONDS.** To convert: `datetime(sent_at/1000, 'unixepoch', 'localtime')`. Current millis: `unixepoch('now') * 1000`. N days ago: `unixepoch('now', '-N days') * 1000`. ## Rules -- On load, make Cued usable before doing the user task. Run a quick health gate with `cued status`, `cued doctor`, and, when synced sources or current data matter, `cued integrations status`. If the gate shows a blocked daemon, failed permissions, failed integrations, stale queued work, database contention, or missing local access needed for the request, pause the user-facing task and unblock Cued first using the self-repair workflow below. Continue the user task only after Cued is healthy enough for that task, or after recording the exact blocker. Do not ask the user to debug Cued manually unless the next required step is a system permission or account login only they can grant. - Prefer SQL over bash parsing. - Always limit result size unless the user asks for exhaustive output. - Start broad, then drill down. - DM conversations have `type = 'dm'`. Group conversations have `type = 'group'`. -## Important Tables +## Tables -This is not a full schema reference. Use these tables when CLI commands are too coarse and you need exact counts, joins, ranking, or provenance. Prefer `cued` commands for mutations and attachment fetches. +### contacts +Canonical people/entities. +``` +id TEXT PRIMARY KEY +kind TEXT -- currently always 'person' +name TEXT +photo_url TEXT +company TEXT +archived INTEGER +created_at INTEGER +updated_at INTEGER +``` -- `contacts`: canonical people/entities. Key fields: `id`, `name`, `company`, `archived`. -- `contact_handles`: email/phone/platform handles. Key fields: `contact_id`, `type`, `value`, `normalized_value`, `platform`, `is_deterministic`. -- `contact_sources`: where a contact came from. Key fields: `contact_id`, `platform`, `account_key`, `source_entity_key`, `profile_url`, `first_seen_at`, `last_seen_at`. -- `contact_memories`: durable agent-written context. Query it for current memories with `stale_at IS NULL`; write through `cued contacts memory ...`, not SQL. -- `conversations`: canonical threads. Key fields: `id`, `platform`, `account_key`, `type`, `name`, `participant_names`, `last_message_at`, `last_message_preview`, `unread_count`. -- `conversation_participants`: contact membership in threads. Key fields: `conversation_id`, `contact_id`, `participant_name`, `is_self`, `is_active`. -- `messages`: canonical message rows. Key fields: `id`, `platform`, `conversation_id`, `sender_contact_id`, `sender_name`, `sent_at`, `is_from_me`, `content`, `attachment_count`, `reaction_count`, `reply_to_message_id`. -- `message_reactions`: reactions/tapbacks. Key fields: `message_id`, `reactor_contact_id`, `reactor_name`, `emoji`, `is_active`, `created_at`. -- `message_attachments`: attachment metadata. Key fields: `id`, `message_id`, `platform`, `kind`, `mime_type`, `filename`, `title`, `size_bytes`, `text_content`, `access_kind`, `availability_status`. -- `attachment_content`: extracted text for fetched attachments. Key fields: `attachment_id`, `status`, `text_content`, `mime_type`, `extracted_at`, `last_error`. Prefer `cued attachments search`. -- `messages_fts`: FTS5 over message search fields: `sender_name`, `conversation_name`, `participant_names`, `attachment_text`, `content`. -- `attachment_content_fts`: FTS5 over extracted attachment text. Prefer `cued attachments search `. -- `integration_states`: platform connection status. Key fields: `platform`, `account_key`, `auth_state`, `enabled`. -- `contact_merge_decisions`: merge/split audit trail. Inspect for dedupe provenance; perform merges through `cued contacts merge...`. +### contact_handles +Phone/email/platform identifiers per contact. +``` +id TEXT PRIMARY KEY +contact_id TEXT -- FK → contacts.id +type TEXT -- 'email', 'phone', 'linkedin', 'slack', etc. +value TEXT +normalized_value TEXT +platform TEXT +account_key TEXT +is_deterministic INTEGER +``` -Useful SQL examples: +### contact_sources +Where each contact was discovered. +``` +id TEXT PRIMARY KEY +contact_id TEXT -- FK → contacts.id +platform TEXT -- 'imessage', 'slack', 'linkedin', 'whatsapp', 'signal' +account_key TEXT +source_entity_key TEXT +profile_url TEXT +first_seen_at INTEGER +last_seen_at INTEGER +``` -```bash -cued sql "select count(*) as messages from messages" -cued sql "select platform, count(*) as messages from messages group by platform order by messages desc" -cued sql "select id, platform, name, participant_names, datetime(last_message_at/1000,'unixepoch','localtime') as last_message from conversations order by last_message_at desc limit 20" -cued sql "select id, sender_name, conversation_name, datetime(sent_at/1000,'unixepoch','localtime') as sent, content, attachment_count from messages where conversation_id = 'conversation-id-here' order by sent_at desc limit 50" -cued sql "select ma.id, ma.kind, ma.mime_type, ma.filename, ma.size_bytes, ma.access_kind, ma.availability_status from message_attachments ma where ma.message_id = 'message-id-here'" +### contact_memories +Successful useful agent memories for a contact. These are durable agent context, not projected source data. Only current memories have `stale_at IS NULL`. +``` +id TEXT PRIMARY KEY +contact_id TEXT +body TEXT +source_kind TEXT -- local_messages, web_search, linkedin, manual, agent, etc. +evidence_json TEXT -- message ids, URLs, handles, profile ids, query evidence +confidence INTEGER -- optional 0-100 +supersedes_memory_id TEXT +stale_at INTEGER +created_by TEXT +created_at INTEGER +updated_at INTEGER ``` -## Attachments +### conversations +Canonical threads across all platforms. +``` +id TEXT PRIMARY KEY +platform TEXT +account_key TEXT +type TEXT -- 'dm' or 'group' +service TEXT +name TEXT +topic TEXT +participant_names TEXT -- pipe-separated, e.g. 'Alice | Bob' +last_message_id TEXT +last_message_at INTEGER +last_message_preview TEXT +unread_count INTEGER +``` -Use a metadata-first workflow. Do not fetch bytes unless the user asks for the attached file or the task clearly depends on reading it. +### conversation_participants +Contact membership per conversation. +``` +conversation_id TEXT -- FK → conversations.id +contact_id TEXT -- FK → contacts.id +participant_name TEXT +role TEXT +is_self INTEGER -- 1 if this participant is the user +is_active INTEGER +joined_at INTEGER +left_at INTEGER +``` -List attachments for a message: -```bash -cued attachments list --message message-id-here --limit 20 +### messages +Canonical messages across all platforms. +``` +id TEXT PRIMARY KEY +platform TEXT +account_key TEXT +conversation_id TEXT -- FK → conversations.id +sender_contact_id TEXT -- FK → contacts.id +sender_name TEXT -- denormalized +conversation_name TEXT -- denormalized +sent_at INTEGER +is_from_me INTEGER -- 1 = user sent it, 0 = received +content TEXT +status TEXT +delivered_at INTEGER +read_at INTEGER +is_deleted INTEGER +is_edited INTEGER +attachment_count INTEGER +reaction_count INTEGER -- denormalized count of active reactions +reply_to_message_id TEXT ``` -Fetch one attachment through the daemon: -```bash -cued attachments fetch attachment-id-here +### message_reactions +Reactions (emoji tapbacks) on messages. Important for determining if someone acknowledged a message without replying. +``` +id TEXT PRIMARY KEY +message_id TEXT -- FK → messages.id +platform TEXT +reactor_contact_id TEXT -- FK → contacts.id (who reacted) +reactor_name TEXT -- denormalized +emoji TEXT -- e.g. '❤️', '😂', '👍', '‼️' +is_active INTEGER -- 1 = still active, 0 = removed +created_at INTEGER ``` -Fetch returns the cached `localPath` when available and extracts text for text-like files and PDFs when supported. It may return `content.status = unsupported` for images, audio, video, binary files, or PDFs without extractable text. It may return `content.status = skipped_large` when the file was cached but too large to index safely. Do not read `attachment_cache` directly for normal work. +### actions +Agent-proposed mutable work. Actions are the preferred write path for merge and memory changes when approval, auditability, or replayability matters. +``` +id TEXT PRIMARY KEY +action_type TEXT -- e.g. 'contact.merge', 'contact.memory.add', 'contact.followup.recommend', 'contact.enrichment.recommend', 'contact.introduction.recommend', 'contact.message.draft', 'conversation.followup.recommend', 'conversation.summary.draft' +action_version TEXT +status TEXT -- proposed, approved, executing, executed, failed, denied, canceled +approval_status TEXT -- pending, approved, denied, auto_approved +execution_status TEXT -- pending, running, succeeded, failed, skipped +title TEXT +summary TEXT +payload_json TEXT +result_json TEXT +error_json TEXT +source_skill TEXT +created_by TEXT +approved_by TEXT +executed_by TEXT +queued_at INTEGER +approved_at INTEGER +executed_at INTEGER +``` -Search already-extracted attachment text: -```bash -cued attachments search "search terms" --limit 20 +### action_effects +Execution effects from approved actions. Use this to audit what an action changed. +``` +id TEXT PRIMARY KEY +action_id TEXT +effect_type TEXT -- e.g. 'contact_memory.added', 'contact.merge.recorded', 'contact.followup.recommended', 'contact.enrichment.recommended', 'contact.introduction.recommended', 'contact.message.drafted', 'conversation.followup.recommended', 'conversation.summary.drafted' +target_table TEXT +target_id TEXT +payload_json TEXT +applied_at INTEGER +reverted_at INTEGER ``` -Safety rules: -- Inspect `filename`, `mime_type`, `size_bytes`, `access_kind`, and `availability_status` before fetching. -- The default fetch path has a conservative byte ceiling to prevent accidental large downloads. -- Use `--allow-large` only when the user explicitly asks for the file or the task clearly depends on the bytes. -- Avoid fetching video, audio, archives, disk images, and opaque binary files unless the user asks for the file itself; agents usually cannot inspect them usefully. -- Use `--max-bytes` when you intentionally want a stricter ceiling for a one-off fetch. -- Treat `metadata_only`, `none`, or missing fetch coordinates as not currently fetchable. -- Never paste private attachment text into fixtures or broad summaries; summarize only what is needed. +### messages_fts +FTS5 full-text search index on messages. Searchable columns: `sender_name`, `conversation_name`, `participant_names`, `attachment_text`, `content`. Use `messages_fts MATCH ''` with FTS5 syntax and join on `messages.id = messages_fts.message_id` for full metadata. + +### integration_states +Platform connection status. Key columns: `platform`, `account_key`, `auth_state`, `enabled`. ## Relationship Patterns @@ -103,9 +194,79 @@ Safety rules: - **Find a person**: Search `contacts` by name (`LIKE '%name%' COLLATE NOCASE`), then check `contact_handles` for email/phone/handle matches. - **Cross-platform view**: Join `conversation_participants` → `conversations` for a contact to see all their threads across platforms. - **Duplicate detection**: Match `contact_handles.normalized_value` across different `contact_id`s, or match `contacts.name` case-insensitively. Many contacts have phone numbers as names (e.g. `+1347...`) because they were discovered via iMessage before being linked. -- **Merge duplicates**: `cued contacts merge [--reason TEXT]` for one merge, or `cued contacts merge-batch merges.json --apply` for many exact-evidence merges with one rebuild. `merge-batch` dry-runs by default when `--apply` is omitted. -- **Merge audit trail**: Manual merges are recorded in `contact_merge_decisions`, so they survive rebuilds and replay. -- **Contact memories**: Use `contact_memories` for compact, evidence-backed agent memory. Write via `cued contacts memory ...`, not arbitrary SQL. +- **Merge duplicates**: Use `cued actions propose contact.merge --payload '{"primaryContactId":"...","secondaryContactId":"...","reason":"..."}' --source-skill cued` to propose a merge. Direct `cued contacts merge` commands do not exist. +- **Merge audit trail**: Manual merges are recorded as executed `contact.merge` actions with `contact.merge.recorded` rows in `action_effects`; there is no separate merge-decision table. +- **Contact memories**: Use `contact_memories` for compact, evidence-backed agent memory. `cued contacts memory add/stale` proposes actions by default; use `--execute` only when the user explicitly asked to apply the mutation now. Do not write arbitrary SQL. +- **Follow-up recommendations**: Use `contact.followup.recommend` for harmless follow-up queueing. It records an executed `contact.followup.recommended` effect only; it does not send messages. +- **Enrichment recommendations**: Use `contact.enrichment.recommend` for local-data-backed profile enrichment suggestions. It records an executed `contact.enrichment.recommended` effect only; it does not rewrite contact fields. +- **Introduction recommendations**: Use `contact.introduction.recommend` for harmless intro suggestions between two contacts. It records an executed `contact.introduction.recommended` effect only; it does not message either person. +- **Message drafts**: Use `contact.message.draft` for suggested outbound text. It records an executed `contact.message.drafted` effect only; it does not send a message. +- **Conversation follow-ups**: Use `conversation.followup.recommend` for conversation-level next-step recommendations. It records an executed `conversation.followup.recommended` effect only. +- **Conversation summaries**: Use `conversation.summary.draft` for drafted conversation summaries. It records an executed `conversation.summary.drafted` effect only; it does not rewrite messages. + +## Actions + +Use actions when proposing or applying mutable work. Available definitions: +```bash +cued actions definitions +``` + +Action schemas and daemon-loaded executor modules live in skill directories like +`skills/cued/actions/`. The installed DMG bundles skills under +`Contents/Resources/skills/*`, and the daemon also discovers local skills under +`~/.cued/skills/*`. For development, `CUED_SKILL_ROOT` or `CUED_SKILL_ROOTS` +can point at explicit skill roots. Add new mutable skills by adding JSON plus the +matching executor module inside the skill directory; keep action state in +`actions` and execution evidence in `action_effects`. + +Install the bundled Cued skill into the daemon-local skill root when you want a +modifiable copy that the installed app can discover: +```bash +cued skill install-local +cued skill status-local +``` +Install another local action skill by passing its skill root: +```bash +cued skill install-local /path/to/my-skill +cued skill status-local my-skill +``` + +Local skill roots under `~/.cued/skills/*` are loaded before bundled skills. A +local skill with the same directory name as a bundled skill replaces that +bundled skill for daemon action loading; action type/version collisions across +different skill roots are rejected. + +Propose an action: +```bash +cued actions propose contact.memory.add --payload '{"contactId":"contact-id-here","body":"Evidence-backed memory."}' --title "Add contact memory" --source-skill cued +cued actions propose contact.followup.recommend --payload '{"contactId":"contact-id-here","reason":"Recent inbound message has no newer outbound reply.","suggestedMessage":"Following up on our last thread."}' --title "Recommend follow-up" --source-skill cued +cued actions propose contact.enrichment.recommend --payload '{"contactId":"contact-id-here","field":"profile_url","value":"https://example.com/profile","sourceKind":"contact_sources"}' --title "Recommend enrichment" --source-skill cued +cued actions propose contact.introduction.recommend --payload '{"fromContactId":"contact-a","toContactId":"contact-b","reason":"They are both working on overlapping ideas."}' --title "Recommend intro" --source-skill cued +cued actions propose contact.message.draft --payload '{"contactId":"contact-id-here","body":"Following up on our last thread.","reason":"Recent inbound message has no newer outbound reply."}' --title "Draft follow-up" --source-skill cued +cued actions propose conversation.followup.recommend --payload '{"conversationId":"conversation-id-here","reason":"Recent active conversation may need a next step."}' --title "Recommend conversation follow-up" --source-skill cued +cued actions propose conversation.summary.draft --payload '{"conversationId":"conversation-id-here","summary":"Recent thread summary.","reason":"Prep context for follow-up."}' --title "Draft conversation summary" --source-skill cued +``` + +Review and approve: +```bash +cued actions list --status proposed +cued actions show action-id-here +cued actions approve action-id-here --by user +``` + +Execute approved work: +```bash +cued actions execute action-id-here --by cued +cued actions run-approved --limit 10 --by cued +``` + +For contact-specific commands, use the built-in action flags: +```bash +cued contacts memory add contact-id "Evidence-backed memory." --source local_messages --evidence '{"message_ids":["message-id"]}' +cued contacts memory stale memory-id +``` + +These commands queue actions by default. Only use `--execute` when the user has approved execution or explicitly asked for immediate mutation. ## Contact Memories @@ -168,7 +329,3 @@ Good enrichment memories are compact and claim-specific: - useful follow-up context, such as likely founder/investor/recruiting relevance. Do not update canonical contact fields from web search unless identity is deterministic and the field is directly supported. Prefer `cued contacts memory add ... --source web --evidence ...` for researched context. If two verified sources disagree, either supersede a stale memory with evidence or write nothing and report the conflict. - -## Self-Repair And Unblocking - -This is mandatory whenever this skill is loaded, not only when the user explicitly asks for repair. Start with operational evidence before editing source code. Inspect `cued status`, `cued doctor`, `cued logs --tail 200`, `cued integrations status`, and targeted read-only SQL for active, failed, or wedged sync/projection state. Use the Cued CLI first: resume queued work, run a targeted source sync, refresh integrations, rebuild projections, reset a specific source, or cancel stuck sync/ingestion work when the CLI exposes that control. If a run is blocking local progress, read the logs and database state before stopping or restarting anything, record what was active, then restart Cued and verify the counts/state changed. Escalate to source edits only after local repair fails or proves a code defect. diff --git a/skills/cued/actions/contact-enrichment-recommend.cjs b/skills/cued/actions/contact-enrichment-recommend.cjs new file mode 100644 index 00000000..09d4d0bf --- /dev/null +++ b/skills/cued/actions/contact-enrichment-recommend.cjs @@ -0,0 +1,30 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const contactId = helpers.requiredStringPayload(payload, "contactId", action); + if (!db.contactExists(contactId)) { + throw new Error(`Contact not found: ${contactId}`); + } + + const recommendation = { + contactId, + field: helpers.requiredStringPayload(payload, "field", action), + value: helpers.requiredStringPayload(payload, "value", action), + sourceKind: helpers.optionalStringPayload(payload, "sourceKind", action) ?? "local", + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.enrichment.recommended", + targetTable: "contacts", + targetId: contactId, + payload: recommendation, + }); + + return { + result: { recommendation }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-followup-recommend.cjs b/skills/cued/actions/contact-followup-recommend.cjs new file mode 100644 index 00000000..4029bf63 --- /dev/null +++ b/skills/cued/actions/contact-followup-recommend.cjs @@ -0,0 +1,29 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const contactId = helpers.requiredStringPayload(payload, "contactId", action); + if (!db.contactExists(contactId)) { + throw new Error(`Contact not found: ${contactId}`); + } + + const recommendation = { + contactId, + reason: helpers.requiredStringPayload(payload, "reason", action), + suggestedMessage: helpers.optionalStringPayload(payload, "suggestedMessage", action), + dueAt: helpers.optionalNumberPayload(payload, "dueAt", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.followup.recommended", + targetTable: "contacts", + targetId: contactId, + payload: recommendation, + }); + + return { + result: { recommendation }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-introduction-recommend.cjs b/skills/cued/actions/contact-introduction-recommend.cjs new file mode 100644 index 00000000..078425c7 --- /dev/null +++ b/skills/cued/actions/contact-introduction-recommend.cjs @@ -0,0 +1,37 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const fromContactId = helpers.requiredStringPayload(payload, "fromContactId", action); + const toContactId = helpers.requiredStringPayload(payload, "toContactId", action); + if (fromContactId === toContactId) { + throw new Error("Introduction recommendation requires two different contacts."); + } + if (!db.contactExists(fromContactId)) { + throw new Error(`From contact not found: ${fromContactId}`); + } + if (!db.contactExists(toContactId)) { + throw new Error(`To contact not found: ${toContactId}`); + } + + const recommendation = { + fromContactId, + toContactId, + reason: helpers.requiredStringPayload(payload, "reason", action), + suggestedIntro: helpers.optionalStringPayload(payload, "suggestedIntro", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.introduction.recommended", + targetTable: "contacts", + targetId: fromContactId, + payload: recommendation, + }); + + return { + result: { recommendation }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-memory-add.cjs b/skills/cued/actions/contact-memory-add.cjs new file mode 100644 index 00000000..2c1f923f --- /dev/null +++ b/skills/cued/actions/contact-memory-add.cjs @@ -0,0 +1,26 @@ +function execute({ action, db, executedBy, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const memory = db.addContactMemory({ + contactId: helpers.requiredStringPayload(payload, "contactId", action), + body: helpers.requiredStringPayload(payload, "body", action), + sourceKind: helpers.optionalStringPayload(payload, "sourceKind", action) ?? "agent", + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + supersedesMemoryId: helpers.optionalStringPayload(payload, "supersedesMemoryId", action), + createdBy: executedBy, + }); + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact_memory.added", + targetTable: "contact_memories", + targetId: memory.id, + payload: { memoryId: memory.id, contactId: memory.contact_id }, + }); + + return { + result: { memory }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-memory-stale.cjs b/skills/cued/actions/contact-memory-stale.cjs new file mode 100644 index 00000000..7cb2259f --- /dev/null +++ b/skills/cued/actions/contact-memory-stale.cjs @@ -0,0 +1,20 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const memory = db.markContactMemoryStale( + helpers.requiredStringPayload(payload, "memoryId", action), + ); + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact_memory.marked_stale", + targetTable: "contact_memories", + targetId: memory?.id ?? null, + payload: { memoryId: memory?.id, contactId: memory?.contact_id }, + }); + + return { + result: { memory }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-merge.cjs b/skills/cued/actions/contact-merge.cjs new file mode 100644 index 00000000..fb4aaf01 --- /dev/null +++ b/skills/cued/actions/contact-merge.cjs @@ -0,0 +1,126 @@ +function resolveCanonicalContactId(contactId, aliasMap) { + const seen = new Set(); + let current = contactId; + while (!seen.has(current)) { + seen.add(current); + const next = aliasMap.get(current); + if (!next || next === current) { + return current; + } + current = next; + } + throw new Error(`Contact merge alias cycle detected at ${current}`); +} + +function parseMergeEffectPayload(payloadJson) { + if (!payloadJson) { + return null; + } + const payload = JSON.parse(payloadJson); + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return null; + } + if ( + typeof payload.secondaryContactId !== "string" || + typeof payload.canonicalContactId !== "string" + ) { + return null; + } + return { + contact_id: payload.secondaryContactId, + canonical_contact_id: payload.canonicalContactId, + }; +} + +function listContactMergeAliases(db) { + return db + .listActiveActionEffects({ + actionType: "contact.merge", + effectType: "contact.merge.recorded", + }) + .map((effect) => parseMergeEffectPayload(effect.payload_json)) + .filter((alias) => alias !== null); +} + +function planContactMerges(db, input, randomUUID) { + if (input.length === 0) { + throw new Error("At least one contact merge is required."); + } + + const aliasMap = new Map( + listContactMergeAliases(db).map((row) => [row.contact_id, row.canonical_contact_id]), + ); + const planned = []; + for (const merge of input) { + const primaryContactId = merge.primaryContactId.trim(); + const secondaryContactId = merge.secondaryContactId.trim(); + if (!primaryContactId || !secondaryContactId) { + throw new Error("Primary and secondary contact ids are required."); + } + if (primaryContactId === secondaryContactId) { + throw new Error("Cannot merge a contact into itself"); + } + if (!db.contactExists(primaryContactId)) { + throw new Error(`Primary contact not found: ${primaryContactId}`); + } + if (!db.contactExists(secondaryContactId)) { + throw new Error(`Secondary contact not found: ${secondaryContactId}`); + } + + const canonicalPrimary = resolveCanonicalContactId(primaryContactId, aliasMap); + const canonicalSecondary = resolveCanonicalContactId(secondaryContactId, aliasMap); + if (canonicalPrimary === canonicalSecondary) { + throw new Error( + `Contacts already resolve to the same canonical contact: ${canonicalPrimary}`, + ); + } + + aliasMap.set(canonicalSecondary, canonicalPrimary); + resolveCanonicalContactId(canonicalSecondary, aliasMap); + planned.push({ + decisionId: randomUUID(), + primaryContactId: canonicalPrimary, + secondaryContactId: canonicalSecondary, + canonicalContactId: canonicalPrimary, + reason: merge.reason ?? null, + }); + } + return planned; +} + +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const [decision] = planContactMerges( + db, + [ + { + primaryContactId: helpers.requiredStringPayload(payload, "primaryContactId", action), + secondaryContactId: helpers.requiredStringPayload(payload, "secondaryContactId", action), + reason: helpers.optionalStringPayload(payload, "reason", action), + }, + ], + helpers.randomUUID, + ); + if (!decision) { + throw new Error("Contact merge action did not produce a merge decision."); + } + + db.moveContactMemoriesToContact({ + fromContactId: decision.secondaryContactId, + toContactId: decision.canonicalContactId, + }); + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.merge.recorded", + targetTable: "contacts", + targetId: decision.canonicalContactId, + payload: decision, + }); + + return { + result: { decision }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact-message-draft.cjs b/skills/cued/actions/contact-message-draft.cjs new file mode 100644 index 00000000..2080d450 --- /dev/null +++ b/skills/cued/actions/contact-message-draft.cjs @@ -0,0 +1,30 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const contactId = helpers.requiredStringPayload(payload, "contactId", action); + if (!db.contactExists(contactId)) { + throw new Error(`Contact not found: ${contactId}`); + } + + const draft = { + contactId, + body: helpers.requiredStringPayload(payload, "body", action), + reason: helpers.requiredStringPayload(payload, "reason", action), + channelHint: helpers.optionalStringPayload(payload, "channelHint", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.message.drafted", + targetTable: "contacts", + targetId: contactId, + payload: draft, + }); + + return { + result: { draft }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/contact.enrichment.recommend.json b/skills/cued/actions/contact.enrichment.recommend.json new file mode 100644 index 00000000..38e2a3e3 --- /dev/null +++ b/skills/cued/actions/contact.enrichment.recommend.json @@ -0,0 +1,19 @@ +{ + "type": "contact.enrichment.recommend", + "version": "1", + "description": "Record a local-data-backed contact enrichment recommendation.", + "module": "actions/contact-enrichment-recommend.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "contactId": "string", + "field": "string", + "value": "string" + }, + "optional": { + "sourceKind": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/actions/contact.followup.recommend.json b/skills/cued/actions/contact.followup.recommend.json new file mode 100644 index 00000000..0da0a5c6 --- /dev/null +++ b/skills/cued/actions/contact.followup.recommend.json @@ -0,0 +1,18 @@ +{ + "type": "contact.followup.recommend", + "version": "1", + "description": "Record a harmless follow-up recommendation for a contact.", + "module": "actions/contact-followup-recommend.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "contactId": "string", + "reason": "string" + }, + "optional": { + "suggestedMessage": "string", + "dueAt": "number", + "evidence": "object" + } + } +} diff --git a/skills/cued/actions/contact.introduction.recommend.json b/skills/cued/actions/contact.introduction.recommend.json new file mode 100644 index 00000000..936d1c44 --- /dev/null +++ b/skills/cued/actions/contact.introduction.recommend.json @@ -0,0 +1,19 @@ +{ + "type": "contact.introduction.recommend", + "version": "1", + "description": "Record a harmless introduction recommendation between two contacts.", + "module": "actions/contact-introduction-recommend.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "fromContactId": "string", + "toContactId": "string", + "reason": "string" + }, + "optional": { + "suggestedIntro": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/actions/contact.memory.add.json b/skills/cued/actions/contact.memory.add.json new file mode 100644 index 00000000..9cd0f6a5 --- /dev/null +++ b/skills/cued/actions/contact.memory.add.json @@ -0,0 +1,19 @@ +{ + "type": "contact.memory.add", + "version": "1", + "description": "Add an evidence-backed durable memory to a contact.", + "module": "actions/contact-memory-add.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "contactId": "string", + "body": "string" + }, + "optional": { + "sourceKind": "string", + "evidence": "object", + "confidence": "number", + "supersedesMemoryId": "string" + } + } +} diff --git a/skills/cued/actions/contact.memory.stale.json b/skills/cued/actions/contact.memory.stale.json new file mode 100644 index 00000000..67cc36f5 --- /dev/null +++ b/skills/cued/actions/contact.memory.stale.json @@ -0,0 +1,13 @@ +{ + "type": "contact.memory.stale", + "version": "1", + "description": "Mark a contact memory stale without deleting its audit trail.", + "module": "actions/contact-memory-stale.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "memoryId": "string" + }, + "optional": {} + } +} diff --git a/skills/cued/actions/contact.merge.json b/skills/cued/actions/contact.merge.json new file mode 100644 index 00000000..113c466a --- /dev/null +++ b/skills/cued/actions/contact.merge.json @@ -0,0 +1,19 @@ +{ + "type": "contact.merge", + "version": "1", + "description": "Merge one duplicate contact into a canonical contact.", + "module": "actions/contact-merge.cjs", + "postExecution": { + "rebuildProjection": true + }, + "requiresApprovalDefault": true, + "payload": { + "required": { + "primaryContactId": "string", + "secondaryContactId": "string" + }, + "optional": { + "reason": "string" + } + } +} diff --git a/skills/cued/actions/contact.message.draft.json b/skills/cued/actions/contact.message.draft.json new file mode 100644 index 00000000..b7556582 --- /dev/null +++ b/skills/cued/actions/contact.message.draft.json @@ -0,0 +1,19 @@ +{ + "type": "contact.message.draft", + "version": "1", + "description": "Record a harmless drafted message for a contact without sending it.", + "module": "actions/contact-message-draft.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "contactId": "string", + "body": "string", + "reason": "string" + }, + "optional": { + "channelHint": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/actions/conversation-followup-recommend.cjs b/skills/cued/actions/conversation-followup-recommend.cjs new file mode 100644 index 00000000..bd8c149f --- /dev/null +++ b/skills/cued/actions/conversation-followup-recommend.cjs @@ -0,0 +1,29 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const conversationId = helpers.requiredStringPayload(payload, "conversationId", action); + if (!db.conversationExists(conversationId)) { + throw new Error(`Conversation not found: ${conversationId}`); + } + + const recommendation = { + conversationId, + reason: helpers.requiredStringPayload(payload, "reason", action), + suggestedNextStep: helpers.optionalStringPayload(payload, "suggestedNextStep", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "conversation.followup.recommended", + targetTable: "conversations", + targetId: conversationId, + payload: recommendation, + }); + + return { + result: { recommendation }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/conversation-summary-draft.cjs b/skills/cued/actions/conversation-summary-draft.cjs new file mode 100644 index 00000000..d62fdfc7 --- /dev/null +++ b/skills/cued/actions/conversation-summary-draft.cjs @@ -0,0 +1,30 @@ +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + const conversationId = helpers.requiredStringPayload(payload, "conversationId", action); + if (!db.conversationExists(conversationId)) { + throw new Error(`Conversation not found: ${conversationId}`); + } + + const draft = { + conversationId, + summary: helpers.requiredStringPayload(payload, "summary", action), + reason: helpers.requiredStringPayload(payload, "reason", action), + timeWindow: helpers.optionalStringPayload(payload, "timeWindow", action), + evidence: helpers.optionalObjectPayload(payload, "evidence", action), + confidence: helpers.optionalNumberPayload(payload, "confidence", action), + }; + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "conversation.summary.drafted", + targetTable: "conversations", + targetId: conversationId, + payload: draft, + }); + + return { + result: { draft }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/skills/cued/actions/conversation.followup.recommend.json b/skills/cued/actions/conversation.followup.recommend.json new file mode 100644 index 00000000..ff935db5 --- /dev/null +++ b/skills/cued/actions/conversation.followup.recommend.json @@ -0,0 +1,18 @@ +{ + "type": "conversation.followup.recommend", + "version": "1", + "description": "Record a harmless follow-up recommendation for a conversation.", + "module": "actions/conversation-followup-recommend.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "conversationId": "string", + "reason": "string" + }, + "optional": { + "suggestedNextStep": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/actions/conversation.summary.draft.json b/skills/cued/actions/conversation.summary.draft.json new file mode 100644 index 00000000..d6aa8b8a --- /dev/null +++ b/skills/cued/actions/conversation.summary.draft.json @@ -0,0 +1,19 @@ +{ + "type": "conversation.summary.draft", + "version": "1", + "description": "Record a harmless drafted summary for a conversation without modifying messages.", + "module": "actions/conversation-summary-draft.cjs", + "requiresApprovalDefault": true, + "payload": { + "required": { + "conversationId": "string", + "summary": "string", + "reason": "string" + }, + "optional": { + "timeWindow": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/evals/evals.json b/skills/cued/evals/evals.json index 4542f925..ce95a6fa 100644 --- a/skills/cued/evals/evals.json +++ b/skills/cued/evals/evals.json @@ -204,7 +204,7 @@ "Searched contact_handles for matching normalized_value across different contact_ids", "Joined to contacts to show names and to contact_sources for platform info", "Identified cross-platform duplicates (e.g., same person on iMessage and Slack)", - "Suggested using cued contacts merge to merge duplicates", + "Suggested proposing contact.merge actions with primaryContactId and secondaryContactId payloads", "Handled phone-number-as-name contacts (name LIKE '+%') as likely duplicates" ] }, @@ -217,7 +217,7 @@ "Searched contact_handles for the phone number's normalized_value", "Found both the phone-number contact and any named contacts sharing the same number", "Showed the named contact's details (name, platform, handles)", - "Suggested merging the two contacts using cued contacts merge ", + "Suggested merging the two contacts by proposing a contact.merge action", "Checked recent messages to provide additional context about who this person is" ] }, @@ -332,7 +332,7 @@ "Joined contact_sources to show cross-platform evidence for each duplicate pair or cluster", "Proposed autonomous merges only for high-confidence cases, not weak same-name guesses", "Identified which contact should be canonical before merging", - "Suggested using cued contacts merge for each safe merge" + "Suggested proposing one contact.merge action for each safe merge" ] }, { @@ -455,6 +455,84 @@ "Explained how local and external evidence should aggregate into one canonical contact record", "Did not skip the local-first pass before proposing external enrichment" ] + }, + { + "id": 36, + "prompt": "Find people I should follow up with from recent unanswered inbound messages, but do not send anything. Queue harmless follow-up recommendations that I can review.", + "expected_output": "A reviewable set of follow-up recommendations grounded in recent local messages. Each recommendation should map to a contact.followup.recommend action payload and should not send any message.", + "files": [], + "expectations": [ + "Found contacts from conversations where a recent inbound message has no newer outbound reply", + "Resolved each candidate to a contact_id", + "Created or suggested contact.followup.recommend action payloads instead of sending messages", + "Included a reason and evidence for each recommendation", + "Did not mutate state unless an explicit approval/execution command was run" + ] + }, + { + "id": 37, + "prompt": "Find profile fields that can be enriched from local data already in Cued, but do not rewrite contacts yet. Queue enrichment recommendations I can review.", + "expected_output": "A reviewable set of local-data-backed enrichment recommendations. Each recommendation should map to a contact.enrichment.recommend action payload and should not rewrite contact fields.", + "files": [], + "expectations": [ + "Looked for enrichment evidence in contact_sources, contact_handles, conversations, and messages", + "Resolved each recommendation to a contact_id", + "Created or suggested contact.enrichment.recommend action payloads instead of updating contacts directly", + "Included field, value, sourceKind, confidence, and evidence for each recommendation", + "Did not mutate state unless an explicit approval/execution command was run" + ] + }, + { + "id": 38, + "prompt": "Find people I might want to introduce to each other from my local message graph, but do not message anyone. Queue reviewable introduction recommendations only.", + "expected_output": "A reviewable set of introduction recommendations grounded in local Cued relationship evidence. Each recommendation should map to a contact.introduction.recommend action payload and should not send messages.", + "files": [], + "expectations": [ + "Resolved each side of the recommendation to a contact_id", + "Used local relationship evidence such as shared topics, shared conversations, or complementary context", + "Created or suggested contact.introduction.recommend action payloads instead of sending messages", + "Included a reason, suggestedIntro when useful, confidence, and evidence for each recommendation", + "Did not mutate state unless an explicit approval/execution command was run" + ] + }, + { + "id": 39, + "prompt": "Draft replies for recent unanswered inbound messages, but do not send them. Queue message drafts I can review.", + "expected_output": "A reviewable set of drafted message actions grounded in recent local conversations. Each draft should map to a contact.message.draft action payload and should not send anything.", + "files": [], + "expectations": [ + "Found recent inbound messages with no newer outbound reply", + "Resolved each draft target to a contact_id", + "Created or suggested contact.message.draft action payloads instead of sending messages", + "Included body, reason, channelHint when known, confidence, and evidence for each draft", + "Did not mutate state unless an explicit approval/execution command was run" + ] + }, + { + "id": 40, + "prompt": "Draft concise summaries for my recent active conversations so I can review them later, but do not rewrite messages or memories.", + "expected_output": "A reviewable set of drafted conversation summary actions grounded in recent local messages. Each item should map to a conversation.summary.draft action payload and should not mutate messages.", + "files": [], + "expectations": [ + "Found recent active conversations with enough message history to summarize", + "Resolved each draft to a conversationId", + "Created or suggested conversation.summary.draft action payloads instead of writing summaries into messages or memories", + "Included summary, reason, timeWindow, confidence, and evidence for each draft", + "Did not mutate state unless an explicit approval/execution command was run" + ] + }, + { + "id": 41, + "prompt": "Find recent conversations that need a next step, but do not message anyone. Queue conversation-level follow-up recommendations.", + "expected_output": "A reviewable set of conversation follow-up recommendation actions grounded in recent local messages. Each item should map to a conversation.followup.recommend action payload and should not send messages.", + "files": [], + "expectations": [ + "Found recent active conversations that may need a next step", + "Resolved each recommendation to a conversationId", + "Created or suggested conversation.followup.recommend action payloads instead of sending messages", + "Included reason, suggestedNextStep when useful, confidence, and evidence for each recommendation", + "Did not mutate state unless an explicit approval/execution command was run" + ] } ] } diff --git a/src/actions/contact-merge-effects.ts b/src/actions/contact-merge-effects.ts new file mode 100644 index 00000000..7853c221 --- /dev/null +++ b/src/actions/contact-merge-effects.ts @@ -0,0 +1,34 @@ +import type { CuedDatabase } from "../db/database.js"; + +export interface ContactMergeAlias { + contact_id: string; + canonical_contact_id: string; +} + +function parseMergeEffectPayload(payloadJson: string | null): ContactMergeAlias | null { + if (!payloadJson) { + return null; + } + const payload = JSON.parse(payloadJson) as unknown; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return null; + } + const row = payload as Record; + if (typeof row.secondaryContactId !== "string" || typeof row.canonicalContactId !== "string") { + return null; + } + return { + contact_id: row.secondaryContactId, + canonical_contact_id: row.canonicalContactId, + }; +} + +export function listContactMergeAliases(db: CuedDatabase): ContactMergeAlias[] { + return db + .listActiveActionEffects({ + actionType: "contact.merge", + effectType: "contact.merge.recorded", + }) + .map((effect) => parseMergeEffectPayload(effect.payload_json)) + .filter((alias): alias is ContactMergeAlias => alias !== null); +} diff --git a/src/actions/execution.ts b/src/actions/execution.ts new file mode 100644 index 00000000..a9e1106a --- /dev/null +++ b/src/actions/execution.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { safeParseJson } from "../db/codecs.js"; +import type { ActionEffectRow, ActionRow, ContactMemoryRow } from "../db/database.js"; + +export interface ActionSkillDatabase { + addContactMemory(input: { + contactId: string; + body: string; + sourceKind?: string; + evidence?: unknown; + confidence?: number | null; + supersedesMemoryId?: string | null; + createdBy?: string | null; + }): ContactMemoryRow; + markContactMemoryStale(id: string, staleAt?: number | null): ContactMemoryRow | null; + moveContactMemoriesToContact(input: { + fromContactId: string; + toContactId: string; + updatedAt?: number; + }): void; + contactExists(id: string): boolean; + conversationExists(id: string): boolean; + recordActionEffect(input: { + actionId: string; + effectType: string; + targetTable?: string | null; + targetId?: string | null; + payload?: unknown; + appliedAt?: number; + }): ActionEffectRow; + listActiveActionEffects(input?: { + actionType?: string; + effectType?: string; + limit?: number; + }): ActionEffectRow[]; +} + +export interface ActionExecutionContext { + action: ActionRow; + db: ActionSkillDatabase; + executedBy: string; + helpers: ActionExecutionHelpers; +} + +export interface ActionExecutionResult { + result: unknown; + effects: ActionEffectRow[]; +} + +export type ActionExecutor = (context: ActionExecutionContext) => ActionExecutionResult; + +export interface ActionExecutionHelpers { + randomUUID: typeof randomUUID; + parseActionPayloadObject: typeof parseActionPayloadObject; + requiredStringPayload: typeof requiredStringPayload; + optionalStringPayload: typeof optionalStringPayload; + optionalNumberPayload: typeof optionalNumberPayload; + optionalObjectPayload: typeof optionalObjectPayload; +} + +export const actionExecutionHelpers: ActionExecutionHelpers = { + randomUUID, + parseActionPayloadObject, + requiredStringPayload, + optionalStringPayload, + optionalNumberPayload, + optionalObjectPayload, +}; + +export function parseActionPayloadObject(action: ActionRow): Record { + const payload = safeParseJson | null>( + action.payload_json, + `action:${action.id}:payload`, + null, + (value): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value), + ); + if (!payload) { + throw new Error(`Action payload must be a JSON object: ${action.id}`); + } + return payload; +} + +export function requiredStringPayload( + payload: Record, + field: string, + action: ActionRow, +): string { + const value = payload[field]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Action '${action.action_type}' payload field '${field}' must be a string.`); + } + return value; +} + +export function optionalStringPayload( + payload: Record, + field: string, + action: ActionRow, +): string | null { + const value = payload[field]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "string") { + throw new Error(`Action '${action.action_type}' payload field '${field}' must be a string.`); + } + return value; +} + +export function optionalNumberPayload( + payload: Record, + field: string, + action: ActionRow, +): number | null { + const value = payload[field]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Action '${action.action_type}' payload field '${field}' must be a number.`); + } + return value; +} + +export function optionalObjectPayload( + payload: Record, + field: string, + action: ActionRow, +): Record | null { + const value = payload[field]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Action '${action.action_type}' payload field '${field}' must be an object.`); + } + return value as Record; +} diff --git a/src/actions/executor-loader.ts b/src/actions/executor-loader.ts new file mode 100644 index 00000000..e544d9a4 --- /dev/null +++ b/src/actions/executor-loader.ts @@ -0,0 +1,33 @@ +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import type { ActionExecutor } from "./execution.js"; +import type { ActionDefinition } from "./registry.js"; + +const require = createRequire(import.meta.url); + +function resolveSkillModulePath(definition: ActionDefinition): string { + if (definition.module.startsWith("/") || definition.module.includes("..")) { + throw new Error(`Invalid action executor module path: ${definition.module}`); + } + const modulePath = resolve(definition.skillRoot, definition.module); + const skillRoot = resolve(definition.skillRoot); + if (!modulePath.startsWith(`${skillRoot}/`)) { + throw new Error(`Action executor module escapes skill root: ${definition.module}`); + } + return modulePath; +} + +function loadActionModule(definition: ActionDefinition): Record { + const modulePath = resolveSkillModulePath(definition); + delete require.cache[require.resolve(modulePath)]; + const loaded = require(modulePath) as unknown; + if (!loaded || typeof loaded !== "object") { + throw new Error(`Action executor module did not export an object: ${definition.module}`); + } + return loaded as Record; +} + +export function loadActionExecutor(definition: ActionDefinition): ActionExecutor | null { + const execute = loadActionModule(definition).execute; + return typeof execute === "function" ? (execute as ActionExecutor) : null; +} diff --git a/src/actions/registry.test.ts b/src/actions/registry.test.ts new file mode 100644 index 00000000..22641e63 --- /dev/null +++ b/src/actions/registry.test.ts @@ -0,0 +1,300 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, delimiter, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadActionExecutor } from "./executor-loader.js"; +import { + ActionDefinitionRegistry, + defaultActionDefinitionsPath, + defaultCuedSkillRoots, +} from "./registry.js"; + +describe("action definition registry", () => { + const tempDirs: string[] = []; + const originalCuedSkillRoot = process.env.CUED_SKILL_ROOT; + const originalCuedSkillRoots = process.env.CUED_SKILL_ROOTS; + const originalCuedHome = process.env.CUED_HOME; + + afterEach(() => { + if (originalCuedSkillRoot === undefined) { + delete process.env.CUED_SKILL_ROOT; + } else { + process.env.CUED_SKILL_ROOT = originalCuedSkillRoot; + } + if (originalCuedSkillRoots === undefined) { + delete process.env.CUED_SKILL_ROOTS; + } else { + process.env.CUED_SKILL_ROOTS = originalCuedSkillRoots; + } + if (originalCuedHome === undefined) { + delete process.env.CUED_HOME; + } else { + process.env.CUED_HOME = originalCuedHome; + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it("loads bundled Cued action definitions", () => { + const registry = ActionDefinitionRegistry.load(defaultActionDefinitionsPath()); + + expect( + registry + .list() + .map((definition) => `${definition.type}@${definition.version}:${definition.module}`), + ).toEqual([ + "contact.enrichment.recommend@1:actions/contact-enrichment-recommend.cjs", + "contact.followup.recommend@1:actions/contact-followup-recommend.cjs", + "contact.introduction.recommend@1:actions/contact-introduction-recommend.cjs", + "contact.memory.add@1:actions/contact-memory-add.cjs", + "contact.memory.stale@1:actions/contact-memory-stale.cjs", + "contact.merge@1:actions/contact-merge.cjs", + "contact.message.draft@1:actions/contact-message-draft.cjs", + "conversation.followup.recommend@1:actions/conversation-followup-recommend.cjs", + "conversation.summary.draft@1:actions/conversation-summary-draft.cjs", + ]); + expect(registry.get("contact.merge", "1")?.skillName).toBe("cued"); + expect( + registry.validatePayload("contact.merge", "1", { + primaryContactId: "contact-1", + secondaryContactId: "contact-2", + reason: "same normalized phone", + }), + ).toEqual({ ok: true, errors: [] }); + expect(registry.get("contact.merge", "1")?.postExecution).toEqual({ + rebuildProjection: true, + }); + expect(registry.get("contact.memory.add", "1")?.postExecution).toEqual({ + rebuildProjection: false, + }); + for (const definition of registry.list()) { + expect(loadActionExecutor(definition), definition.module).not.toBeNull(); + } + }); + + it("rejects unknown actions and invalid payloads", () => { + const registry = ActionDefinitionRegistry.load(defaultActionDefinitionsPath()); + + expect(registry.validatePayload("unknown.action", "1", {})).toEqual({ + ok: false, + errors: ["Unknown action definition: unknown.action@1"], + }); + expect( + registry.validatePayload("contact.merge", "1", { primaryContactId: "contact-1" }), + ).toEqual({ + ok: false, + errors: ["Missing required payload field 'secondaryContactId'."], + }); + expect( + registry.validatePayload("contact.memory.add", "1", { + contactId: "contact-1", + body: "Works on Cued", + evidence: "message-1", + }), + ).toEqual({ + ok: false, + errors: ["Payload field 'evidence' must be object."], + }); + }); + + it("rejects duplicate definitions", () => { + const dir = mkdtempSync(join(tmpdir(), "cued-actions-registry-")); + tempDirs.push(dir); + const path = join(dir, "actions"); + mkdirSync(path); + writeFileSync( + join(path, "one.json"), + JSON.stringify({ + type: "contact.merge", + version: "1", + description: "Merge contacts", + module: "actions/contact-merge.cjs", + payload: { required: {}, optional: {} }, + }), + ); + writeFileSync( + join(path, "two.json"), + JSON.stringify({ + type: "contact.merge", + version: "1", + description: "Merge contacts again", + module: "actions/contact-merge-duplicate.cjs", + payload: { required: {}, optional: {} }, + }), + ); + + expect(() => ActionDefinitionRegistry.load(path)).toThrow( + "Duplicate action definition: contact.merge@1", + ); + }); + + it("loads definitions and executors from an explicit skill root", () => { + const skillRoot = mkdtempSync(join(tmpdir(), "cued-external-skill-")); + tempDirs.push(skillRoot); + const actionsDir = join(skillRoot, "actions"); + mkdirSync(actionsDir); + writeFileSync(join(skillRoot, "SKILL.md"), "---\nname: cued-test\n---\n"); + writeFileSync( + join(actionsDir, "test.echo.json"), + JSON.stringify({ + type: "test.echo", + version: "1", + description: "Echo test action", + module: "actions/test-echo.cjs", + payload: { required: {}, optional: {} }, + }), + ); + writeFileSync( + join(actionsDir, "test-echo.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + ); + process.env.CUED_SKILL_ROOT = skillRoot; + + const definition = ActionDefinitionRegistry.load().get("test.echo", "1"); + expect(definition?.skillRoot).toBe(skillRoot); + expect(definition?.skillName).toBe(basename(skillRoot)); + expect(definition?.sourcePath).toBe(join(actionsDir, "test.echo.json")); + expect(definition ? loadActionExecutor(definition) : null).not.toBeNull(); + }); + + it("reloads modified local executor modules", () => { + const skillRoot = mkdtempSync(join(tmpdir(), "cued-reloadable-skill-")); + tempDirs.push(skillRoot); + const actionsDir = join(skillRoot, "actions"); + mkdirSync(actionsDir); + writeFileSync(join(skillRoot, "SKILL.md"), "---\nname: reloadable\n---\n"); + writeFileSync( + join(actionsDir, "test.reload.json"), + JSON.stringify({ + type: "test.reload", + version: "1", + description: "Reloadable test action", + module: "actions/test-reload.cjs", + payload: { required: {}, optional: {} }, + }), + ); + const modulePath = join(actionsDir, "test-reload.cjs"); + writeFileSync( + modulePath, + "module.exports = { execute: () => ({ result: { version: 1 }, effects: [] }) };\n", + ); + process.env.CUED_SKILL_ROOT = skillRoot; + + const definition = ActionDefinitionRegistry.load().get("test.reload", "1"); + const firstExecutor = definition ? loadActionExecutor(definition) : null; + expect((firstExecutor as unknown as () => { result: { version: number } })().result).toEqual({ + version: 1, + }); + + writeFileSync( + modulePath, + "module.exports = { execute: () => ({ result: { version: 2 }, effects: [] }) };\n", + ); + const secondExecutor = definition ? loadActionExecutor(definition) : null; + expect((secondExecutor as unknown as () => { result: { version: number } })().result).toEqual({ + version: 2, + }); + }); + + it("loads definitions from multiple explicit skill roots", () => { + const firstRoot = mkdtempSync(join(tmpdir(), "cued-external-skill-a-")); + const secondRoot = mkdtempSync(join(tmpdir(), "cued-external-skill-b-")); + tempDirs.push(firstRoot, secondRoot); + for (const [root, type] of [ + [firstRoot, "test.first"], + [secondRoot, "test.second"], + ] as const) { + const actionsDir = join(root, "actions"); + mkdirSync(actionsDir); + writeFileSync(join(root, "SKILL.md"), `---\nname: ${type}\n---\n`); + writeFileSync( + join(actionsDir, `${type}.json`), + JSON.stringify({ + type, + version: "1", + description: `${type} action`, + module: "actions/execute.cjs", + payload: { required: {}, optional: {} }, + }), + ); + writeFileSync( + join(actionsDir, "execute.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + ); + } + process.env.CUED_SKILL_ROOTS = [firstRoot, secondRoot].join(delimiter); + delete process.env.CUED_SKILL_ROOT; + + expect( + ActionDefinitionRegistry.load() + .list() + .map((definition) => definition.type), + ).toEqual(["test.first", "test.second"]); + }); + + it("lets daemon-local skills override bundled skills by skill name", () => { + const homeRoot = mkdtempSync(join(tmpdir(), "cued-local-skill-home-")); + tempDirs.push(homeRoot); + const localSkillRoot = join(homeRoot, "skills", "cued"); + const actionsDir = join(localSkillRoot, "actions"); + mkdirSync(actionsDir, { recursive: true }); + writeFileSync(join(localSkillRoot, "SKILL.md"), "---\nname: cued\n---\n"); + writeFileSync( + join(actionsDir, "test.local.json"), + JSON.stringify({ + type: "test.local", + version: "1", + description: "Local override action", + module: "actions/test-local.cjs", + payload: { required: {}, optional: {} }, + }), + ); + writeFileSync( + join(actionsDir, "test-local.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + ); + process.env.CUED_HOME = homeRoot; + + expect(defaultCuedSkillRoots()[0]).toBe(localSkillRoot); + expect( + ActionDefinitionRegistry.load() + .list() + .map((definition) => definition.type), + ).toEqual(["test.local"]); + }); + + it("rejects duplicate definitions across skill roots", () => { + const firstRoot = mkdtempSync(join(tmpdir(), "cued-external-skill-a-")); + const secondRoot = mkdtempSync(join(tmpdir(), "cued-external-skill-b-")); + tempDirs.push(firstRoot, secondRoot); + for (const root of [firstRoot, secondRoot]) { + const actionsDir = join(root, "actions"); + mkdirSync(actionsDir); + writeFileSync(join(root, "SKILL.md"), "---\nname: duplicate-test\n---\n"); + writeFileSync( + join(actionsDir, "test.echo.json"), + JSON.stringify({ + type: "test.echo", + version: "1", + description: "Duplicate action", + module: "actions/execute.cjs", + payload: { required: {}, optional: {} }, + }), + ); + writeFileSync( + join(actionsDir, "execute.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + ); + } + process.env.CUED_SKILL_ROOTS = [firstRoot, secondRoot].join(delimiter); + delete process.env.CUED_SKILL_ROOT; + + expect(() => ActionDefinitionRegistry.load()).toThrow( + "Duplicate action definition: test.echo@1", + ); + }); +}); diff --git a/src/actions/registry.ts b/src/actions/registry.ts new file mode 100644 index 00000000..ccb3d6a1 --- /dev/null +++ b/src/actions/registry.ts @@ -0,0 +1,328 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ACTION_PAYLOAD_FIELD_TYPE_VALUES = ["string", "number", "boolean", "object"] as const; +export type ActionPayloadFieldType = (typeof ACTION_PAYLOAD_FIELD_TYPE_VALUES)[number]; + +export interface ActionDefinition { + type: string; + version: string; + description: string; + module: string; + skillName: string; + skillRoot: string; + sourcePath: string; + postExecution: { + rebuildProjection: boolean; + }; + requiresApprovalDefault: boolean; + payload: { + required: Record; + optional: Record; + }; +} + +interface ActionDefinitionManifest { + version: number; + actions: ActionDefinition[]; +} + +export interface ActionPayloadValidationResult { + ok: boolean; + errors: string[]; +} + +function moduleRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +} + +function uniqueExistingSkillRoots( + paths: string[], + options: { bySkillName?: boolean } = {}, +): string[] { + const seen = new Set(); + const roots: string[] = []; + for (const path of paths) { + const root = resolve(path); + const key = options.bySkillName ? basename(root) : root; + if (seen.has(key) || !existsSync(join(root, "SKILL.md"))) { + continue; + } + seen.add(key); + roots.push(root); + } + return roots; +} + +function listSkillRoots(parentPath: string): string[] { + if (!existsSync(parentPath) || !statSync(parentPath).isDirectory()) { + return []; + } + return readdirSync(parentPath) + .sort() + .map((entry) => join(parentPath, entry)) + .filter((entryPath) => existsSync(join(entryPath, "SKILL.md"))); +} + +function envSkillRoots(): string[] { + const raw = process.env.CUED_SKILL_ROOTS ?? process.env.CUED_SKILL_ROOT; + if (!raw) { + return []; + } + return raw + .split(delimiter) + .map((value) => value.trim()) + .filter(Boolean); +} + +export function defaultCuedSkillRoots(): string[] { + const currentRoot = moduleRoot(); + const cuedHome = process.env.CUED_HOME ?? join(homedir(), ".cued"); + const explicitRoots = uniqueExistingSkillRoots(envSkillRoots()); + if (explicitRoots.length > 0) { + return explicitRoots; + } + + return uniqueExistingSkillRoots( + [ + ...listSkillRoots(join(cuedHome, "skills")), + join(cuedHome, "skills", "cued"), + ...listSkillRoots(join(currentRoot, "skills")), + ...listSkillRoots(join(currentRoot, "..", "skills")), + join(currentRoot, "skills", "cued"), + join(currentRoot, "..", "skills", "cued"), + ], + { bySkillName: true }, + ); +} + +export function defaultCuedSkillRoot(): string { + const roots = defaultCuedSkillRoots(); + if (roots.length === 0) { + return join(moduleRoot(), "skills", "cued"); + } + return roots[0]!; +} + +export function defaultActionDefinitionsPath(): string { + return join(defaultCuedSkillRoot(), "actions"); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isFieldType(value: unknown): value is ActionPayloadFieldType { + return ACTION_PAYLOAD_FIELD_TYPE_VALUES.includes(value as ActionPayloadFieldType); +} + +function parsePayloadShape(value: unknown, actionType: string): ActionDefinition["payload"] { + if (!isPlainObject(value)) { + throw new Error(`Action '${actionType}' payload definition must be an object.`); + } + const required = isPlainObject(value.required) ? value.required : {}; + const optional = isPlainObject(value.optional) ? value.optional : {}; + + for (const [field, type] of [...Object.entries(required), ...Object.entries(optional)]) { + if (!isFieldType(type)) { + throw new Error( + `Action '${actionType}' payload field '${field}' must use one of: ${ACTION_PAYLOAD_FIELD_TYPE_VALUES.join(", ")}.`, + ); + } + } + + return { + required: required as Record, + optional: optional as Record, + }; +} + +function parseActionDefinition( + raw: unknown, + index: number, + sourcePath: string, + skillRoot: string, +): ActionDefinition { + if (!isPlainObject(raw)) { + throw new Error(`Action definition at index ${index} must be an object.`); + } + const type = typeof raw.type === "string" ? raw.type.trim() : ""; + const version = typeof raw.version === "string" ? raw.version.trim() : ""; + const description = typeof raw.description === "string" ? raw.description.trim() : ""; + const module = typeof raw.module === "string" ? raw.module.trim() : ""; + if (!type || !version || !description || !module) { + throw new Error( + `Action definition at index ${index} must include type, version, description, and module: ${sourcePath}`, + ); + } + + return { + type, + version, + description, + module, + skillName: basename(skillRoot), + skillRoot, + sourcePath, + postExecution: { + rebuildProjection: + isPlainObject(raw.postExecution) && raw.postExecution.rebuildProjection === true, + }, + requiresApprovalDefault: raw.requiresApprovalDefault !== false, + payload: parsePayloadShape(raw.payload, type), + }; +} + +function assertUniqueDefinitions(actions: ActionDefinition[]): void { + const seen = new Set(); + for (const definition of actions) { + const key = `${definition.type}@${definition.version}`; + if (seen.has(key)) { + throw new Error(`Duplicate action definition: ${key}`); + } + seen.add(key); + } +} + +function parseManifest( + raw: unknown, + sourcePath: string, + skillRoot: string, +): ActionDefinitionManifest { + if (!isPlainObject(raw)) { + throw new Error(`Action definition manifest must be an object: ${sourcePath}`); + } + if (raw.version !== 1) { + throw new Error(`Unsupported action definition manifest version in ${sourcePath}.`); + } + if (!Array.isArray(raw.actions)) { + throw new Error(`Action definition manifest must include an actions array: ${sourcePath}`); + } + + const actions = raw.actions.map((value, index) => + parseActionDefinition(value, index, sourcePath, skillRoot), + ); + assertUniqueDefinitions(actions); + + return { version: 1, actions }; +} + +function loadActionDefinitions(path: string): ActionDefinition[] { + if (!existsSync(path)) { + return []; + } + if (statSync(path).isDirectory()) { + const skillRoot = dirname(path); + const actions = readdirSync(path) + .filter((fileName) => fileName.endsWith(".json")) + .sort() + .map((fileName, index) => { + const filePath = join(path, fileName); + return parseActionDefinition( + JSON.parse(readFileSync(filePath, "utf8")) as unknown, + index, + filePath, + skillRoot, + ); + }); + assertUniqueDefinitions(actions); + return actions; + } + return parseManifest(JSON.parse(readFileSync(path, "utf8")) as unknown, path, dirname(path)) + .actions; +} + +function loadDefaultActionDefinitions(): ActionDefinition[] { + return defaultCuedSkillRoots().flatMap((skillRoot) => + loadActionDefinitions(join(skillRoot, "actions")), + ); +} + +export class ActionDefinitionRegistry { + private readonly definitions = new Map(); + + constructor(definitions: ActionDefinition[]) { + for (const definition of definitions) { + const key = this.key(definition.type, definition.version); + if (this.definitions.has(key)) { + throw new Error(`Duplicate action definition: ${key}`); + } + this.definitions.set(key, definition); + } + } + + static load(path?: string): ActionDefinitionRegistry { + return new ActionDefinitionRegistry( + path ? loadActionDefinitions(path) : loadDefaultActionDefinitions(), + ); + } + + list(): ActionDefinition[] { + return [...this.definitions.values()].sort((left, right) => + `${left.type}@${left.version}`.localeCompare(`${right.type}@${right.version}`), + ); + } + + get(type: string, version = "1"): ActionDefinition | null { + return this.definitions.get(this.key(type, version)) ?? null; + } + + validatePayload(type: string, version: string, payload: unknown): ActionPayloadValidationResult { + const definition = this.get(type, version); + if (!definition) { + return { ok: false, errors: [`Unknown action definition: ${type}@${version}`] }; + } + if (!isPlainObject(payload)) { + return { ok: false, errors: [`Action '${type}' payload must be a JSON object.`] }; + } + + const errors: string[] = []; + for (const [field, expectedType] of Object.entries(definition.payload.required)) { + if (!(field in payload)) { + errors.push(`Missing required payload field '${field}'.`); + continue; + } + if (!matchesFieldType(payload[field], expectedType)) { + errors.push(`Payload field '${field}' must be ${expectedType}.`); + } + } + for (const [field, expectedType] of Object.entries(definition.payload.optional)) { + if ( + field in payload && + payload[field] !== null && + !matchesFieldType(payload[field], expectedType) + ) { + errors.push(`Payload field '${field}' must be ${expectedType}.`); + } + } + + return { ok: errors.length === 0, errors }; + } + + private key(type: string, version: string): string { + return `${type}@${version}`; + } +} + +export function actionRequiresProjectionRebuild(action: { + action_type: string; + action_version: string; +}): boolean { + return ( + ActionDefinitionRegistry.load().get(action.action_type, action.action_version)?.postExecution + .rebuildProjection === true + ); +} + +function matchesFieldType(value: unknown, expectedType: ActionPayloadFieldType): boolean { + switch (expectedType) { + case "object": + return isPlainObject(value); + case "string": + case "number": + case "boolean": + return typeof value === expectedType; + } +} diff --git a/src/cli-actions.test.ts b/src/cli-actions.test.ts new file mode 100644 index 00000000..b1eb70b7 --- /dev/null +++ b/src/cli-actions.test.ts @@ -0,0 +1,255 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CuedDatabase } from "./db/database.js"; + +describe("actions CLI", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function createHome(): string { + const home = mkdtempSync(join(tmpdir(), "cued-cli-actions-")); + mkdirSync(join(home, "tmp"), { recursive: true }); + tempDirs.push(home); + runCli(home, ["status"]); + return home; + } + + function runCli(home: string, args: string[]): string { + return execFileSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CUED_DB_KEY: "test-encryption-key", + CUED_HOME: home, + TMPDIR: join(home, "tmp"), + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } + + function insertContact(home: string, id: string, name: string): void { + const originalDbKey = process.env.CUED_DB_KEY; + process.env.CUED_DB_KEY = "test-encryption-key"; + const db = new CuedDatabase(join(home, "local.db")); + try { + ( + db as unknown as { + sqlite: { + prepare: (sql: string) => { + run: (...params: unknown[]) => void; + }; + }; + } + ).sqlite + .prepare( + "INSERT INTO contacts (id, kind, name, photo_url, company, archived, created_at, updated_at) VALUES (?, 'person', ?, NULL, NULL, 0, 1, 1)", + ) + .run(id, name); + } finally { + db.close(); + if (originalDbKey === undefined) { + delete process.env.CUED_DB_KEY; + } else { + process.env.CUED_DB_KEY = originalDbKey; + } + } + } + + it("proposes, lists, shows, and approves actions", () => { + const home = createHome(); + const definitions = JSON.parse(runCli(home, ["actions", "definitions"])) as Array<{ + type: string; + version: string; + }>; + expect(definitions.map((definition) => `${definition.type}@${definition.version}`)).toContain( + "contact.merge@1", + ); + + const proposed = JSON.parse( + runCli(home, [ + "actions", + "propose", + "contact.merge", + "--payload", + '{"primaryContactId":"contact-1","secondaryContactId":"contact-2"}', + "--title", + "Merge duplicate contact", + "--summary", + "Same email address", + "--source-skill", + "cued", + ]), + ) as { id: string; status: string; approval_status: string; payload_hash: string }; + + expect(proposed).toMatchObject({ + status: "proposed", + approval_status: "pending", + payload_hash: expect.any(String), + }); + + const listed = JSON.parse(runCli(home, ["actions", "list", "--status", "proposed"])) as Array<{ + id: string; + action_type: string; + }>; + expect(listed).toEqual([ + expect.objectContaining({ id: proposed.id, action_type: "contact.merge" }), + ]); + + const shown = JSON.parse(runCli(home, ["actions", "show", proposed.id])) as { + action: { id: string; source_skill: string }; + effects: unknown[]; + }; + expect(shown).toEqual({ + action: expect.objectContaining({ id: proposed.id, source_skill: "cued" }), + effects: [], + }); + + const approved = JSON.parse( + runCli(home, ["actions", "approve", proposed.id, "--by", "soham"]), + ) as { + id: string; + status: string; + approval_status: string; + approved_by: string; + }; + expect(approved).toMatchObject({ + id: proposed.id, + status: "approved", + approval_status: "approved", + approved_by: "soham", + }); + }); + + it("can auto-approve or deny proposed actions", () => { + const home = createHome(); + const autoApproved = JSON.parse( + runCli(home, [ + "actions", + "propose", + "contact.memory.add", + "--payload", + '{"contactId":"contact-1","body":"Met at demo day"}', + "--no-approval", + ]), + ) as { id: string; status: string; approval_status: string }; + expect(autoApproved).toMatchObject({ + status: "approved", + approval_status: "auto_approved", + }); + + const pending = JSON.parse( + runCli(home, [ + "actions", + "propose", + "contact.memory.stale", + "--payload", + '{"memoryId":"memory-1"}', + ]), + ) as { id: string }; + const denied = JSON.parse(runCli(home, ["actions", "deny", pending.id, "--by", "soham"])) as { + id: string; + status: string; + approval_status: string; + }; + expect(denied).toMatchObject({ + id: pending.id, + status: "denied", + approval_status: "denied", + }); + }); + + it("validates action definitions before queueing", () => { + const home = createHome(); + + expect(() => runCli(home, ["actions", "propose", "unknown.action", "--payload", "{}"])).toThrow( + "Unknown action definition: unknown.action@1", + ); + expect(() => + runCli(home, [ + "actions", + "propose", + "contact.merge", + "--payload", + '{"primaryContactId":"contact-1"}', + ]), + ).toThrow("Missing required payload field 'secondaryContactId'."); + }); + + it("executes approved actions", () => { + const home = createHome(); + insertContact(home, "contact-1", "Ava Chen"); + + const action = JSON.parse( + runCli(home, [ + "actions", + "propose", + "contact.memory.add", + "--payload", + '{"contactId":"contact-1","body":"Met at demo day","sourceKind":"local_messages"}', + ]), + ) as { id: string }; + runCli(home, ["actions", "approve", action.id, "--by", "soham"]); + const executed = JSON.parse( + runCli(home, ["actions", "execute", action.id, "--by", "runner"]), + ) as { + action: { status: string; execution_status: string; executed_by: string }; + effects: Array<{ effect_type: string; target_table: string; target_id: string }>; + }; + + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + executed_by: "runner", + }); + expect(executed.effects).toEqual([ + expect.objectContaining({ + effect_type: "contact_memory.added", + target_table: "contact_memories", + target_id: expect.any(String), + }), + ]); + }); + + it("runs approved pending actions in a batch", () => { + const home = createHome(); + insertContact(home, "contact-1", "Ava Chen"); + + const action = JSON.parse( + runCli(home, [ + "actions", + "propose", + "contact.memory.add", + "--payload", + '{"contactId":"contact-1","body":"Batch memory"}', + "--no-approval", + ]), + ) as { id: string }; + const result = JSON.parse( + runCli(home, ["actions", "run-approved", "--limit", "5", "--by", "batch-runner"]), + ) as { + attempted: number; + succeeded: number; + failed: number; + results: Array<{ actionId: string; ok: boolean }>; + }; + + expect(result).toEqual({ + attempted: 1, + succeeded: 1, + failed: 0, + results: [expect.objectContaining({ actionId: action.id, ok: true })], + }); + }); +}); diff --git a/src/cli-contacts-memory.test.ts b/src/cli-contacts-memory.test.ts index ace1d835..7418f955 100644 --- a/src/cli-contacts-memory.test.ts +++ b/src/cli-contacts-memory.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -19,6 +19,7 @@ describe("contacts memory CLI", () => { function createHome(): string { const home = mkdtempSync(join(tmpdir(), "cued-cli-memory-")); + mkdirSync(join(home, "tmp"), { recursive: true }); tempDirs.push(home); runCli(home, ["status"]); insertContact(home, "contact-1", "Ava Chen"); @@ -26,6 +27,8 @@ describe("contacts memory CLI", () => { } function insertContact(home: string, id: string, name: string): void { + const originalDbKey = process.env.CUED_DB_KEY; + process.env.CUED_DB_KEY = "test-encryption-key"; const db = new CuedDatabase(join(home, "local.db")); try { ( @@ -43,21 +46,31 @@ describe("contacts memory CLI", () => { .run(id, name); } finally { db.close(); + if (originalDbKey === undefined) { + delete process.env.CUED_DB_KEY; + } else { + process.env.CUED_DB_KEY = originalDbKey; + } } } function runCli(home: string, args: string[]): string { - return execFileSync("pnpm", ["--silent", "exec", "tsx", "src/cli.ts", ...args], { + return execFileSync("node", ["--import", "tsx", "src/cli.ts", ...args], { cwd: process.cwd(), - env: { ...process.env, CUED_HOME: home }, + env: { + ...process.env, + CUED_DB_KEY: "test-encryption-key", + CUED_HOME: home, + TMPDIR: join(home, "tmp"), + }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } - it("adds, supersedes, and lists current contact memories", () => { + it("executes contact memory actions and lists current memories", () => { const home = createHome(); - const first = JSON.parse( + const firstExecuted = JSON.parse( runCli(home, [ "contacts", "memory", @@ -70,13 +83,17 @@ describe("contacts memory CLI", () => { "80", "--evidence", '{"message_ids":["message-1"]}', + "--execute", ]), - ) as { id: string; confidence: number; evidence_json: string }; + ) as { + result: { memory: { id: string; confidence: number; evidence_json: string } }; + }; + const first = firstExecuted.result.memory; expect(first.confidence).toBe(80); expect(JSON.parse(first.evidence_json)).toEqual({ message_ids: ["message-1"] }); - const second = JSON.parse( + const secondExecuted = JSON.parse( runCli(home, [ "contacts", "memory", @@ -92,8 +109,12 @@ describe("contacts memory CLI", () => { '{"message_ids":["message-2"]}', "--supersedes", first.id, + "--execute", ]), - ) as { id: string; supersedes_memory_id: string }; + ) as { + result: { memory: { id: string; supersedes_memory_id: string } }; + }; + const second = secondExecuted.result.memory; expect(second.supersedes_memory_id).toBe(first.id); @@ -126,43 +147,100 @@ describe("contacts memory CLI", () => { ).toThrow(); }); - it("dry-runs contact merge batches from a JSON file", () => { + it("rejects the removed queue flag because queueing is the default", () => { + const home = createHome(); + + expect(() => + runCli(home, ["contacts", "memory", "add", "contact-1", "Queued memory", "--queue"]), + ).toThrow(); + }); + + it("queues and executes contact memory actions from contact commands", () => { const home = createHome(); - insertContact(home, "contact-2", "Ava Duplicate"); - const batchPath = join(home, "merge-batch.json"); - writeFileSync( - batchPath, - JSON.stringify([ - { - primaryContactId: "contact-1", - secondaryContactId: "contact-2", - reason: "exact email match", - }, + const queued = JSON.parse( + runCli(home, [ + "contacts", + "memory", + "add", + "contact-1", + "Queued memory", + "--source", + "local_messages", ]), - ); - - const result = JSON.parse(runCli(home, ["contacts", "merge-batch", batchPath])) as { - applied: boolean; - mergeCount: number; - decisions: Array<{ - primaryContactId: string; - secondaryContactId: string; - canonicalContactId: string; - reason: string; - }>; + ) as { id: string; action_type: string; status: string }; + expect(queued).toMatchObject({ + action_type: "contact.memory.add", + status: "proposed", + }); + + const executed = JSON.parse( + runCli(home, [ + "contacts", + "memory", + "add", + "contact-1", + "Executed memory", + "--execute", + "--source", + "local_messages", + "--by", + "runner", + ]), + ) as { + action: { action_type: string; status: string; executed_by: string }; + effects: Array<{ effect_type: string; target_table: string; target_id: string }>; }; + expect(executed.action).toMatchObject({ + action_type: "contact.memory.add", + status: "executed", + executed_by: "runner", + }); + expect(executed.effects).toEqual([ + expect.objectContaining({ + effect_type: "contact_memory.added", + target_table: "contact_memories", + }), + ]); + }); + + it("queues stale actions by default and mutates only on execute", () => { + const home = createHome(); + const added = JSON.parse( + runCli(home, ["contacts", "memory", "add", "contact-1", "Memory to stale", "--execute"]), + ) as { result: { memory: { id: string } } }; + const memoryId = added.result.memory.id; + + const queued = JSON.parse(runCli(home, ["contacts", "memory", "stale", memoryId])) as { + action_type: string; + status: string; + }; + expect(queued).toMatchObject({ + action_type: "contact.memory.stale", + status: "proposed", + }); + + const beforeExecute = JSON.parse( + runCli(home, ["contacts", "memory", "list", "contact-1"]), + ) as Array<{ id: string; stale_at: number | null }>; + expect(beforeExecute).toEqual([expect.objectContaining({ id: memoryId, stale_at: null })]); - expect(result).toMatchObject({ - applied: false, - mergeCount: 1, - decisions: [ - { - primaryContactId: "contact-1", - secondaryContactId: "contact-2", - canonicalContactId: "contact-1", - reason: "exact email match", - }, - ], + const executed = JSON.parse( + runCli(home, ["contacts", "memory", "stale", memoryId, "--execute", "--by", "runner"]), + ) as { + action: { action_type: string; status: string; executed_by: string }; + effects: Array<{ effect_type: string; target_table: string; target_id: string }>; + }; + expect(executed.action).toMatchObject({ + action_type: "contact.memory.stale", + status: "executed", + executed_by: "runner", }); + expect(executed.effects).toEqual([ + expect.objectContaining({ + effect_type: "contact_memory.marked_stale", + target_table: "contact_memories", + target_id: memoryId, + }), + ]); }); }); diff --git a/src/cli.ts b/src/cli.ts index 3cd03997..1da90be3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,8 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { DaemonRequestTimeoutError, sendDaemonRequest } from "./client.js"; +import { ActionDefinitionRegistry, actionRequiresProjectionRebuild } from "./actions/registry.js"; +import { type DaemonRequestInput, DaemonRequestTimeoutError, sendDaemonRequest } from "./client.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "./core/app-metadata.js"; import { CUED_DB_PATH, CUED_SOCKET_PATH, ensureCuedDirs } from "./core/config.js"; import { resolveHostOS } from "./core/platform-capabilities.js"; @@ -31,6 +32,8 @@ import { listMenuBarIntegrationStates, } from "./platforms/core/state/status.js"; import { + ACTION_STATUS_VALUES, + type ActionStatus, getPlatformFeatureMatrixRow, getPlatformFutureFeatureNotes, getPlatformHelperRequirements, @@ -57,6 +60,7 @@ import { } from "./runtime/logs.js"; import { readMenuBarStatusCache } from "./runtime/menu-bar-status-cache.js"; import { buildOnboardingSnapshot } from "./runtime/onboarding.js"; +import { rebuildProjectedState } from "./runtime/projection/projector.js"; import { runProjectionWorkerFromEnv } from "./runtime/projection/worker.js"; import { checkForUpdates, @@ -68,7 +72,12 @@ import { setUpdateHelperLastError, } from "./runtime/updater/service.js"; import { runSetupTUI } from "./setup.js"; -import { getGlobalCuedSkillStatus, installGlobalCuedSkill } from "./skills/install.js"; +import { + getGlobalCuedSkillStatus, + getLocalCuedSkillStatus, + installGlobalCuedSkill, + installLocalCuedSkill, +} from "./skills/install.js"; import { getTelemetryStatus, sendTelemetryEvent, setTelemetryEnabled } from "./telemetry/client.js"; import { durationBucket, @@ -138,9 +147,17 @@ Usage: cued login-item enable|disable|status cued onboarding complete|snapshot|status [--refresh-managed] [--refresh-permissions] cued telemetry status|enable|disable|smoke - cued skill install-global|status - cued permissions doctor|status|request [--all|--contacts|--full-disk-access] + cued skill install-global|install-local [skill-root]|status|status-local [skill-name] + cued permissions doctor|status|request [--all|--contacts|--messages|--full-disk-access] cued sql + cued actions definitions + cued actions propose --payload JSON [--version VERSION] [--title TEXT] [--summary TEXT] [--source-skill NAME] [--no-approval] + cued actions list [--status STATUS] [--limit N] + cued actions show + cued actions approve [--by ACTOR] + cued actions deny [--by ACTOR] + cued actions execute [--by ACTOR] + cued actions run-approved [--limit N] [--by ACTOR] cued integrations list cued integrations status cued integrations capabilities @@ -152,11 +169,9 @@ Usage: cued integrations remove [account] cued integrations enable [account] cued integrations disable [account] - cued contacts merge [--reason TEXT] - cued contacts merge-batch [--apply] - cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] + cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] [--execute] cued contacts memory list [--limit N] [--include-stale] - cued contacts memory stale + cued contacts memory stale [--execute] cued attachments list [--message ID] [--conversation ID] [--platform PLATFORM] [--account ACCOUNT] [--limit N] cued attachments fetch [--variant original] [--max-bytes N] [--allow-large] [--no-extract] cued attachments search [--conversation ID] [--platform PLATFORM] [--account ACCOUNT] [--limit N] @@ -212,6 +227,17 @@ function parseJsonFlag(args: string[], flag: string): unknown { } } +function parseActionStatusFlag(args: string[], flag: string): ActionStatus | undefined { + const raw = parseFlagValue(args, flag); + if (raw === undefined) { + return undefined; + } + if (!ACTION_STATUS_VALUES.includes(raw as ActionStatus)) { + throw new Error(`${flag} must be one of: ${ACTION_STATUS_VALUES.join(", ")}.`); + } + return raw as ActionStatus; +} + function parseFreeTextArgument(args: string[], startIndex: number): string | undefined { const bodyTokens = args.slice(startIndex).filter((value, index, values) => { if (value.startsWith("--")) { @@ -223,34 +249,10 @@ function parseFreeTextArgument(args: string[], startIndex: number): string | und return bodyTokens.length > 0 ? bodyTokens.join(" ") : undefined; } -function parseContactMergeBatchFile(path: string): Array<{ - primaryContactId: string; - secondaryContactId: string; - reason?: string | null; -}> { - const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; - if (!Array.isArray(parsed)) { - throw new Error("Merge batch file must contain a JSON array."); +function assertNoLegacyQueueFlag(args: string[]): void { + if (args.includes("--queue")) { + throw new Error("--queue was removed because contacts memory commands queue by default."); } - return parsed.map((value, index) => { - if (!value || typeof value !== "object") { - throw new Error(`Merge batch item ${index} must be an object.`); - } - const item = value as Record; - if (typeof item.primaryContactId !== "string" || typeof item.secondaryContactId !== "string") { - throw new Error( - `Merge batch item ${index} must include primaryContactId and secondaryContactId strings.`, - ); - } - if (item.reason !== undefined && item.reason !== null && typeof item.reason !== "string") { - throw new Error(`Merge batch item ${index} reason must be a string when provided.`); - } - return { - primaryContactId: item.primaryContactId, - secondaryContactId: item.secondaryContactId, - reason: item.reason ?? null, - }; - }); } function getAppStatusMetadata(db: ReturnType) { @@ -425,6 +427,39 @@ function isUnsupportedDaemonCommand(response: { ok: boolean; error?: string }): return !response.ok && response.error === "Unsupported command"; } +async function sendOptionalDaemonRequest( + request: DaemonRequestInput, +): Promise> | null> { + try { + const response = await sendDaemonRequest(request); + return isUnsupportedDaemonCommand(response) ? null : response; + } catch { + return null; + } +} + +function printDaemonResultOrThrow( + response: Awaited>, + fallbackError: string, +): void { + if (!response.ok) { + throw new Error(response.error ?? fallbackError); + } + printJson(response.result ?? null); +} + +async function printOptionalDaemonResult( + request: DaemonRequestInput, + fallbackError: string, +): Promise { + const response = await sendOptionalDaemonRequest(request); + if (!response) { + return false; + } + printDaemonResultOrThrow(response, fallbackError); + return true; +} + async function main(): Promise { ensureCuedDirs(); @@ -759,11 +794,19 @@ async function main(): Promise { case "install-global": printJson(installGlobalCuedSkill()); return; + case "install-local": + printJson(installLocalCuedSkill(rest[0])); + return; case "status": printJson(getGlobalCuedSkillStatus()); return; + case "status-local": + printJson(getLocalCuedSkillStatus(rest[0])); + return; default: - throw new Error("Usage: cued skill install-global | status"); + throw new Error( + "Usage: cued skill install-global | install-local [skill-root] | status | status-local [skill-name]", + ); } case "telemetry": { const db = openCuedDatabase(); @@ -964,6 +1007,240 @@ async function main(): Promise { } return; } + case "actions": { + switch (subcommand) { + case "definitions": + printJson(ActionDefinitionRegistry.load().list()); + return; + case "propose": { + const actionType = rest[0]; + const actionVersion = parseFlagValue(rest, "--version") ?? "1"; + const payload = parseJsonFlag(rest, "--payload"); + if (!actionType || payload === undefined) { + throw new Error( + "Usage: cued actions propose --payload JSON [--version VERSION] [--title TEXT] [--summary TEXT] [--source-skill NAME] [--no-approval]", + ); + } + const requiresApproval = rest.includes("--no-approval") ? false : undefined; + const title = parseFlagValue(rest, "--title") ?? null; + const summary = parseFlagValue(rest, "--summary") ?? null; + const sourceSkill = parseFlagValue(rest, "--source-skill") ?? null; + const createdBy = parseFlagValue(rest, "--created-by") ?? "cued-cli"; + const dedupeKey = parseFlagValue(rest, "--dedupe-key") ?? null; + if ( + await printOptionalDaemonResult( + { + command: "actions-propose", + actionType, + actionVersion, + payload, + title, + summary, + sourceSkill, + createdBy, + requiresApproval, + dedupeKey, + }, + "Daemon actions propose failed", + ) + ) { + return; + } + const registry = ActionDefinitionRegistry.load(); + const definition = registry.get(actionType, actionVersion); + if (!definition) { + throw new Error(`Unknown action definition: ${actionType}@${actionVersion}`); + } + const validation = registry.validatePayload(actionType, actionVersion, payload); + if (!validation.ok) { + throw new Error(validation.errors.join(" ")); + } + const db = openCuedDatabase(); + try { + printJson( + db.createAction({ + actionType, + actionVersion, + title, + summary, + payload, + sourceSkill: sourceSkill ?? definition.skillName, + createdBy, + requiresApproval: requiresApproval ?? definition.requiresApprovalDefault, + dedupeKey, + }), + ); + } finally { + db.close(); + } + return; + } + case "list": + { + const status = parseActionStatusFlag(rest, "--status"); + const limit = parseIntegerFlag(rest, "--limit"); + if ( + await printOptionalDaemonResult( + { command: "actions-list", status, limit }, + "Daemon actions list failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + printJson(db.listActions({ status, limit })); + } finally { + db.close(); + } + } + return; + case "show": { + const actionId = rest[0]; + if (!actionId) { + throw new Error("Usage: cued actions show "); + } + if ( + await printOptionalDaemonResult( + { command: "actions-show", actionId }, + "Daemon actions show failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + const action = db.getAction(actionId); + if (!action) { + throw new Error(`Action not found: ${actionId}`); + } + printJson({ + action, + effects: db.listActionEffects(actionId), + }); + } finally { + db.close(); + } + return; + } + case "approve": { + const actionId = rest[0]; + if (!actionId) { + throw new Error("Usage: cued actions approve [--by ACTOR]"); + } + const approvedBy = parseFlagValue(rest, "--by") ?? "user"; + if ( + await printOptionalDaemonResult( + { command: "actions-approve", actionId, approvedBy }, + "Daemon actions approve failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + printJson(db.approveAction(actionId, approvedBy)); + } finally { + db.close(); + } + return; + } + case "deny": { + const actionId = rest[0]; + if (!actionId) { + throw new Error("Usage: cued actions deny [--by ACTOR]"); + } + const deniedBy = parseFlagValue(rest, "--by") ?? "user"; + if ( + await printOptionalDaemonResult( + { command: "actions-deny", actionId, deniedBy }, + "Daemon actions deny failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + printJson(db.denyAction(actionId, deniedBy)); + } finally { + db.close(); + } + return; + } + case "execute": { + const actionId = rest[0]; + if (!actionId) { + throw new Error("Usage: cued actions execute [--by ACTOR]"); + } + const executedBy = parseFlagValue(rest, "--by") ?? "cued-cli"; + if ( + await printOptionalDaemonResult( + { command: "actions-execute", actionId, executedBy }, + "Daemon actions execute failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + const executed = db.executeApprovedAction(actionId, executedBy); + printJson({ + ...executed, + projection: actionRequiresProjectionRebuild(executed.action) + ? rebuildProjectedState(db) + : null, + }); + } finally { + db.close(); + } + return; + } + case "run-approved": { + const limit = parseIntegerFlag(rest, "--limit"); + const executedBy = parseFlagValue(rest, "--by") ?? "cued-cli"; + if ( + await printOptionalDaemonResult( + { command: "actions-run-approved", limit, executedBy }, + "Daemon actions run-approved failed", + ) + ) { + return; + } + const db = openCuedDatabase(); + try { + const approved = db.listApprovedPendingActions(limit ?? 25); + const results = approved.map((action) => { + try { + const executed = db.executeApprovedAction(action.id, executedBy); + if (actionRequiresProjectionRebuild(executed.action)) { + rebuildProjectedState(db); + } + return { actionId: action.id, ok: true, result: executed }; + } catch (error) { + return { + actionId: action.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + printJson({ + attempted: results.length, + succeeded: results.filter((result) => result.ok).length, + failed: results.filter((result) => !result.ok).length, + results, + }); + } finally { + db.close(); + } + return; + } + default: + throw new Error( + "Usage: cued actions definitions | cued actions propose --payload JSON [--version VERSION] [--title TEXT] [--summary TEXT] [--source-skill NAME] [--no-approval] | cued actions list [--status STATUS] [--limit N] | cued actions show | cued actions approve [--by ACTOR] | cued actions deny [--by ACTOR] | cued actions execute [--by ACTOR] | cued actions run-approved [--limit N] [--by ACTOR]", + ); + } + } case "status": try { response = await sendDaemonRequest({ command: "status" }); @@ -1165,6 +1442,7 @@ async function main(): Promise { if (subcommand === "memory" || subcommand === "memories") { const memoryCommand = rest[0]; const args = rest.slice(1); + assertNoLegacyQueueFlag(args); const db = openCuedDatabase(); try { switch (memoryCommand) { @@ -1173,19 +1451,30 @@ async function main(): Promise { const body = parseFlagValue(args, "--body") ?? parseFreeTextArgument(args, 1); if (!contactId || !body) { throw new Error( - "Usage: cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID]", + "Usage: cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] [--execute]", ); } + const payload = { + contactId, + body, + sourceKind: parseFlagValue(args, "--source") ?? "agent", + evidence: parseJsonFlag(args, "--evidence"), + confidence: parseIntegerFlag(args, "--confidence") ?? null, + supersedesMemoryId: parseFlagValue(args, "--supersedes") ?? null, + }; + const action = db.createAction({ + actionType: "contact.memory.add", + payload, + title: "Add contact memory", + summary: body, + sourceSkill: "cued", + createdBy: parseFlagValue(args, "--created-by") ?? "cued-cli", + requiresApproval: !args.includes("--execute"), + }); printJson( - db.addContactMemory({ - contactId, - body, - sourceKind: parseFlagValue(args, "--source") ?? "agent", - evidence: parseJsonFlag(args, "--evidence"), - confidence: parseIntegerFlag(args, "--confidence") ?? null, - supersedesMemoryId: parseFlagValue(args, "--supersedes") ?? null, - createdBy: parseFlagValue(args, "--created-by") ?? "cued-cli", - }), + args.includes("--execute") + ? db.executeApprovedAction(action.id, parseFlagValue(args, "--by") ?? "cued-cli") + : action, ); return; } @@ -1208,60 +1497,38 @@ async function main(): Promise { case "stale": { const memoryId = args[0]; if (!memoryId) { - throw new Error("Usage: cued contacts memory stale "); + throw new Error("Usage: cued contacts memory stale [--execute]"); } - printJson(db.markContactMemoryStale(memoryId)); + const action = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId }, + title: "Mark contact memory stale", + sourceSkill: "cued", + createdBy: parseFlagValue(args, "--created-by") ?? "cued-cli", + requiresApproval: !args.includes("--execute"), + }); + printJson( + args.includes("--execute") + ? db.executeApprovedAction(action.id, parseFlagValue(args, "--by") ?? "cued-cli") + : action, + ); return; } default: throw new Error( - "Usage: cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] | cued contacts memory list [--limit N] [--include-stale] | cued contacts memory stale ", + "Usage: cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] [--execute] | cued contacts memory list [--limit N] [--include-stale] | cued contacts memory stale [--execute]", ); } } finally { db.close(); } } - if (subcommand === "merge-batch") { - const batchPath = rest[0]; - if (!batchPath) { - throw new Error("Usage: cued contacts merge-batch [--apply]"); - } - const merges = parseContactMergeBatchFile(batchPath); - const apply = rest.includes("--apply"); - if (!apply) { - const db = openCuedDatabaseReadOnly(); - try { - const decisions = db.planContactMergeDecisions(merges); - printJson({ - applied: false, - mergeCount: decisions.length, - decisions, - }); - } finally { - db.close(); - } - return; - } - response = await sendDaemonRequest({ - command: "contacts-merge-batch", - merges, - apply: true, - }); - break; - } - if (subcommand !== "merge" || !rest[0] || !rest[1]) { + if (subcommand) { throw new Error( - "Usage: cued contacts merge [--reason TEXT] | cued contacts merge-batch [--apply] | cued contacts memory add|list|stale ...", + "Usage: cued contacts memory add|list|stale ... (use cued actions propose contact.merge for merges)", ); } - response = await sendDaemonRequest({ - command: "contacts-merge", - primaryContactId: rest[0], - secondaryContactId: rest[1], - reason: parseFlagValue(rest.slice(2), "--reason"), - }); - break; + throw new Error("Usage: cued contacts memory add|list|stale ..."); case "rebuild": response = await sendDaemonRequest({ command: "rebuild" }); break; diff --git a/src/core/identity.ts b/src/core/identity.ts index 2b221dcb..f9b0527d 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -1,16 +1,9 @@ export const CUED_APP_BUNDLE_IDENTIFIER = "so.cued.desktop"; -export const CUED_LEGACY_APP_BUNDLE_IDENTIFIERS = ["dev.cued.app"] as const; export const CUED_LAUNCH_AGENT_LABEL = "so.cued.desktop.daemon"; -export const CUED_LEGACY_LAUNCH_AGENT_LABELS = ["dev.cued.daemon"] as const; export const CUED_DB_KEYCHAIN_SERVICE = "so.cued.desktop.db"; -export const CUED_LEGACY_DB_KEYCHAIN_SERVICES = ["dev.cued.db"] as const; export function cuedAuthKeychainService(platform: string): string { return `so.cued.desktop.auth.${platform}`; } - -export function legacyCuedAuthKeychainService(platform: string): string { - return `dev.cued.auth.${platform}`; -} diff --git a/src/db/database.ts b/src/db/database.ts index b9e6afac..beed9455 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,12 +1,18 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { existsSync, rmSync } from "node:fs"; import type Database from "better-sqlite3-multiple-ciphers"; import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import { type BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; +import { type ActionSkillDatabase, actionExecutionHelpers } from "../actions/execution.js"; +import { loadActionExecutor } from "../actions/executor-loader.js"; +import { ActionDefinitionRegistry } from "../actions/registry.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "../core/app-metadata.js"; import { CUED_DB_PATH, ensureCuedDirs } from "../core/config.js"; import type { + ActionApprovalStatus, + ActionExecutionStatus, + ActionStatus, AuthSessionState, ConnectionKind, IntegrationAuthState, @@ -39,16 +45,17 @@ import type { UpdateStatusSnapshot, } from "../runtime/updater/types.js"; import { safeParseJson, safeStringifyJson } from "./codecs.js"; -import { MIGRATIONS } from "./migrations.js"; import * as schema from "./schema.js"; +import { CURRENT_SCHEMA_SQL } from "./schema-bootstrap.js"; import { openSqliteDatabase } from "./sqlite.js"; const { + actionEffects, + actions, attachmentCache, attachmentContent, appSettings, authSessions, - contactMergeDecisions, contactHandles, contactMemories, contactSources, @@ -67,7 +74,6 @@ const { rawEventProjectionFailures, rawEvents, sourceAccounts, - slackBackfillProofs, syncProofs, syncScopes, syncCheckpoints, @@ -88,6 +94,10 @@ const APP_SETTING_KEYS = { } as const; const DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5_000; +function hashActionPayload(payloadJson: string): string { + return createHash("sha256").update(payloadJson).digest("hex"); +} + export interface DaemonStatusRow { singleton_key: "daemon"; pid: number | null; @@ -150,6 +160,85 @@ export interface MessageFtsIndexQueueRow { last_error: string | null; } +export interface ActionRow { + id: string; + action_type: string; + action_version: string; + status: ActionStatus; + approval_status: ActionApprovalStatus; + execution_status: ActionExecutionStatus; + priority: number; + title: string | null; + summary: string | null; + payload_json: string; + payload_hash: string; + result_json: string | null; + error_json: string | null; + source_skill: string | null; + source_job_id: string | null; + created_by: string; + approved_by: string | null; + denied_by: string | null; + executed_by: string | null; + requires_approval: number; + queued_at: number; + approved_at: number | null; + denied_at: number | null; + locked_at: number | null; + started_at: number | null; + executed_at: number | null; + updated_at: number; + dedupe_key: string | null; +} + +export interface ActionEffectRow { + id: string; + action_id: string; + effect_type: string; + target_table: string | null; + target_id: string | null; + payload_json: string | null; + applied_at: number; + reverted_at: number | null; +} + +export interface ExecutedActionResult { + action: ActionRow; + effects: ActionEffectRow[]; + result: unknown; +} + +const ACTION_ROW_COLUMNS = ` + id, + action_type, + action_version, + status, + approval_status, + execution_status, + priority, + title, + summary, + payload_json, + payload_hash, + result_json, + error_json, + source_skill, + source_job_id, + created_by, + approved_by, + denied_by, + executed_by, + requires_approval, + queued_at, + approved_at, + denied_at, + locked_at, + started_at, + executed_at, + updated_at, + dedupe_key +`; + export interface ContactMemoryRow { id: string; contact_id: string; @@ -165,20 +254,6 @@ export interface ContactMemoryRow { updated_at: number; } -export interface ContactMergeBatchInput { - primaryContactId: string; - secondaryContactId: string; - reason?: string | null; -} - -export interface PlannedContactMergeDecision { - decisionId: string; - primaryContactId: string; - secondaryContactId: string; - canonicalContactId: string; - reason: string | null; -} - export interface ProjectionStateRow { singleton_key: "global"; projection_watermark: number; @@ -244,17 +319,6 @@ export interface SyncProofRow { updated_at: number; } -export interface ContactMergeDecisionRow { - id: string; - decision_type: string; - primary_contact_id: string; - secondary_contact_id: string; - canonical_contact_id: string; - reason: string | null; - created_by: string | null; - created_at: number; -} - function buildSyncScopeId( platform: Platform, accountKey: string, @@ -521,23 +585,6 @@ function now(): number { return Date.now(); } -function resolveCanonicalContactIdFromAliases( - contactId: string, - aliasMap: Map, -): string { - const seen = new Set(); - let current = contactId; - while (!seen.has(current)) { - seen.add(current); - const next = aliasMap.get(current); - if (!next || next === current) { - return current; - } - current = next; - } - throw new Error(`Contact merge alias cycle detected at ${current}`); -} - function chunkArray(items: readonly T[], size: number): T[][] { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { @@ -599,39 +646,8 @@ export class CuedDatabase { this.db = drizzle(this.sqlite, { schema }); } - migrate(): void { - for (const migration of MIGRATIONS) { - const alreadyApplied = this.sqlite - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'") - .get() as { 1: number } | undefined; - - if (alreadyApplied) { - const migrationIds = [migration.id, ...(migration.legacyIds ?? [])]; - const placeholders = migrationIds.map(() => "?").join(", "); - const applied = this.sqlite - .prepare(`SELECT id FROM schema_migrations WHERE id IN (${placeholders}) LIMIT 1`) - .get(...migrationIds) as { id: string } | undefined; - if (applied) { - continue; - } - } - - this.sqlite.exec("BEGIN"); - try { - if (migration.apply) { - migration.apply(this.sqlite); - } else if (migration.sql) { - this.sqlite.exec(migration.sql); - } - this.sqlite - .prepare("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)") - .run(migration.id, now()); - this.sqlite.exec("COMMIT"); - } catch (error) { - this.sqlite.exec("ROLLBACK"); - throw error; - } - } + initializeSchema(): void { + this.sqlite.exec(CURRENT_SCHEMA_SQL); } close(): void { @@ -657,167 +673,504 @@ export class CuedDatabase { return statement.all(); } - listContactMergeDecisions(): ContactMergeDecisionRow[] { - return this.db - .select({ - id: contactMergeDecisions.id, - decision_type: contactMergeDecisions.decisionType, - primary_contact_id: contactMergeDecisions.primaryContactId, - secondary_contact_id: contactMergeDecisions.secondaryContactId, - canonical_contact_id: contactMergeDecisions.canonicalContactId, - reason: contactMergeDecisions.reason, - created_by: contactMergeDecisions.createdBy, - created_at: contactMergeDecisions.createdAt, - }) - .from(contactMergeDecisions) - .orderBy(asc(contactMergeDecisions.createdAt), asc(contactMergeDecisions.id)) - .all() as ContactMergeDecisionRow[]; - } + createAction(input: { + actionType: string; + actionVersion?: string; + payload: unknown; + priority?: number; + title?: string | null; + summary?: string | null; + sourceSkill?: string | null; + sourceJobId?: string | null; + createdBy?: string | null; + requiresApproval?: boolean; + dedupeKey?: string | null; + }): ActionRow { + const actionType = input.actionType.trim(); + const actionVersion = (input.actionVersion ?? "1").trim(); + const createdBy = (input.createdBy ?? "agent").trim() || "agent"; + if (!actionType) { + throw new Error("Action type is required."); + } + if (!actionVersion) { + throw new Error("Action version is required."); + } - listContactMergeAliases(): Array<{ - contact_id: string; - canonical_contact_id: string; - }> { - return this.db - .select({ - contact_id: contactMergeDecisions.secondaryContactId, - canonical_contact_id: contactMergeDecisions.canonicalContactId, + const payloadJson = safeStringifyJson(input.payload); + if (payloadJson === null) { + throw new Error("Action payload must be JSON-serializable."); + } + + const queuedAt = now(); + const requiresApproval = input.requiresApproval ?? true; + const id = randomUUID(); + this.db + .insert(actions) + .values({ + id, + actionType, + actionVersion, + status: requiresApproval ? "proposed" : "approved", + approvalStatus: requiresApproval ? "pending" : "auto_approved", + executionStatus: "pending", + priority: Math.trunc(input.priority ?? 0), + title: input.title ?? null, + summary: input.summary ?? null, + payloadJson, + payloadHash: hashActionPayload(payloadJson), + resultJson: null, + errorJson: null, + sourceSkill: input.sourceSkill ?? null, + sourceJobId: input.sourceJobId ?? null, + createdBy, + approvedBy: requiresApproval ? null : "system", + deniedBy: null, + executedBy: null, + requiresApproval: requiresApproval ? 1 : 0, + queuedAt, + approvedAt: requiresApproval ? null : queuedAt, + deniedAt: null, + lockedAt: null, + startedAt: null, + executedAt: null, + updatedAt: queuedAt, + dedupeKey: input.dedupeKey ?? null, }) - .from(contactMergeDecisions) - .where(eq(contactMergeDecisions.decisionType, "merge")) - .orderBy(asc(contactMergeDecisions.createdAt), asc(contactMergeDecisions.id)) - .all() as Array<{ - contact_id: string; - canonical_contact_id: string; - }>; + .run(); + + return this.getAction(id)!; } - resolveCanonicalContactId(contactId: string): string { - const aliasMap = new Map( - this.listContactMergeAliases().map((row) => [row.contact_id, row.canonical_contact_id]), + getAction(id: string): ActionRow | null { + return ( + (this.sqlite + .prepare( + ` + SELECT ${ACTION_ROW_COLUMNS} + FROM actions + WHERE id = ? + LIMIT 1 + `, + ) + .get(id) as ActionRow | undefined) ?? null ); - return resolveCanonicalContactIdFromAliases(contactId, aliasMap); } - planContactMergeDecisions(input: ContactMergeBatchInput[]): PlannedContactMergeDecision[] { - if (input.length === 0) { - throw new Error("At least one contact merge is required."); + listActions(input: { status?: ActionStatus; limit?: number } = {}): ActionRow[] { + const filters: string[] = []; + const params: unknown[] = []; + if (input.status) { + filters.push("status = ?"); + params.push(input.status); } + const limit = + typeof input.limit === "number" && Number.isFinite(input.limit) + ? Math.max(1, Math.min(500, Math.trunc(input.limit))) + : 100; + params.push(limit); - const aliasMap = new Map( - this.listContactMergeAliases().map((row) => [row.contact_id, row.canonical_contact_id]), - ); - const planned: PlannedContactMergeDecision[] = []; - for (const merge of input) { - const primaryContactId = merge.primaryContactId.trim(); - const secondaryContactId = merge.secondaryContactId.trim(); - if (!primaryContactId || !secondaryContactId) { - throw new Error("Primary and secondary contact ids are required."); - } - if (primaryContactId === secondaryContactId) { - throw new Error("Cannot merge a contact into itself"); - } + return this.sqlite + .prepare( + ` + SELECT ${ACTION_ROW_COLUMNS} + FROM actions + ${filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : ""} + ORDER BY priority ASC, queued_at ASC, id ASC + LIMIT ? + `, + ) + .all(...params) as ActionRow[]; + } - this.assertContactExists(primaryContactId, "Primary"); - this.assertContactExists(secondaryContactId, "Secondary"); + listApprovedPendingActions(limit = 25): ActionRow[] { + const normalizedLimit = + Number.isFinite(limit) && limit > 0 ? Math.max(1, Math.min(100, Math.trunc(limit))) : 25; + return this.sqlite + .prepare( + ` + SELECT ${ACTION_ROW_COLUMNS} + FROM actions + WHERE status = 'approved' + AND approval_status IN ('approved', 'auto_approved') + AND execution_status = 'pending' + ORDER BY priority ASC, approved_at ASC, queued_at ASC, id ASC + LIMIT ? + `, + ) + .all(normalizedLimit) as ActionRow[]; + } - const canonicalPrimary = resolveCanonicalContactIdFromAliases(primaryContactId, aliasMap); - const canonicalSecondary = resolveCanonicalContactIdFromAliases(secondaryContactId, aliasMap); - if (canonicalPrimary === canonicalSecondary) { - throw new Error( - `Contacts already resolve to the same canonical contact: ${canonicalPrimary}`, - ); - } + updateActionPayload(id: string, payload: unknown): ActionRow { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.locked_at !== null || action.execution_status !== "pending") { + throw new Error("Cannot update an action payload after execution has started."); + } + if (action.status !== "proposed" && action.status !== "approved") { + throw new Error(`Cannot update an action payload in status '${action.status}'.`); + } - aliasMap.set(canonicalSecondary, canonicalPrimary); - resolveCanonicalContactIdFromAliases(canonicalSecondary, aliasMap); - planned.push({ - decisionId: randomUUID(), - primaryContactId: canonicalPrimary, - secondaryContactId: canonicalSecondary, - canonicalContactId: canonicalPrimary, - reason: merge.reason ?? null, - }); + const payloadJson = safeStringifyJson(payload); + if (payloadJson === null) { + throw new Error("Action payload must be JSON-serializable."); } - return planned; + const timestamp = now(); + this.db + .update(actions) + .set({ + payloadJson, + payloadHash: hashActionPayload(payloadJson), + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; } - recordContactMergeDecision(input: { - primaryContactId: string; - secondaryContactId: string; - reason?: string | null; - createdBy?: string | null; - }): { - decisionId: string; - primaryContactId: string; - secondaryContactId: string; - canonicalContactId: string; - } { - const [decision] = this.recordContactMergeDecisionsBatch( - [ - { - primaryContactId: input.primaryContactId, - secondaryContactId: input.secondaryContactId, - reason: input.reason ?? null, - }, - ], - { createdBy: input.createdBy }, - ); - return { - decisionId: decision!.decisionId, - primaryContactId: decision!.primaryContactId, - secondaryContactId: decision!.secondaryContactId, - canonicalContactId: decision!.canonicalContactId, - }; + approveAction(id: string, approvedBy = "user"): ActionRow { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.approval_status === "denied") { + throw new Error("Cannot approve a denied action."); + } + if (action.locked_at !== null || action.execution_status !== "pending") { + throw new Error("Cannot approve an action after execution has started."); + } + const timestamp = now(); + this.db + .update(actions) + .set({ + status: "approved", + approvalStatus: "approved", + approvedBy, + approvedAt: timestamp, + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; } - recordContactMergeDecisionsBatch( - input: ContactMergeBatchInput[], - options: { createdBy?: string | null } = {}, - ): PlannedContactMergeDecision[] { - const planned = this.planContactMergeDecisions(input); + denyAction(id: string, deniedBy = "user"): ActionRow { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.approval_status === "approved" || action.approval_status === "auto_approved") { + throw new Error("Cannot deny an approved action."); + } + if (action.locked_at !== null || action.execution_status !== "pending") { + throw new Error("Cannot deny an action after execution has started."); + } const timestamp = now(); - return this.sqlite.transaction(() => { - for (const decision of planned) { - this.db - .insert(contactMergeDecisions) - .values({ - id: decision.decisionId, - decisionType: "merge", - primaryContactId: decision.primaryContactId, - secondaryContactId: decision.secondaryContactId, - canonicalContactId: decision.canonicalContactId, - reason: decision.reason, - createdBy: options.createdBy ?? "cli", - createdAt: timestamp, - }) - .run(); - this.db - .update(contactMemories) - .set({ contactId: decision.canonicalContactId, updatedAt: timestamp }) - .where(eq(contactMemories.contactId, decision.secondaryContactId)) - .run(); - } - return planned; - })(); + this.db + .update(actions) + .set({ + status: "denied", + approvalStatus: "denied", + deniedBy, + deniedAt: timestamp, + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; } - private assertContactExists(contactId: string, label: "Primary" | "Secondary"): void { - const contact = this.sqlite + lockActionForExecution(id: string, executedBy = "daemon"): ActionRow { + const timestamp = now(); + const result = this.sqlite .prepare( ` - SELECT id - FROM contacts + UPDATE actions + SET status = 'executing', + execution_status = 'running', + executed_by = ?, + locked_at = ?, + started_at = ?, + updated_at = ? WHERE id = ? - LIMIT 1 + AND approval_status IN ('approved', 'auto_approved') + AND execution_status = 'pending' + AND locked_at IS NULL `, ) - .get(contactId) as { id: string } | undefined; - if (!contact) { - throw new Error(`${label} contact not found: ${contactId}`); + .run(executedBy, timestamp, timestamp, timestamp, id); + if (result.changes !== 1) { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.approval_status !== "approved" && action.approval_status !== "auto_approved") { + throw new Error("Action must be approved before execution."); + } + throw new Error( + `Cannot execute an action with execution status '${action.execution_status}'.`, + ); + } + return this.getAction(id)!; + } + + completeAction(id: string, result?: unknown): ActionRow { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.execution_status !== "running") { + throw new Error("Only running actions can be completed."); + } + const timestamp = now(); + this.db + .update(actions) + .set({ + status: "executed", + executionStatus: "succeeded", + resultJson: safeStringifyJson(result), + executedAt: timestamp, + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; + } + + failAction(id: string, error: unknown): ActionRow { + const action = this.getAction(id); + if (!action) { + throw new Error(`Action not found: ${id}`); + } + if (action.execution_status !== "running") { + throw new Error("Only running actions can fail."); + } + const timestamp = now(); + this.db + .update(actions) + .set({ + status: "failed", + executionStatus: "failed", + errorJson: safeStringifyJson({ + message: error instanceof Error ? error.message : String(error), + failedAt: timestamp, + }), + executedAt: timestamp, + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; + } + + recordActionEffect(input: { + actionId: string; + effectType: string; + targetTable?: string | null; + targetId?: string | null; + payload?: unknown; + appliedAt?: number; + }): ActionEffectRow { + const action = this.getAction(input.actionId); + if (!action) { + throw new Error(`Action not found: ${input.actionId}`); + } + const effectType = input.effectType.trim(); + if (!effectType) { + throw new Error("Action effect type is required."); + } + const id = randomUUID(); + this.db + .insert(actionEffects) + .values({ + id, + actionId: input.actionId, + effectType, + targetTable: input.targetTable ?? null, + targetId: input.targetId ?? null, + payloadJson: safeStringifyJson(input.payload), + appliedAt: input.appliedAt ?? now(), + revertedAt: null, + }) + .run(); + return this.getActionEffect(id)!; + } + + getActionEffect(id: string): ActionEffectRow | null { + return ( + (this.sqlite + .prepare( + ` + SELECT + id, + action_id, + effect_type, + target_table, + target_id, + payload_json, + applied_at, + reverted_at + FROM action_effects + WHERE id = ? + LIMIT 1 + `, + ) + .get(id) as ActionEffectRow | undefined) ?? null + ); + } + + listActionEffects(actionId: string): ActionEffectRow[] { + return this.sqlite + .prepare( + ` + SELECT + id, + action_id, + effect_type, + target_table, + target_id, + payload_json, + applied_at, + reverted_at + FROM action_effects + WHERE action_id = ? + ORDER BY applied_at ASC, id ASC + `, + ) + .all(actionId) as ActionEffectRow[]; + } + + listActiveActionEffects( + input: { actionType?: string; effectType?: string; limit?: number } = {}, + ): ActionEffectRow[] { + const filters = [ + "a.status = 'executed'", + "a.execution_status = 'succeeded'", + "ae.reverted_at IS NULL", + ]; + const params: unknown[] = []; + if (input.actionType) { + filters.push("a.action_type = ?"); + params.push(input.actionType); + } + if (input.effectType) { + filters.push("ae.effect_type = ?"); + params.push(input.effectType); + } + const limit = + typeof input.limit === "number" && Number.isFinite(input.limit) + ? Math.max(1, Math.min(10_000, Math.trunc(input.limit))) + : 10_000; + params.push(limit); + + return this.sqlite + .prepare( + ` + SELECT + ae.id, + ae.action_id, + ae.effect_type, + ae.target_table, + ae.target_id, + ae.payload_json, + ae.applied_at, + ae.reverted_at + FROM action_effects ae + JOIN actions a ON a.id = ae.action_id + WHERE ${filters.join(" AND ")} + ORDER BY a.queued_at ASC, ae.applied_at ASC, ae.id ASC + LIMIT ? + `, + ) + .all(...params) as ActionEffectRow[]; + } + + executeApprovedAction(id: string, executedBy = "daemon"): ExecutedActionResult { + const locked = this.lockActionForExecution(id, executedBy); + try { + return this.sqlite.transaction(() => { + const registry = ActionDefinitionRegistry.load(); + const definition = registry.get(locked.action_type, locked.action_version); + if (!definition) { + throw new Error( + `Unsupported executable action type: ${locked.action_type}@${locked.action_version}`, + ); + } + const payload = safeParseJson( + locked.payload_json, + `action:${locked.id}:payload`, + null, + ); + const validation = registry.validatePayload( + locked.action_type, + locked.action_version, + payload, + ); + if (!validation.ok) { + throw new Error(`Action payload failed validation: ${validation.errors.join(" ")}`); + } + const executor = loadActionExecutor(definition); + if (!executor) { + throw new Error(`Missing action executor module: ${definition.module}`); + } + const { result, effects } = executor({ + action: locked, + db: this.createActionSkillDatabase(), + executedBy, + helpers: actionExecutionHelpers, + }); + + return { + action: this.completeAction(locked.id, result), + effects, + result, + }; + })(); + } catch (error) { + this.failAction(locked.id, error); + throw error; } } + private createActionSkillDatabase(): ActionSkillDatabase { + return { + addContactMemory: (input) => this.addContactMemory(input), + markContactMemoryStale: (id, staleAt) => this.markContactMemoryStale(id, staleAt), + moveContactMemoriesToContact: (input) => this.moveContactMemoriesToContact(input), + contactExists: (id) => this.contactExists(id), + conversationExists: (id) => this.conversationExists(id), + recordActionEffect: (input) => this.recordActionEffect(input), + listActiveActionEffects: (input) => this.listActiveActionEffects(input), + }; + } + + moveContactMemoriesToContact(input: { + fromContactId: string; + toContactId: string; + updatedAt?: number; + }): void { + this.db + .update(contactMemories) + .set({ contactId: input.toContactId, updatedAt: input.updatedAt ?? now() }) + .where(eq(contactMemories.contactId, input.fromContactId)) + .run(); + } + + contactExists(id: string): boolean { + const contact = this.sqlite.prepare("SELECT id FROM contacts WHERE id = ? LIMIT 1").get(id) as + | { id: string } + | undefined; + return contact !== undefined; + } + + conversationExists(id: string): boolean { + const conversation = this.sqlite + .prepare("SELECT id FROM conversations WHERE id = ? LIMIT 1") + .get(id) as { id: string } | undefined; + return conversation !== undefined; + } + listAppSettings(): AppSettingRow[] { return this.db .select({ @@ -1128,8 +1481,6 @@ export class CuedDatabase { .delete(syncScopes) .where(eq(syncScopes.platform, platform)) .run().changes; - const removedSlackBackfillProofs = - platform === "slack" ? tx.delete(slackBackfillProofs).run().changes : 0; return ( Number(removedSourceAccounts) + Number(removedRawEvents) + @@ -1137,8 +1488,7 @@ export class CuedDatabase { Number(removedErrors) + Number(removedCheckpoints) + Number(removedSyncProofs) + - Number(removedSyncScopes) + - Number(removedSlackBackfillProofs) + Number(removedSyncScopes) ); }); @@ -2041,22 +2391,13 @@ export class CuedDatabase { .delete(syncRuns) .where(and(eq(syncRuns.platform, platform), eq(syncRuns.accountKey, accountKey))) .run().changes; - const removedSlackBackfillProofs = - platform === "slack" - ? tx - .delete(slackBackfillProofs) - .where(eq(slackBackfillProofs.accountKey, accountKey)) - .run().changes - : 0; - return ( Number(removedSourceAccounts) + Number(removedCheckpoints) + Number(removedSyncProofs) + Number(removedSyncScopes) + Number(removedRunErrors) + - Number(removedRuns) + - Number(removedSlackBackfillProofs) + Number(removedRuns) ); }); } @@ -4919,7 +5260,7 @@ export class CuedDatabase { export function openCuedDatabase(dbPath?: string): CuedDatabase { const db = new CuedDatabase(dbPath); - db.migrate(); + db.initializeSchema(); db.recordAppMetadata({ version: getCurrentAppVersion(), releaseChannel: getCurrentReleaseChannel(), diff --git a/src/db/schema-bootstrap.ts b/src/db/schema-bootstrap.ts new file mode 100644 index 00000000..a95410a8 --- /dev/null +++ b/src/db/schema-bootstrap.ts @@ -0,0 +1,914 @@ +export const CURRENT_SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS source_accounts ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active', + metadata_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key) + ); + + CREATE TABLE IF NOT EXISTS sync_checkpoints ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + source_cursor_json TEXT, + raw_ingest_watermark INTEGER NOT NULL DEFAULT 0, + sync_mode TEXT NOT NULL DEFAULT 'full', + last_full_sync_at INTEGER, + last_success_at INTEGER, + last_error_at INTEGER, + last_error_summary TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key) + ); + + CREATE TABLE IF NOT EXISTS sync_scopes ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + scope_kind TEXT NOT NULL, + scope_key TEXT NOT NULL, + parent_scope_id TEXT REFERENCES sync_scopes(id) ON DELETE CASCADE, + display_name TEXT, + metadata_json TEXT, + first_discovered_at INTEGER NOT NULL, + last_observed_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, scope_kind, scope_key) + ); + + CREATE TABLE IF NOT EXISTS sync_proofs ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + scope_id TEXT NOT NULL REFERENCES sync_scopes(id) ON DELETE CASCADE, + proof_kind TEXT NOT NULL, + status TEXT NOT NULL, + sync_mode TEXT, + run_started_at INTEGER, + last_observed_at INTEGER NOT NULL, + completed_at INTEGER, + fresh_until INTEGER, + resume_cursor_json TEXT, + coverage_json TEXT, + stats_json TEXT, + error_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, scope_id, proof_kind) + ); + + CREATE TABLE IF NOT EXISTS sync_runs ( + id TEXT PRIMARY KEY, + platform TEXT, + account_key TEXT, + run_type TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + queued_at INTEGER NOT NULL, + scheduled_at INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + finished_at INTEGER, + details_json TEXT + ); + + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + platform TEXT, + account_key TEXT, + priority INTEGER NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + queued_at INTEGER NOT NULL, + scheduled_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + attempt INTEGER NOT NULL DEFAULT 0, + owner_id TEXT, + lease_expires_at INTEGER, + last_progress_at INTEGER, + checkpoint_json TEXT, + progress_json TEXT, + error_json TEXT + ); + + CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, + action_type TEXT NOT NULL, + action_version TEXT NOT NULL, + status TEXT NOT NULL, + approval_status TEXT NOT NULL, + execution_status TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + title TEXT, + summary TEXT, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + result_json TEXT, + error_json TEXT, + source_skill TEXT, + source_job_id TEXT, + created_by TEXT NOT NULL, + approved_by TEXT, + denied_by TEXT, + executed_by TEXT, + requires_approval INTEGER NOT NULL DEFAULT 1, + queued_at INTEGER NOT NULL, + approved_at INTEGER, + denied_at INTEGER, + locked_at INTEGER, + started_at INTEGER, + executed_at INTEGER, + updated_at INTEGER NOT NULL, + dedupe_key TEXT + ); + + CREATE TABLE IF NOT EXISTS action_effects ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE, + effect_type TEXT NOT NULL, + target_table TEXT, + target_id TEXT, + payload_json TEXT, + applied_at INTEGER NOT NULL, + reverted_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS sync_run_errors ( + id TEXT PRIMARY KEY, + sync_run_id TEXT NOT NULL REFERENCES sync_runs(id) ON DELETE CASCADE, + platform TEXT, + account_key TEXT, + error_code TEXT, + error_message TEXT NOT NULL, + details_json TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS message_fts_index_queue ( + message_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + status TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + queued_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_error TEXT + ); + + CREATE TABLE IF NOT EXISTS daemon_state ( + singleton_key TEXT PRIMARY KEY CHECK (singleton_key = 'daemon'), + pid INTEGER, + started_at INTEGER, + updated_at INTEGER, + status TEXT NOT NULL, + version TEXT, + details_json TEXT + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projection_state ( + singleton_key TEXT PRIMARY KEY CHECK (singleton_key = 'global'), + projection_watermark INTEGER NOT NULL DEFAULT 0, + last_projected_at INTEGER, + last_rebuild_at INTEGER, + updated_at INTEGER NOT NULL + ); + + INSERT OR IGNORE INTO projection_state ( + singleton_key, projection_watermark, last_projected_at, last_rebuild_at, updated_at + ) VALUES ('global', 0, NULL, NULL, strftime('%s','now') * 1000); + + CREATE TABLE IF NOT EXISTS raw_events ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + entity_kind TEXT NOT NULL, + event_kind TEXT NOT NULL, + external_event_id TEXT, + external_entity_id TEXT, + conversation_external_id TEXT, + occurred_at INTEGER, + observed_at INTEGER NOT NULL, + cursor_json TEXT, + dedupe_key TEXT NOT NULL, + payload_json TEXT NOT NULL, + normalized_schema TEXT, + provenance_json TEXT, + source_version TEXT, + UNIQUE(platform, account_key, dedupe_key) + ); + + CREATE TABLE IF NOT EXISTS contacts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'person', + name TEXT, + photo_url TEXT, + company TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS contact_handles ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + type TEXT NOT NULL, + value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + platform TEXT, + account_key TEXT, + is_deterministic INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS contact_sources ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + source_entity_key TEXT NOT NULL, + profile_url TEXT, + metadata_json TEXT, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + UNIQUE(platform, account_key, source_entity_key) + ); + + CREATE TABLE IF NOT EXISTS contact_memories ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL, + body TEXT NOT NULL, + source_kind TEXT NOT NULL, + evidence_json TEXT, + confidence INTEGER, + supersedes_memory_id TEXT REFERENCES contact_memories(id) ON DELETE SET NULL, + stale_at INTEGER, + created_by TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 100)) + ); + + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + source_conversation_key TEXT NOT NULL, + native_conversation_key TEXT, + type TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + removal_reason TEXT, + service TEXT, + name TEXT, + topic TEXT, + participant_names TEXT, + last_message_id TEXT, + last_message_at INTEGER, + last_message_preview TEXT, + unread_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, source_conversation_key) + ); + + CREATE TABLE IF NOT EXISTS conversation_participants ( + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + source_participant_key TEXT, + participant_name TEXT, + role TEXT, + is_self INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + joined_at INTEGER, + left_at INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (conversation_id, contact_id, source_participant_key) + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + platform_message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, + sender_source_key TEXT, + sender_name TEXT, + conversation_name TEXT, + sent_at INTEGER NOT NULL, + service TEXT, + status TEXT, + is_from_me INTEGER NOT NULL DEFAULT 0, + content TEXT, + delivered_at INTEGER, + read_at INTEGER, + edited_at INTEGER, + deleted_at INTEGER, + reply_to_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL, + is_deleted INTEGER NOT NULL DEFAULT 0, + is_edited INTEGER NOT NULL DEFAULT 0, + attachment_count INTEGER NOT NULL DEFAULT 0, + reaction_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, platform_message_id) + ); + + CREATE TABLE IF NOT EXISTS message_attachments ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + source_attachment_key TEXT NOT NULL, + kind TEXT, + mime_type TEXT, + filename TEXT, + title TEXT, + local_path TEXT, + remote_url TEXT, + size_bytes INTEGER, + text_content TEXT, + access_kind TEXT, + access_ref_json TEXT, + preview_ref_json TEXT, + availability_status TEXT, + provider_metadata_json TEXT, + metadata_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, source_attachment_key) + ); + + CREATE TABLE IF NOT EXISTS message_reactions ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + source_reaction_key TEXT NOT NULL, + reactor_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, + reactor_source_key TEXT, + reactor_name TEXT, + emoji TEXT NOT NULL, + reaction_type TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, source_reaction_key) + ); + + CREATE TABLE IF NOT EXISTS attachment_cache ( + id TEXT PRIMARY KEY, + attachment_id TEXT NOT NULL REFERENCES message_attachments(id) ON DELETE CASCADE, + variant TEXT NOT NULL, + status TEXT NOT NULL, + cache_path TEXT, + mime_type TEXT, + size_bytes INTEGER, + sha256 TEXT, + fetched_at INTEGER, + last_accessed_at INTEGER, + expires_at INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(attachment_id, variant) + ); + + CREATE TABLE IF NOT EXISTS attachment_content ( + attachment_id TEXT PRIMARY KEY REFERENCES message_attachments(id) ON DELETE CASCADE, + extractor TEXT, + status TEXT NOT NULL, + text_content TEXT, + mime_type TEXT, + extracted_at INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS timeline_events ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + source_event_key TEXT NOT NULL, + event_kind TEXT NOT NULL, + actor_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, + actor_source_key TEXT, + actor_name TEXT, + subject_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, + subject_source_key TEXT, + event_at INTEGER NOT NULL, + text TEXT, + system_kind TEXT, + call_provider TEXT, + call_direction TEXT, + call_status TEXT, + call_medium TEXT, + call_started_at INTEGER, + call_duration_seconds INTEGER, + call_ended_at INTEGER, + call_disconnected_cause TEXT, + metadata_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key, source_event_key) + ); + + CREATE TABLE IF NOT EXISTS integration_states ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + display_name TEXT, + auth_state TEXT NOT NULL, + enabled INTEGER NOT NULL, + connection_kind TEXT NOT NULL, + sync_capable INTEGER NOT NULL, + launch_strategy TEXT, + launch_target TEXT, + imported_from TEXT, + artifact_paths_json TEXT, + metadata_json TEXT, + last_seen_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(platform, account_key) + ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + integration_state_id TEXT NOT NULL REFERENCES integration_states(id) ON DELETE CASCADE, + state TEXT NOT NULL, + native_pid INTEGER, + requested_at INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + keychain_service TEXT, + keychain_account TEXT, + result_summary_json TEXT, + error_summary TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS outbound_messages ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_key TEXT NOT NULL, + target TEXT NOT NULL, + thread_id TEXT, + text TEXT NOT NULL, + status TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + scheduled_for INTEGER NOT NULL, + started_at INTEGER, + finished_at INTEGER, + last_error TEXT, + metadata_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id UNINDEXED, + sender_name, + conversation_name, + participant_names, + attachment_text, + content + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS attachment_content_fts USING fts5( + attachment_id UNINDEXED, + filename, + title, + content + ); + + CREATE INDEX IF NOT EXISTS idx_source_accounts_lookup + ON source_accounts(platform, account_key); + + CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_lookup + ON sync_checkpoints(platform, account_key); + + CREATE INDEX IF NOT EXISTS idx_sync_scopes_lookup + ON sync_scopes(platform, account_key, scope_kind, scope_key); + + CREATE INDEX IF NOT EXISTS idx_sync_scopes_parent + ON sync_scopes(parent_scope_id, updated_at); + + CREATE INDEX IF NOT EXISTS idx_sync_proofs_lookup + ON sync_proofs(platform, account_key, scope_id, proof_kind); + + CREATE INDEX IF NOT EXISTS idx_sync_proofs_status + ON sync_proofs(platform, account_key, status, updated_at); + + CREATE INDEX IF NOT EXISTS idx_sync_runs_status_type_queue + ON sync_runs(status, run_type, scheduled_at, queued_at); + + CREATE INDEX IF NOT EXISTS idx_sync_runs_platform_account_status_queue + ON sync_runs(platform, account_key, status, scheduled_at, queued_at); + + CREATE INDEX IF NOT EXISTS idx_jobs_claim + ON jobs(status, scheduled_at, priority, queued_at); + + CREATE INDEX IF NOT EXISTS idx_jobs_owner_lease + ON jobs(owner_id, lease_expires_at); + + CREATE INDEX IF NOT EXISTS idx_jobs_platform_account_status + ON jobs(platform, account_key, status, scheduled_at); + + CREATE INDEX IF NOT EXISTS idx_actions_status_queue + ON actions(status, approval_status, execution_status, queued_at); + + CREATE INDEX IF NOT EXISTS idx_actions_type_status + ON actions(action_type, status, queued_at); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_actions_dedupe + ON actions(dedupe_key) + WHERE dedupe_key IS NOT NULL; + + CREATE INDEX IF NOT EXISTS idx_action_effects_action + ON action_effects(action_id, applied_at); + + CREATE INDEX IF NOT EXISTS idx_action_effects_target + ON action_effects(target_table, target_id, applied_at); + + CREATE INDEX IF NOT EXISTS idx_message_fts_index_queue_status + ON message_fts_index_queue(status, queued_at); + + CREATE INDEX IF NOT EXISTS idx_raw_events_lookup + ON raw_events(platform, account_key, observed_at); + + CREATE INDEX IF NOT EXISTS idx_raw_events_schema_lookup + ON raw_events(platform, entity_kind, event_kind, normalized_schema); + + CREATE INDEX IF NOT EXISTS idx_contact_handles_lookup + ON contact_handles(type, normalized_value, account_key); + + CREATE INDEX IF NOT EXISTS idx_contact_sources_contact + ON contact_sources(contact_id, platform, account_key); + + CREATE INDEX IF NOT EXISTS idx_contact_memories_contact_current + ON contact_memories(contact_id, stale_at, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_contact_memories_created + ON contact_memories(created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_conversation_participants_contact + ON conversation_participants(contact_id); + + CREATE INDEX IF NOT EXISTS idx_timeline_events_actor_contact + ON timeline_events(actor_contact_id); + + CREATE INDEX IF NOT EXISTS idx_message_reactions_reactor_contact + ON message_reactions(reactor_contact_id); + + CREATE INDEX IF NOT EXISTS idx_conversations_lookup + ON conversations(platform, account_key, source_conversation_key); + + CREATE INDEX IF NOT EXISTS idx_messages_conversation + ON messages(conversation_id, sent_at); + + CREATE INDEX IF NOT EXISTS idx_messages_sender + ON messages(sender_contact_id, sent_at); + + CREATE INDEX IF NOT EXISTS idx_messages_platform_message + ON messages(platform, account_key, platform_message_id); + + CREATE INDEX IF NOT EXISTS idx_message_attachments_message + ON message_attachments(message_id); + + CREATE INDEX IF NOT EXISTS idx_message_reactions_message + ON message_reactions(message_id, is_active); + + CREATE INDEX IF NOT EXISTS idx_attachment_cache_status_accessed + ON attachment_cache(status, last_accessed_at, updated_at); + + CREATE INDEX IF NOT EXISTS idx_timeline_events_conversation + ON timeline_events(conversation_id, event_at); + + CREATE INDEX IF NOT EXISTS idx_integration_states_platform + ON integration_states(platform, enabled); + + CREATE INDEX IF NOT EXISTS idx_auth_sessions_platform_account + ON auth_sessions(platform, account_key, requested_at DESC); + + CREATE INDEX IF NOT EXISTS idx_auth_sessions_state + ON auth_sessions(state, requested_at DESC); + + CREATE INDEX IF NOT EXISTS idx_outbound_messages_status_scheduled + ON outbound_messages(status, scheduled_for, created_at); + + CREATE INDEX IF NOT EXISTS idx_outbound_messages_platform_account_status + ON outbound_messages(platform, account_key, status, scheduled_for); + + CREATE VIEW IF NOT EXISTS message_fts_source AS + SELECT + m.rowid AS message_rowid, + m.id AS message_id, + COALESCE(m.sender_name, '') AS sender_name, + COALESCE(m.conversation_name, '') AS conversation_name, + COALESCE(conv.participant_names, '') AS participant_names, + COALESCE(( + SELECT GROUP_CONCAT( + TRIM(COALESCE(ma.filename, '') || ' ' || COALESCE(ma.title, '') || ' ' || COALESCE(ma.text_content, '')), + ' ' + ) + FROM message_attachments ma + WHERE ma.message_id = m.id + ), '') AS attachment_text, + COALESCE(m.content, '') AS content + FROM messages m + JOIN conversations conv ON conv.id = m.conversation_id; + + CREATE TRIGGER IF NOT EXISTS trg_messages_inserted_fts + AFTER INSERT ON messages + BEGIN + DELETE FROM messages_fts WHERE rowid = NEW.rowid; + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id = NEW.id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_messages_updated_fts + AFTER UPDATE OF sender_name, conversation_name, content ON messages + BEGIN + DELETE FROM messages_fts WHERE rowid = NEW.rowid; + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id = NEW.id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_messages_deleted_fts + AFTER DELETE ON messages + BEGIN + DELETE FROM messages_fts WHERE rowid = OLD.rowid; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_attachments_inserted + AFTER INSERT ON message_attachments + BEGIN + UPDATE messages + SET + attachment_count = ( + SELECT COUNT(*) FROM message_attachments WHERE message_id = NEW.message_id + ), + updated_at = MAX(updated_at, NEW.updated_at) + WHERE id = NEW.message_id; + DELETE FROM messages_fts + WHERE rowid IN (SELECT rowid FROM messages WHERE id = NEW.message_id); + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id = NEW.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_attachments_updated + AFTER UPDATE ON message_attachments + BEGIN + UPDATE messages + SET + attachment_count = ( + SELECT COUNT(*) FROM message_attachments WHERE message_id = NEW.message_id + ), + updated_at = MAX(updated_at, NEW.updated_at) + WHERE id = NEW.message_id; + DELETE FROM messages_fts + WHERE rowid IN (SELECT rowid FROM messages WHERE id = NEW.message_id); + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id = NEW.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_attachments_deleted + AFTER DELETE ON message_attachments + BEGIN + UPDATE messages + SET attachment_count = ( + SELECT COUNT(*) FROM message_attachments WHERE message_id = OLD.message_id + ) + WHERE id = OLD.message_id; + DELETE FROM messages_fts + WHERE rowid IN (SELECT rowid FROM messages WHERE id = OLD.message_id); + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id = OLD.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_reactions_inserted + AFTER INSERT ON message_reactions + BEGIN + UPDATE messages + SET reaction_count = ( + SELECT COUNT(*) FROM message_reactions WHERE message_id = NEW.message_id AND is_active = 1 + ) + WHERE id = NEW.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_reactions_updated + AFTER UPDATE ON message_reactions + BEGIN + UPDATE messages + SET reaction_count = ( + SELECT COUNT(*) FROM message_reactions WHERE message_id = NEW.message_id AND is_active = 1 + ) + WHERE id = NEW.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_message_reactions_deleted + AFTER DELETE ON message_reactions + BEGIN + UPDATE messages + SET reaction_count = ( + SELECT COUNT(*) FROM message_reactions WHERE message_id = OLD.message_id AND is_active = 1 + ) + WHERE id = OLD.message_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contacts_name_updated + AFTER UPDATE OF name ON contacts + BEGIN + UPDATE messages + SET sender_name = NEW.name + WHERE sender_contact_id = NEW.id; + + UPDATE conversation_participants + SET participant_name = NEW.name + WHERE contact_id = NEW.id; + + UPDATE timeline_events + SET actor_name = NEW.name + WHERE actor_contact_id = NEW.id; + + UPDATE message_reactions + SET reactor_name = NEW.name + WHERE reactor_contact_id = NEW.id; + + UPDATE conversations + SET participant_names = ( + SELECT GROUP_CONCAT(cp.participant_name, ' | ') + FROM conversation_participants cp + WHERE cp.conversation_id = conversations.id + AND cp.is_active = 1 + AND cp.participant_name IS NOT NULL + AND cp.participant_name <> '' + ) + WHERE id IN ( + SELECT DISTINCT conversation_id + FROM conversation_participants + WHERE contact_id = NEW.id + ); + + DELETE FROM messages_fts + WHERE rowid IN ( + SELECT rowid FROM messages WHERE sender_contact_id = NEW.id + UNION + SELECT m.rowid + FROM messages m + JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id + WHERE cp.contact_id = NEW.id + ); + + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id IN ( + SELECT id FROM messages WHERE sender_contact_id = NEW.id + UNION + SELECT m.id + FROM messages m + JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id + WHERE cp.contact_id = NEW.id + ); + END; + + CREATE TRIGGER IF NOT EXISTS trg_conversations_name_updated + AFTER UPDATE OF name ON conversations + BEGIN + UPDATE messages + SET conversation_name = NEW.name + WHERE conversation_id = NEW.id; + + DELETE FROM messages_fts + WHERE rowid IN ( + SELECT rowid FROM messages WHERE conversation_id = NEW.id + ); + + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id IN ( + SELECT id FROM messages WHERE conversation_id = NEW.id + ); + END; + + CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_inserted + AFTER INSERT ON conversation_participants + BEGIN + UPDATE conversation_participants + SET participant_name = COALESCE( + (SELECT name FROM contacts WHERE id = NEW.contact_id), + participant_name + ) + WHERE conversation_id = NEW.conversation_id + AND contact_id = NEW.contact_id + AND COALESCE(source_participant_key, '') = COALESCE(NEW.source_participant_key, ''); + + UPDATE conversations + SET participant_names = ( + SELECT GROUP_CONCAT(cp.participant_name, ' | ') + FROM conversation_participants cp + WHERE cp.conversation_id = NEW.conversation_id + AND cp.is_active = 1 + AND cp.participant_name IS NOT NULL + AND cp.participant_name <> '' + ) + WHERE id = NEW.conversation_id; + + DELETE FROM messages_fts + WHERE rowid IN ( + SELECT rowid FROM messages WHERE conversation_id = NEW.conversation_id + ); + + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id IN ( + SELECT id FROM messages WHERE conversation_id = NEW.conversation_id + ); + END; + + CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_updated + AFTER UPDATE ON conversation_participants + BEGIN + UPDATE conversations + SET participant_names = ( + SELECT GROUP_CONCAT(cp.participant_name, ' | ') + FROM conversation_participants cp + WHERE cp.conversation_id = NEW.conversation_id + AND cp.is_active = 1 + AND cp.participant_name IS NOT NULL + AND cp.participant_name <> '' + ) + WHERE id = NEW.conversation_id; + + DELETE FROM messages_fts + WHERE rowid IN ( + SELECT rowid FROM messages WHERE conversation_id = NEW.conversation_id + ); + + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id IN ( + SELECT id FROM messages WHERE conversation_id = NEW.conversation_id + ); + END; + + CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_deleted + AFTER DELETE ON conversation_participants + BEGIN + UPDATE conversations + SET participant_names = ( + SELECT GROUP_CONCAT(cp.participant_name, ' | ') + FROM conversation_participants cp + WHERE cp.conversation_id = OLD.conversation_id + AND cp.is_active = 1 + AND cp.participant_name IS NOT NULL + AND cp.participant_name <> '' + ) + WHERE id = OLD.conversation_id; + + DELETE FROM messages_fts + WHERE rowid IN ( + SELECT rowid FROM messages WHERE conversation_id = OLD.conversation_id + ); + + INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) + SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content + FROM message_fts_source + WHERE message_id IN ( + SELECT id FROM messages WHERE conversation_id = OLD.conversation_id + ); + END; + `; diff --git a/src/db/schema.ts b/src/db/schema.ts index 6a417d38..f4aff501 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,8 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { + ACTION_APPROVAL_STATUS_VALUES, + ACTION_EXECUTION_STATUS_VALUES, + ACTION_STATUS_VALUES, AUTH_SESSION_STATE_VALUES, CONNECTION_KIND_VALUES, CONTACT_KIND_VALUES, @@ -22,11 +25,6 @@ function textEnum( return text(name, { enum: values }); } -export const schemaMigrations = sqliteTable("schema_migrations", { - id: text("id").primaryKey(), - appliedAt: integer("applied_at").notNull(), -}); - export const sourceAccounts = sqliteTable("source_accounts", { id: text("id").primaryKey(), platform: textEnum("platform", PLATFORM_VALUES).notNull(), @@ -128,6 +126,48 @@ export const jobs = sqliteTable("jobs", { errorJson: text("error_json"), }); +export const actions = sqliteTable("actions", { + id: text("id").primaryKey(), + actionType: text("action_type").notNull(), + actionVersion: text("action_version").notNull(), + status: textEnum("status", ACTION_STATUS_VALUES).notNull(), + approvalStatus: textEnum("approval_status", ACTION_APPROVAL_STATUS_VALUES).notNull(), + executionStatus: textEnum("execution_status", ACTION_EXECUTION_STATUS_VALUES).notNull(), + priority: integer("priority").notNull(), + title: text("title"), + summary: text("summary"), + payloadJson: text("payload_json").notNull(), + payloadHash: text("payload_hash").notNull(), + resultJson: text("result_json"), + errorJson: text("error_json"), + sourceSkill: text("source_skill"), + sourceJobId: text("source_job_id"), + createdBy: text("created_by").notNull(), + approvedBy: text("approved_by"), + deniedBy: text("denied_by"), + executedBy: text("executed_by"), + requiresApproval: integer("requires_approval").notNull(), + queuedAt: integer("queued_at").notNull(), + approvedAt: integer("approved_at"), + deniedAt: integer("denied_at"), + lockedAt: integer("locked_at"), + startedAt: integer("started_at"), + executedAt: integer("executed_at"), + updatedAt: integer("updated_at").notNull(), + dedupeKey: text("dedupe_key"), +}); + +export const actionEffects = sqliteTable("action_effects", { + id: text("id").primaryKey(), + actionId: text("action_id").notNull(), + effectType: text("effect_type").notNull(), + targetTable: text("target_table"), + targetId: text("target_id"), + payloadJson: text("payload_json"), + appliedAt: integer("applied_at").notNull(), + revertedAt: integer("reverted_at"), +}); + export const syncRunErrors = sqliteTable("sync_run_errors", { id: text("id").primaryKey(), syncRunId: text("sync_run_id").notNull(), @@ -149,33 +189,6 @@ export const messageFtsIndexQueue = sqliteTable("message_fts_index_queue", { lastError: text("last_error"), }); -export const slackBackfillProofs = sqliteTable("slack_backfill_proofs", { - id: text("id").primaryKey(), - accountKey: text("account_key").notNull(), - teamId: text("team_id").notNull(), - conversationId: text("conversation_id").notNull(), - conversationName: text("conversation_name"), - conversationFamily: text("conversation_family").notNull(), - syncMode: text("sync_mode").notNull(), - scanStartedAt: integer("scan_started_at").notNull(), - knownConversationCount: integer("known_conversation_count").notNull(), - conversationPhase: text("conversation_phase").notNull(), - historyComplete: integer("history_complete").notNull(), - historyCursor: text("history_cursor"), - threadRootCount: integer("thread_root_count").notNull(), - completedThreadCount: integer("completed_thread_count").notNull(), - pendingThreadCount: integer("pending_thread_count").notNull(), - activeThreadTs: text("active_thread_ts"), - repliesCursor: text("replies_cursor"), - oldestMessageTs: text("oldest_message_ts"), - newestMessageTs: text("newest_message_ts"), - firstDiscoveredAt: integer("first_discovered_at").notNull(), - historyCompleteAt: integer("history_complete_at"), - repliesCompleteAt: integer("replies_complete_at"), - lastObservedAt: integer("last_observed_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - export const daemonState = sqliteTable("daemon_state", { singletonKey: text("singleton_key").primaryKey(), pid: integer("pid"), @@ -284,17 +297,6 @@ export const contactSources = sqliteTable("contact_sources", { metadataJson: text("metadata_json"), }); -export const contactMergeDecisions = sqliteTable("contact_merge_decisions", { - id: text("id").primaryKey(), - decisionType: text("decision_type").notNull(), - primaryContactId: text("primary_contact_id").notNull(), - secondaryContactId: text("secondary_contact_id").notNull(), - canonicalContactId: text("canonical_contact_id").notNull(), - reason: text("reason"), - createdBy: text("created_by"), - createdAt: integer("created_at").notNull(), -}); - export const contactMemories = sqliteTable("contact_memories", { id: text("id").primaryKey(), contactId: text("contact_id").notNull(), diff --git a/src/macos/install.test.ts b/src/macos/install.test.ts index 084ed564..d99d7af0 100644 --- a/src/macos/install.test.ts +++ b/src/macos/install.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -83,34 +83,27 @@ describe("macOS app bundle resolution", () => { expect(isValidCuedAppBundle(appPath)).toBe(true); }); - it("ignores legacy app bundles and falls back to the valid candidate", () => { - const legacy = createAppBundle(createTempDir("cued-legacy-app-"), "legacy.invalid.app"); + it("ignores invalid app bundles and falls back to the valid candidate", () => { + const invalid = createAppBundle(createTempDir("cued-invalid-app-"), "invalid.app"); const valid = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - expect(resolveInstalledAppPathFromCandidates([legacy, valid])).toBe(valid); + expect(resolveInstalledAppPathFromCandidates([invalid, valid])).toBe(valid); }); it("resolves the built app path under the repo root after flattening", () => { expect(getBuiltAppPath()).toBe(join(process.cwd(), "native", "macos", "dist", "Cued.app")); }); - it("reports login item status alongside legacy launch agent state", () => { - const homeDir = setTempHome(); + it("reports native login item status", () => { + setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); process.env.CUED_APP_PATH = appPath; - const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); - mkdirSync(join(plistPath, ".."), { recursive: true }); - writeFileSync(plistPath, "legacy"); - execFileSyncMock.mockImplementation((command: string, args?: string[]) => { if (command === join(appPath, "Contents", "MacOS", "CuedDaemon")) { expect(args).toEqual(["login-item", "status"]); return '{"enabled":false,"status":"not_registered","requiresApproval":false,"found":true}'; } - if (command === "launchctl" && args?.[0] === "print") { - return "legacy loaded"; - } throw new Error(`unexpected command: ${command}`); }); @@ -119,66 +112,20 @@ describe("macOS app bundle resolution", () => { appPath, enabled: false, status: "not_registered", - legacyLaunchAgent: expect.objectContaining({ - installed: true, - loaded: true, - }), }), ); }); - it("uses the launchctl-reported plist path when the shell HOME differs", () => { + it("enables the native login item", () => { setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); process.env.CUED_APP_PATH = appPath; - const actualHome = createTempDir("cued-actual-home-"); - const actualPlistPath = join(actualHome, "Library", "LaunchAgents", "dev.cued.daemon.plist"); - mkdirSync(join(actualPlistPath, ".."), { recursive: true }); - writeFileSync(actualPlistPath, "legacy"); - - execFileSyncMock.mockImplementation((command: string, args?: string[]) => { - if (command === join(appPath, "Contents", "MacOS", "CuedDaemon")) { - return '{"enabled":false,"status":"not_registered","requiresApproval":false,"found":true}'; - } - if (command === "launchctl" && args?.[0] === "print") { - return `gui/501/dev.cued.daemon = {\n\tpath = ${actualPlistPath}\n}`; - } - throw new Error(`unexpected command: ${command}`); - }); - - expect(getLoginItemStatus(appPath).legacyLaunchAgent).toEqual( - expect.objectContaining({ - plistPath: actualPlistPath, - installed: true, - loaded: true, - }), - ); - }); - - it("migrates an existing legacy launch agent when enabling the login item", () => { - const homeDir = setTempHome(); - const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - process.env.CUED_APP_PATH = appPath; - - const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); - mkdirSync(join(plistPath, ".."), { recursive: true }); - writeFileSync(plistPath, "legacy"); - execFileSyncMock.mockImplementation((command: string, args?: string[]) => { if (command === join(appPath, "Contents", "MacOS", "CuedDaemon")) { expect(args).toEqual(["login-item", "enable"]); return '{"enabled":true,"status":"enabled","requiresApproval":false,"found":true}'; } - if (command === "launchctl" && args?.[0] === "bootout") { - return ""; - } - if (command === "launchctl" && args?.[0] === "print") { - if (existsSync(plistPath)) { - return "legacy loaded"; - } - throw new Error("legacy not loaded"); - } if (command === "ps") { return ""; } @@ -191,24 +138,15 @@ describe("macOS app bundle resolution", () => { expect.objectContaining({ appPath, enabled: true, - migratedLegacyLaunchAgent: true, - legacyLaunchAgent: expect.objectContaining({ - installed: false, - loaded: false, - }), }), ); }); - it("disables the native login item and removes any legacy plist", () => { - const homeDir = setTempHome(); + it("disables the native login item", () => { + setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); process.env.CUED_APP_PATH = appPath; - const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); - mkdirSync(join(plistPath, ".."), { recursive: true }); - writeFileSync(plistPath, "legacy"); - execFileSyncMock.mockImplementation((command: string, args?: string[]) => { if (command === join(appPath, "Contents", "MacOS", "CuedDaemon")) { if (args?.[1] === "status") { @@ -218,12 +156,6 @@ describe("macOS app bundle resolution", () => { return '{"enabled":false,"status":"not_registered","requiresApproval":false,"found":true}'; } } - if (command === "launchctl" && args?.[0] === "bootout") { - return ""; - } - if (command === "launchctl" && args?.[0] === "print") { - throw new Error("legacy not loaded"); - } if (command === "ps") { return ""; } @@ -237,10 +169,6 @@ describe("macOS app bundle resolution", () => { appPath, enabled: false, status: "not_registered", - migratedLegacyLaunchAgent: true, - legacyLaunchAgent: expect.objectContaining({ - installed: false, - }), }), ); }); diff --git a/src/macos/install.ts b/src/macos/install.ts index 0a925832..3f1b8759 100644 --- a/src/macos/install.ts +++ b/src/macos/install.ts @@ -3,13 +3,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { CUED_APP_BUNDLE_IDENTIFIER, CUED_LEGACY_LAUNCH_AGENT_LABELS } from "../core/identity.js"; +import { CUED_APP_BUNDLE_IDENTIFIER } from "../core/identity.js"; import { resolveMacOSNativeBinary } from "../runtime/native-binary.js"; import { terminateCompetingDaemons } from "./competing-daemons.js"; const APP_NAME = "Cued.app"; const APP_EXECUTABLE_NAME = "CuedDaemon"; -const LEGACY_LAUNCH_AGENT_LABEL = CUED_LEGACY_LAUNCH_AGENT_LABELS[0]; type NativeLoginItemStatus = { enabled: boolean; @@ -18,26 +17,11 @@ type NativeLoginItemStatus = { found: boolean; }; -export type LegacyLaunchAgentStatus = { - label: string; - plistPath: string; - installed: boolean; - loaded: boolean; - details?: string; -}; - export type LoginItemStatus = NativeLoginItemStatus & { appPath: string | null; - legacyLaunchAgent: LegacyLaunchAgentStatus; -}; - -export type LoginItemCommandResult = LoginItemStatus & { - migratedLegacyLaunchAgent: boolean; }; -function currentUid(): number { - return typeof process.getuid === "function" ? process.getuid() : 0; -} +export type LoginItemCommandResult = LoginItemStatus; function repoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -225,23 +209,6 @@ export function installMacOSAppFromSource( }; } -function launchAgentPath(): string { - return join(homedir(), "Library", "LaunchAgents", `${LEGACY_LAUNCH_AGENT_LABEL}.plist`); -} - -export function getLaunchAgentPlistPath(): string { - return launchAgentPath(); -} - -function legacyLaunchAgentServiceTarget(): string { - return `gui/${currentUid()}/${LEGACY_LAUNCH_AGENT_LABEL}`; -} - -function parseLaunchAgentPath(details: string): string | null { - const match = details.match(/^\s*path = (.+)$/m); - return match?.[1]?.trim() ?? null; -} - function appExecutablePath(appPath: string): string { return join(appPath, "Contents", "MacOS", APP_EXECUTABLE_NAME); } @@ -291,69 +258,11 @@ function runNativeLoginItemCommand( }; } -export function bootoutLaunchAgent(): { plistPath: string; bootedOut: boolean } { - const status = getLegacyLaunchAgentStatus(); - if (!status.installed && !status.loaded) { - return { plistPath: status.plistPath, bootedOut: false }; - } - - try { - execFileSync("launchctl", ["bootout", legacyLaunchAgentServiceTarget()], { stdio: "ignore" }); - return { plistPath: status.plistPath, bootedOut: true }; - } catch { - return { plistPath: status.plistPath, bootedOut: false }; - } -} - -export function getLegacyLaunchAgentStatus(): LegacyLaunchAgentStatus { - const fallbackPath = launchAgentPath(); - try { - const details = execFileSync("launchctl", ["print", legacyLaunchAgentServiceTarget()], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - const plistPath = parseLaunchAgentPath(details) ?? fallbackPath; - return { - label: LEGACY_LAUNCH_AGENT_LABEL, - plistPath, - installed: existsSync(plistPath), - loaded: true, - details, - }; - } catch (error) { - return { - label: LEGACY_LAUNCH_AGENT_LABEL, - plistPath: fallbackPath, - installed: existsSync(fallbackPath), - loaded: false, - details: error instanceof Error ? error.message : String(error), - }; - } -} - -export function removeLegacyLaunchAgent(): { - plistPath: string; - bootedOut: boolean; - removed: boolean; -} { - const { plistPath, bootedOut } = bootoutLaunchAgent(); - const removed = existsSync(plistPath); - if (removed) { - rmSync(plistPath, { force: true }); - } - return { - plistPath, - bootedOut, - removed, - }; -} - export function getLoginItemStatus(appPath?: string): LoginItemStatus { const resolvedAppPath = appPath ?? resolveInstalledAppPath(); return { ...runNativeLoginItemCommand("status", resolvedAppPath ?? undefined), appPath: resolvedAppPath, - legacyLaunchAgent: getLegacyLaunchAgentStatus(), }; } @@ -366,17 +275,11 @@ export function enableLoginItem(appPath?: string): LoginItemCommandResult { expectedExecutablePath: appExecutablePath(resolvedAppPath), }); - const legacyBefore = getLegacyLaunchAgentStatus(); const native = runNativeLoginItemCommand("enable", resolvedAppPath); - if (legacyBefore.installed) { - removeLegacyLaunchAgent(); - } return { ...native, appPath: resolvedAppPath, - legacyLaunchAgent: getLegacyLaunchAgentStatus(), - migratedLegacyLaunchAgent: legacyBefore.installed, }; } @@ -387,13 +290,10 @@ export function disableLoginItem(appPath?: string): LoginItemCommandResult { nativeStatus.status === "enabled" || nativeStatus.status === "requires_approval" ? runNativeLoginItemCommand("disable", resolvedAppPath ?? undefined) : nativeStatus; - const legacy = removeLegacyLaunchAgent(); return { ...native, appPath: resolvedAppPath, - legacyLaunchAgent: getLegacyLaunchAgentStatus(), - migratedLegacyLaunchAgent: legacy.removed || legacy.bootedOut, }; } diff --git a/src/platforms/core/auth/service.test.ts b/src/platforms/core/auth/service.test.ts index 28911a0a..28630fde 100644 --- a/src/platforms/core/auth/service.test.ts +++ b/src/platforms/core/auth/service.test.ts @@ -59,7 +59,7 @@ describe("IntegrationAuthService", () => { const dir = mkdtempSync(join(tmpdir(), "cued-auth-service-db-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/platforms/core/invocation.test.ts b/src/platforms/core/invocation.test.ts index 773d77e4..6f00fe17 100644 --- a/src/platforms/core/invocation.test.ts +++ b/src/platforms/core/invocation.test.ts @@ -6,7 +6,7 @@ import { } from "./invocation.js"; describe("adapter invocation env", () => { - it("preserves legacy cursor env vars while adding generic cursor env", () => { + it("serializes source cursors through the generic env", () => { const env = buildAdapterInvocationEnv({ platform: "linkedin", checkpointSourceCursorJson: JSON.stringify({ @@ -20,16 +20,10 @@ describe("adapter invocation env", () => { lastSyncAt: 123, syncToken: "sync-token", }), - CUED_LINKEDIN_SOURCE_CURSOR: JSON.stringify({ - lastSyncAt: 123, - syncToken: "sync-token", - }), - CUED_LINKEDIN_LAST_SYNC_AT: "123", - CUED_LINKEDIN_SYNC_TOKEN: "sync-token", }); }); - it("serializes proof rows for generic and Discord legacy proof env", () => { + it("serializes proof rows through the generic env", () => { const env = buildAdapterInvocationEnv({ platform: "discord", proofs: [ @@ -59,12 +53,12 @@ describe("adapter invocation env", () => { lastObservedAt: 456, }, ]); - expect(env.CUED_DISCORD_SYNC_PROOFS).toBe(env.CUED_SYNC_PROOFS); + expect(env).not.toHaveProperty("CUED_DISCORD_SYNC_PROOFS"); }); - it("reads generic invocation env when platform legacy env is absent", () => { + it("reads generic invocation env", () => { expect( - readAdapterInvocationEnv("slack", { + readAdapterInvocationEnv({ CUED_SYNC_SOURCE_CURSOR: JSON.stringify({ lastSyncAt: 123 }), CUED_SYNC_PROOFS: JSON.stringify([{ scopeKey: "C1", proofKind: "messages" }]), }), diff --git a/src/platforms/core/invocation.ts b/src/platforms/core/invocation.ts index aa76df1b..58c1175d 100644 --- a/src/platforms/core/invocation.ts +++ b/src/platforms/core/invocation.ts @@ -18,40 +18,12 @@ export function buildAdapterInvocationEnv(input: { proofs?: AdapterInvocationProofRow[]; }): Record { const env: Record = {}; - const platformEnvPrefix = `CUED_${input.platform.toUpperCase()}_`; - const sourceCursor = safeParseJsonRecord( - input.checkpointSourceCursorJson ?? null, - "sync_checkpoints.source_cursor_json", - ); if (input.checkpointSourceCursorJson) { env.CUED_SYNC_SOURCE_CURSOR = input.checkpointSourceCursorJson; - env[`${platformEnvPrefix}SOURCE_CURSOR`] = input.checkpointSourceCursorJson; } if (input.proofs && input.proofs.length > 0) { - const proofsJson = JSON.stringify(input.proofs.map(serializeInvocationProof)); - env.CUED_SYNC_PROOFS = proofsJson; - if (input.platform === "discord") { - env.CUED_DISCORD_SYNC_PROOFS = proofsJson; - } - } - - if (input.platform === "imessage" && typeof sourceCursor?.rowId === "number") { - env.CUED_IMESSAGE_LAST_ROWID = String(sourceCursor.rowId); - } - if (input.platform === "slack" && typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_SLACK_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); - } - if (input.platform === "linkedin") { - if (typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_LINKEDIN_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); - } - if (typeof sourceCursor?.syncToken === "string" && sourceCursor.syncToken.length > 0) { - env.CUED_LINKEDIN_SYNC_TOKEN = sourceCursor.syncToken; - } - } - if (input.platform === "signal" && typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_SIGNAL_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); + env.CUED_SYNC_PROOFS = JSON.stringify(input.proofs.map(serializeInvocationProof)); } return env; @@ -107,21 +79,13 @@ export function selectAdapterInvocationProofs(input: { return input.proofs.filter((proof) => proof.status === "running"); } -export function readAdapterInvocationEnv( - platform: AdapterPlatform, - env: NodeJS.ProcessEnv = process.env, -): { +export function readAdapterInvocationEnv(env: NodeJS.ProcessEnv = process.env): { sourceCursor?: unknown; syncProofs?: unknown; } { - const platformEnvPrefix = `CUED_${platform.toUpperCase()}_`; return { - sourceCursor: parseOptionalJsonEnv( - env[`${platformEnvPrefix}SOURCE_CURSOR`] ?? env.CUED_SYNC_SOURCE_CURSOR, - ), - syncProofs: parseOptionalJsonEnv( - env[`${platformEnvPrefix}SYNC_PROOFS`] ?? env.CUED_SYNC_PROOFS, - ), + sourceCursor: parseOptionalJsonEnv(env.CUED_SYNC_SOURCE_CURSOR), + syncProofs: parseOptionalJsonEnv(env.CUED_SYNC_PROOFS), }; } diff --git a/src/platforms/core/secrets/keychain.ts b/src/platforms/core/secrets/keychain.ts index e058d430..df885c91 100644 --- a/src/platforms/core/secrets/keychain.ts +++ b/src/platforms/core/secrets/keychain.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { cuedAuthKeychainService, legacyCuedAuthKeychainService } from "../../../core/identity.js"; +import { cuedAuthKeychainService } from "../../../core/identity.js"; import type { Platform } from "../../../core/types/provider.js"; import { openCuedDatabaseReadOnly } from "../../../db/database.js"; @@ -14,10 +14,6 @@ export function authKeychainService(platform: Platform): string { return cuedAuthKeychainService(platform); } -export function legacyAuthKeychainService(platform: Platform): string { - return legacyCuedAuthKeychainService(platform); -} - export function loadKeychainSecret( service: string, account: string, diff --git a/src/platforms/core/state/integration-state.test.ts b/src/platforms/core/state/integration-state.test.ts index c7d2a074..b901f993 100644 --- a/src/platforms/core/state/integration-state.test.ts +++ b/src/platforms/core/state/integration-state.test.ts @@ -63,21 +63,10 @@ describe("integration state management", () => { function createDb(): CuedDatabase { const dir = createTempDir("cued-integrations-db-"); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } - type RawSql = { - prepare(sql: string): { - run(...params: unknown[]): unknown; - get(...params: unknown[]): unknown; - }; - }; - - function rawSql(db: CuedDatabase): RawSql { - return (db as unknown as { sqlite: RawSql }).sqlite; - } - function createPackagedSignalHelper(version = "0.12.9"): string { process.env.CUED_SIGNAL_DIR = createTempDir("cued-signal-config-"); const appPath = join(createTempDir("cued-app-"), "Cued.app"); @@ -604,7 +593,7 @@ process.exit(44); db.close(); }); - it("ignores legacy persisted integrations for unknown platforms", () => { + it("ignores persisted integrations for unknown platforms", () => { const db = createDb(); const sqlite = openSqliteDatabase(db.dbPath); sqlite @@ -629,7 +618,7 @@ process.exit(44); ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( - "legacy-telegram", + "unknown-telegram", "telegram", "default", "Telegram", @@ -639,7 +628,7 @@ process.exit(44); 0, "chromium-auth", "https://web.telegram.org/", - "legacy", + "manual", null, null, 1, @@ -1376,95 +1365,6 @@ process.exit(44); db.close(); }); - it("clears legacy Slack backfill proofs when removing a Slack integration", () => { - const db = createDb(); - const timestamp = Date.now(); - db.upsertIntegrationState({ - platform: "slack", - accountKey: "T123", - displayName: "Acme", - authState: "authenticated", - enabled: true, - connectionKind: "browser-session", - syncCapable: true, - launchStrategy: "chromium-auth", - launchTarget: "https://slack.com/signin", - importedFrom: "local-cli", - metadata: {}, - }); - rawSql(db) - .prepare( - `INSERT INTO slack_backfill_proofs ( - id, - account_key, - team_id, - conversation_id, - conversation_name, - conversation_family, - sync_mode, - scan_started_at, - known_conversation_count, - conversation_phase, - history_complete, - history_cursor, - thread_root_count, - completed_thread_count, - pending_thread_count, - active_thread_ts, - replies_cursor, - oldest_message_ts, - newest_message_ts, - first_discovered_at, - history_complete_at, - replies_complete_at, - last_observed_at, - updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - "proof-1", - "T123", - "T123", - "C123", - "general", - "channel", - "full", - timestamp, - 1, - "complete", - 1, - null, - 0, - 0, - 0, - null, - null, - null, - null, - timestamp, - timestamp, - timestamp, - timestamp, - timestamp, - ); - - expect( - rawSql(db) - .prepare("SELECT COUNT(*) AS count FROM slack_backfill_proofs WHERE account_key = ?") - .get("T123"), - ).toEqual({ count: 1 }); - - removeIntegration(db, "slack", "T123"); - - expect( - rawSql(db) - .prepare("SELECT COUNT(*) AS count FROM slack_backfill_proofs WHERE account_key = ?") - .get("T123"), - ).toEqual({ count: 0 }); - - db.close(); - }); - it("reuses the same stable slack workspace key after remove and reconnect", () => { const db = createDb(); diff --git a/src/platforms/core/state/slack-desktop-import-removal.test.ts b/src/platforms/core/state/slack-desktop-import-removal.test.ts index 8e8443c6..290a15db 100644 --- a/src/platforms/core/state/slack-desktop-import-removal.test.ts +++ b/src/platforms/core/state/slack-desktop-import-removal.test.ts @@ -62,7 +62,7 @@ describe("slack desktop import removal tombstones", () => { function createDb(): CuedDatabase { const db = new CuedDatabase(join(createTempDir("cued-slack-import-db-"), "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/platforms/core/types.ts b/src/platforms/core/types.ts index fd30efc2..16176c70 100644 --- a/src/platforms/core/types.ts +++ b/src/platforms/core/types.ts @@ -291,6 +291,34 @@ export const JOB_STATUS_VALUES = [ ] as const; export type JobStatus = (typeof JOB_STATUS_VALUES)[number]; +export const ACTION_STATUS_VALUES = [ + "proposed", + "approved", + "denied", + "executing", + "executed", + "failed", + "canceled", +] as const; +export type ActionStatus = (typeof ACTION_STATUS_VALUES)[number]; + +export const ACTION_APPROVAL_STATUS_VALUES = [ + "pending", + "approved", + "denied", + "auto_approved", +] as const; +export type ActionApprovalStatus = (typeof ACTION_APPROVAL_STATUS_VALUES)[number]; + +export const ACTION_EXECUTION_STATUS_VALUES = [ + "pending", + "running", + "succeeded", + "failed", + "skipped", +] as const; +export type ActionExecutionStatus = (typeof ACTION_EXECUTION_STATUS_VALUES)[number]; + export const RAW_EVENT_ENTITY_KIND_VALUES = [ "contact", "conversation", diff --git a/src/platforms/discord/sync/bundle.ts b/src/platforms/discord/sync/bundle.ts index 0560c33c..b8a0921a 100644 --- a/src/platforms/discord/sync/bundle.ts +++ b/src/platforms/discord/sync/bundle.ts @@ -121,18 +121,8 @@ export async function buildDiscordSyncBundle( ): Promise { const accountKey = input.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; const client = options.client ?? new DiscordApiClient(loadDiscordCredentials(accountKey)); - const sourceCursor = parseDiscordSyncCursor( - options.sourceCursor ?? - (typeof process.env.CUED_DISCORD_SOURCE_CURSOR === "string" - ? JSON.parse(process.env.CUED_DISCORD_SOURCE_CURSOR) - : null), - ); - const syncProofState = parseDiscordProofState( - options.syncProofs ?? - (typeof process.env.CUED_DISCORD_SYNC_PROOFS === "string" - ? JSON.parse(process.env.CUED_DISCORD_SYNC_PROOFS) - : null), - ); + const sourceCursor = parseDiscordSyncCursor(options.sourceCursor ?? null); + const syncProofState = parseDiscordProofState(options.syncProofs ?? null); const syncMessageChannelLimit = options.syncMessageChannelLimit ?? getDiscordSyncMessageChannelLimit(); const syncMessagesPerChannelLimit = diff --git a/src/platforms/discord/sync/worker.ts b/src/platforms/discord/sync/worker.ts index 193f9e4d..8123be20 100644 --- a/src/platforms/discord/sync/worker.ts +++ b/src/platforms/discord/sync/worker.ts @@ -3,7 +3,7 @@ import { buildDiscordSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("discord"); + const invocation = readAdapterInvocationEnv(); const bundle = await buildDiscordSyncBundle( { accountKey: process.env.CUED_ACCOUNT_KEY, diff --git a/src/platforms/gmail/oauth/client.ts b/src/platforms/gmail/oauth/client.ts index d9d99d01..d8765f7e 100644 --- a/src/platforms/gmail/oauth/client.ts +++ b/src/platforms/gmail/oauth/client.ts @@ -137,7 +137,7 @@ export function readGoogleOAuthClientConfigForCredentials( authUri: "https://accounts.google.com/o/oauth2/v2/auth", tokenUri: credentials.tokenUri || "https://oauth2.googleapis.com/token", }, - filePath: "keychain-legacy", + filePath: "keychain-inline", }; } return readGoogleOAuthClientConfig(); diff --git a/src/platforms/gmail/sync/worker.ts b/src/platforms/gmail/sync/worker.ts index 641ef447..5342db91 100644 --- a/src/platforms/gmail/sync/worker.ts +++ b/src/platforms/gmail/sync/worker.ts @@ -3,7 +3,7 @@ import { buildGmailSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("gmail"); + const invocation = readAdapterInvocationEnv(); const pageBudget = process.env.CUED_GMAIL_PAGE_BUDGET ? Number(process.env.CUED_GMAIL_PAGE_BUDGET) : undefined; diff --git a/src/platforms/imessage/worker.ts b/src/platforms/imessage/worker.ts index f9de8612..3c9f9987 100644 --- a/src/platforms/imessage/worker.ts +++ b/src/platforms/imessage/worker.ts @@ -4,10 +4,9 @@ import { buildIMessageSyncBundle, DEFAULT_IMESSAGE_BATCH_LIMIT } from "./sync.js async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("imessage"); + const invocation = readAdapterInvocationEnv(); const bundle = buildIMessageSyncBundle({ path: process.env.CUED_IMESSAGE_DB_PATH || undefined, - lastRowId: Number(process.env.CUED_IMESSAGE_LAST_ROWID || "0"), sourceCursor: invocation.sourceCursor, limit: Number(process.env.CUED_IMESSAGE_BATCH_LIMIT || String(DEFAULT_IMESSAGE_BATCH_LIMIT)), callHistoryPath: process.env.CUED_CALL_HISTORY_DB_PATH || undefined, diff --git a/src/platforms/linkedin/sync/bundle.ts b/src/platforms/linkedin/sync/bundle.ts index 6ade2321..8ea64f58 100644 --- a/src/platforms/linkedin/sync/bundle.ts +++ b/src/platforms/linkedin/sync/bundle.ts @@ -496,7 +496,7 @@ async function listMessagesForConversation( try { latest = await client.getMessages(conversation.entityURN); } catch (error) { - if (isLinkedInLegacyPaginationError(error)) { + if (isLinkedInPaginationRequestError(error)) { return buildMessageBatchResult({ messages: [...seen.values()] .filter((message) => message.entityURN) @@ -506,7 +506,7 @@ async function listMessagesForConversation( resumeCursor: null, previousCursor: resumeCursor, error: { - code: "legacy_pagination_400", + code: "pagination_400", message: error.message, }, }); @@ -542,7 +542,7 @@ async function listMessagesForConversation( ? await client.getMessagesWithPrevCursor(conversation.entityURN, prevCursor) : await client.getMessagesBefore(conversation.entityURN, oldestDeliveredAt - 1); } catch (error) { - if (isLinkedInLegacyPaginationError(error)) { + if (isLinkedInPaginationRequestError(error)) { return buildMessageBatchResult({ messages: [...seen.values()] .filter((message) => message.entityURN) @@ -552,7 +552,7 @@ async function listMessagesForConversation( resumeCursor: null, previousCursor: resumeCursor, error: { - code: "legacy_pagination_400", + code: "pagination_400", message: error.message, }, }); @@ -615,7 +615,7 @@ function loadProjectedReactions( ); } -function isLinkedInLegacyPaginationError(error: unknown): error is LinkedInRequestError { +function isLinkedInPaginationRequestError(error: unknown): error is LinkedInRequestError { return error instanceof LinkedInRequestError && error.statusCode === 400; } diff --git a/src/platforms/linkedin/sync/worker.ts b/src/platforms/linkedin/sync/worker.ts index c4e28c96..dc5fc1ee 100644 --- a/src/platforms/linkedin/sync/worker.ts +++ b/src/platforms/linkedin/sync/worker.ts @@ -3,14 +3,9 @@ import { buildLinkedInSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_LINKEDIN_LAST_SYNC_AT - ? Number(process.env.CUED_LINKEDIN_LAST_SYNC_AT) - : undefined; - const invocation = readAdapterInvocationEnv("linkedin"); + const invocation = readAdapterInvocationEnv(); const bundle = await buildLinkedInSyncBundle({ accountKey: process.env.CUED_ACCOUNT_KEY, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, - syncToken: process.env.CUED_LINKEDIN_SYNC_TOKEN ?? null, sourceCursor: invocation.sourceCursor, syncProofs: invocation.syncProofs, }); diff --git a/src/platforms/signal/cli/client.test.ts b/src/platforms/signal/cli/client.test.ts index d3db68f7..71976e85 100644 --- a/src/platforms/signal/cli/client.test.ts +++ b/src/platforms/signal/cli/client.test.ts @@ -114,13 +114,16 @@ describe("signal cli helpers", () => { expect(resolveSignalCliPath({}, repoRoot)).toBe(repoHelper); }); - it("returns null when no bundled helper exists and ignores legacy env overrides", () => { + it("returns null when no bundled helper exists and ignores env overrides", () => { const repoRoot = createTempDir("cued-signal-repo-"); - const legacyOverride = join(createTempDir("cued-signal-legacy-"), "signal-cli"); - createSignalHelperBinary(legacyOverride); + const ignoredOverride = join(createTempDir("cued-signal-override-"), "signal-cli"); + createSignalHelperBinary(ignoredOverride); expect( - resolveSignalCliPath({ CUED_SIGNAL_CLI_PATH: legacyOverride } as NodeJS.ProcessEnv, repoRoot), + resolveSignalCliPath( + { CUED_SIGNAL_CLI_PATH: ignoredOverride } as NodeJS.ProcessEnv, + repoRoot, + ), ).toBeNull(); }); diff --git a/src/platforms/signal/sync/bundle.test.ts b/src/platforms/signal/sync/bundle.test.ts index d9f33bef..3f6ce0c5 100644 --- a/src/platforms/signal/sync/bundle.test.ts +++ b/src/platforms/signal/sync/bundle.test.ts @@ -6,7 +6,7 @@ describe("signal worker lib", () => { const bundle = await buildSignalSyncBundle({ accountKey: "default", account: "+14155550000", - lastSyncAt: 1_700_000_000_000, + sourceCursor: { lastSyncAt: 1_700_000_000_000 }, client: { async listContacts() { return [ diff --git a/src/platforms/signal/sync/bundle.ts b/src/platforms/signal/sync/bundle.ts index fb707b71..09b61df3 100644 --- a/src/platforms/signal/sync/bundle.ts +++ b/src/platforms/signal/sync/bundle.ts @@ -13,7 +13,7 @@ type SignalClientLike = Pick { const accountKey = options?.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; @@ -57,7 +57,15 @@ export async function buildSignalSyncBundle(options?: { account, lastSyncAt: observedBase, }, - syncMode: options?.lastSyncAt ? "incremental" : "full", + syncMode: hasSignalSourceCursor(options?.sourceCursor) ? "incremental" : "full", hasMore: false, }; } + +function hasSignalSourceCursor(value: unknown): boolean { + return Boolean( + value && + typeof value === "object" && + typeof (value as { lastSyncAt?: unknown }).lastSyncAt === "number", + ); +} diff --git a/src/platforms/signal/sync/worker.ts b/src/platforms/signal/sync/worker.ts index 5202102b..6adf48be 100644 --- a/src/platforms/signal/sync/worker.ts +++ b/src/platforms/signal/sync/worker.ts @@ -1,14 +1,13 @@ +import { readAdapterInvocationEnv } from "../../core/invocation.js"; import { buildSignalSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_SIGNAL_LAST_SYNC_AT - ? Number(process.env.CUED_SIGNAL_LAST_SYNC_AT) - : undefined; + const invocation = readAdapterInvocationEnv(); const bundle = await buildSignalSyncBundle({ accountKey: process.env.CUED_ACCOUNT_KEY, account: process.env.CUED_SIGNAL_ACCOUNT, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, + sourceCursor: invocation.sourceCursor, }); process.stdout.write(JSON.stringify({ ok: true, bundle })); } catch (error) { diff --git a/src/platforms/slack/e2e.test.ts b/src/platforms/slack/e2e.test.ts index 85f03bbc..0bea387f 100644 --- a/src/platforms/slack/e2e.test.ts +++ b/src/platforms/slack/e2e.test.ts @@ -136,7 +136,7 @@ describe("slack e2e", () => { const envDir = mkdtempSync(join(tmpdir(), "cued-slack-e2e-")); tempDirs.push(envDir); const db = new CuedDatabase(join(envDir, "local.db")); - db.migrate(); + db.initializeSchema(); const originalExecArgv = [...process.execArgv]; process.execArgv = ["--import", "tsx"]; @@ -646,20 +646,14 @@ async function runSlackCycle( }, ) { const checkpoint = db.getCheckpoint("slack", "workspace-a"); - const sourceCursor = checkpoint?.source_cursor_json - ? JSON.parse(checkpoint.source_cursor_json) - : null; const envOverrides: Record = { CUED_DB_PATH: options.dbPath, CUED_SLACK_HELPER_BINARY: options.helperBinaryPath, CUED_SLACK_HELPER_API_URL: options.apiURL, PATH: `${options.securityDir}:${process.env.PATH ?? ""}`, }; - if (typeof sourceCursor?.lastSyncAt === "number") { - envOverrides.CUED_SLACK_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); - } if (checkpoint?.source_cursor_json) { - envOverrides.CUED_SLACK_SOURCE_CURSOR = checkpoint.source_cursor_json; + envOverrides.CUED_SYNC_SOURCE_CURSOR = checkpoint.source_cursor_json; } if (typeof options.apiPageBudget === "number") { envOverrides.CUED_SLACK_API_PAGE_BUDGET = String(options.apiPageBudget); diff --git a/src/platforms/slack/sync/bundle.test.ts b/src/platforms/slack/sync/bundle.test.ts index 26e47f36..49364eab 100644 --- a/src/platforms/slack/sync/bundle.test.ts +++ b/src/platforms/slack/sync/bundle.test.ts @@ -425,7 +425,7 @@ describe("slack worker lib", () => { startedAt: 1710000000000, oldestMs: 0, usersComplete: true, - conversationCursor: "team:legacy-cursor", + conversationCursor: "team:resume-cursor", }, }, client: { diff --git a/src/platforms/slack/sync/worker.ts b/src/platforms/slack/sync/worker.ts index 6383e002..838275d6 100644 --- a/src/platforms/slack/sync/worker.ts +++ b/src/platforms/slack/sync/worker.ts @@ -3,16 +3,12 @@ import { buildSlackSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_SLACK_LAST_SYNC_AT - ? Number(process.env.CUED_SLACK_LAST_SYNC_AT) - : undefined; - const invocation = readAdapterInvocationEnv("slack"); + const invocation = readAdapterInvocationEnv(); const apiPageBudget = process.env.CUED_SLACK_API_PAGE_BUDGET ? Number(process.env.CUED_SLACK_API_PAGE_BUDGET) : undefined; const bundle = await buildSlackSyncBundle({ accountKey: process.env.CUED_ACCOUNT_KEY, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, sourceCursor: invocation.sourceCursor, syncProofs: invocation.syncProofs, apiPageBudget: Number.isFinite(apiPageBudget) ? apiPageBudget : undefined, diff --git a/src/platforms/whatsapp/diagnostics.test.ts b/src/platforms/whatsapp/diagnostics.test.ts index 9a28b3e9..a1d7ef8c 100644 --- a/src/platforms/whatsapp/diagnostics.test.ts +++ b/src/platforms/whatsapp/diagnostics.test.ts @@ -21,7 +21,7 @@ describe("whatsapp diagnostics", () => { const dir = mkdtempSync(join(tmpdir(), "cued-whatsapp-diagnostics-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/attachments.test.ts b/src/runtime/attachments.test.ts index ce8e7157..a75145b0 100644 --- a/src/runtime/attachments.test.ts +++ b/src/runtime/attachments.test.ts @@ -43,7 +43,7 @@ describe("attachment service", () => { const dir = mkdtempSync(join(tmpdir(), "cued-attachments-db-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/daemon/server.test.ts b/src/runtime/daemon/server.test.ts index 192a2a98..e930c91b 100644 --- a/src/runtime/daemon/server.test.ts +++ b/src/runtime/daemon/server.test.ts @@ -1,7 +1,12 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { CuedDatabase } from "../../db/database.js"; import { buildSyncResumeTargets, buildWhatsAppRealtimeSnapshotFromEvent, + dispatchRequest, getAdaptiveProjectionBatchSize, getAdaptiveProjectionContinueDelayMs, getAutoSyncTargets, @@ -14,6 +19,56 @@ import { shouldSkipConnectedDiscordSchedulerSync, } from "./server.js"; +function createDispatchHarness() { + const dir = mkdtempSync(join(tmpdir(), "cued-daemon-actions-")); + const db = new CuedDatabase(join(dir, "local.db")); + db.initializeSchema(); + const schedulers = { + wakeIngest: () => {}, + wakeProjection: () => {}, + wakeOutbound: () => {}, + wakeSearchIndex: () => {}, + }; + const realtime = { + getStatus: () => null, + ensureDesiredSessions: async () => {}, + shutdown: async () => {}, + }; + const bootstrap = { + state: "ready", + startedAt: Date.now(), + finishedAt: Date.now(), + error: null, + }; + const dispatch = (request: Parameters[1]) => + dispatchRequest( + db, + request, + new Map(), + schedulers as never, + realtime as never, + realtime as never, + realtime as never, + realtime as never, + realtime as never, + bootstrap as never, + () => {}, + () => {}, + () => ({ shuttingDown: false, requestedAt: null }), + () => false, + () => {}, + () => {}, + ); + return { + db, + dispatch, + cleanup: () => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + describe("discord scheduler pacing", () => { const connectedStatus = { platform: "discord" as const, @@ -319,6 +374,134 @@ describe("interactive auth sessions", () => { }); }); +describe("daemon action requests", () => { + it("proposes actions through dispatch", async () => { + const harness = createDispatchHarness(); + try { + const response = await harness.dispatch({ + id: "actions-propose", + command: "actions-propose", + actionType: "contact.message.draft", + payload: { + contactId: "contact-1", + body: "Following up on our last thread.", + reason: "Recent inbound message has no newer outbound reply.", + }, + title: "Draft follow-up", + createdBy: "daemon-test", + }); + + expect(response).toMatchObject({ + id: "actions-propose", + ok: true, + result: { + action_type: "contact.message.draft", + status: "proposed", + approval_status: "pending", + title: "Draft follow-up", + source_skill: "cued", + created_by: "daemon-test", + }, + }); + } finally { + harness.cleanup(); + } + }); + + it("approves and executes actions through dispatch", async () => { + const harness = createDispatchHarness(); + try { + const action = harness.db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "missing-memory" }, + }); + + const listResponse = await harness.dispatch({ + id: "actions-list", + command: "actions-list", + status: "proposed", + }); + expect(listResponse).toMatchObject({ + id: "actions-list", + ok: true, + }); + expect(listResponse.result).toEqual([expect.objectContaining({ id: action.id })]); + + const approveResponse = await harness.dispatch({ + id: "actions-approve", + command: "actions-approve", + actionId: action.id, + approvedBy: "soham", + }); + expect(approveResponse.result).toMatchObject({ + id: action.id, + status: "approved", + approved_by: "soham", + }); + + const executeResponse = await harness.dispatch({ + id: "actions-execute", + command: "actions-execute", + actionId: action.id, + executedBy: "daemon-test", + }); + expect(executeResponse).toMatchObject({ + id: "actions-execute", + ok: false, + error: "Contact memory not found: missing-memory", + }); + expect(harness.db.getAction(action.id)).toMatchObject({ + status: "failed", + execution_status: "failed", + executed_by: "daemon-test", + }); + } finally { + harness.cleanup(); + } + }); + + it("runs approved actions through dispatch", async () => { + const harness = createDispatchHarness(); + try { + const action = harness.db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "missing-memory" }, + requiresApproval: false, + }); + + const response = await harness.dispatch({ + id: "actions-run-approved", + command: "actions-run-approved", + limit: 10, + executedBy: "daemon-test", + }); + + expect(response).toMatchObject({ + id: "actions-run-approved", + ok: true, + result: { + attempted: 1, + succeeded: 0, + failed: 1, + results: [ + { + actionId: action.id, + ok: false, + error: "Contact memory not found: missing-memory", + }, + ], + }, + }); + expect(harness.db.getAction(action.id)).toMatchObject({ + status: "failed", + execution_status: "failed", + }); + } finally { + harness.cleanup(); + } + }); +}); + describe("sync resume targets", () => { it("allows autosync to be explicitly disabled", () => { const previous = process.env.CUED_AUTOSYNC_PLATFORMS; diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index 28286d63..8abb5491 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -3,6 +3,10 @@ import { existsSync, type FSWatcher, rmSync, watch } from "node:fs"; import { createConnection, createServer, type Socket } from "node:net"; import { basename, dirname } from "node:path"; import process from "node:process"; +import { + ActionDefinitionRegistry, + actionRequiresProjectionRebuild, +} from "../../actions/registry.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "../../core/app-metadata.js"; import { CUED_DAEMON_LOCK_PATH, CUED_SOCKET_PATH } from "../../core/config.js"; import { createLogger } from "../../core/logging.js"; @@ -35,6 +39,7 @@ import { refreshLocalIntegrationStates } from "../../platforms/core/state/local- import { refreshManagedIntegrationStates } from "../../platforms/core/state/refresh.js"; import { getIntegrationSummary } from "../../platforms/core/state/status.js"; import type { SyncContinuation } from "../../platforms/core/sync.js"; +import { ACTION_STATUS_VALUES, type ActionStatus } from "../../platforms/core/types.js"; import { DiscordApiClient, isDiscordAuthInvalidationError, @@ -128,7 +133,7 @@ import { import { collectInboundMessageHookPayloads } from "../message-hooks.js"; import { resolveMacOSNativeBinary } from "../native-binary.js"; import { buildOnboardingSnapshot } from "../onboarding.js"; -import { projectRealtimeRange } from "../projection/projector.js"; +import { projectRealtimeRange, rebuildProjectedState } from "../projection/projector.js"; import { buildProjectionMessageHookBatches, mergeProjectionRunDetails, @@ -5020,7 +5025,41 @@ function stopActiveAuthSessionsForIntegration( } } -async function dispatchRequest( +function runApprovedActions( + db: ReturnType, + input: { limit?: number; executedBy?: string | null } = {}, +): { + attempted: number; + succeeded: number; + failed: number; + results: Array<{ actionId: string; ok: boolean; result?: unknown; error?: string }>; +} { + const actions = db.listApprovedPendingActions(input.limit ?? 25); + const results: Array<{ actionId: string; ok: boolean; result?: unknown; error?: string }> = []; + for (const action of actions) { + try { + const executed = db.executeApprovedAction(action.id, input.executedBy ?? "daemon"); + if (actionRequiresProjectionRebuild(executed.action)) { + rebuildProjectedState(db); + } + results.push({ actionId: action.id, ok: true, result: executed }); + } catch (error) { + results.push({ + actionId: action.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { + attempted: results.length, + succeeded: results.filter((result) => result.ok).length, + failed: results.filter((result) => !result.ok).length, + results, + }; +} + +export async function dispatchRequest( db: ReturnType, request: DaemonRequest, activeAuthSessions: Map, @@ -5303,6 +5342,99 @@ async function dispatchRequest( db.executeReadOnlySql(request.query), ), }; + case "actions-propose": { + const actionVersion = request.actionVersion ?? "1"; + const registry = ActionDefinitionRegistry.load(); + const definition = registry.get(request.actionType, actionVersion); + if (!definition) { + throw new Error(`Unknown action definition: ${request.actionType}@${actionVersion}`); + } + const validation = registry.validatePayload( + request.actionType, + actionVersion, + request.payload, + ); + if (!validation.ok) { + throw new Error(validation.errors.join(" ")); + } + return { + id: request.id, + ok: true, + result: db.createAction({ + actionType: request.actionType, + actionVersion, + payload: request.payload, + priority: request.priority, + title: request.title ?? null, + summary: request.summary ?? null, + sourceSkill: request.sourceSkill ?? definition.skillName, + sourceJobId: request.sourceJobId ?? null, + createdBy: request.createdBy ?? "daemon", + requiresApproval: request.requiresApproval ?? definition.requiresApprovalDefault, + dedupeKey: request.dedupeKey ?? null, + }), + }; + } + case "actions-list": + if (request.status && !ACTION_STATUS_VALUES.includes(request.status as ActionStatus)) { + throw new Error(`Invalid action status: ${request.status}`); + } + return { + id: request.id, + ok: true, + result: db.listActions({ + status: request.status as ActionStatus | undefined, + limit: request.limit, + }), + }; + case "actions-show": { + const action = db.getAction(request.actionId); + if (!action) { + throw new Error(`Action not found: ${request.actionId}`); + } + return { + id: request.id, + ok: true, + result: { + action, + effects: db.listActionEffects(request.actionId), + }, + }; + } + case "actions-approve": + return { + id: request.id, + ok: true, + result: db.approveAction(request.actionId, request.approvedBy ?? "user"), + }; + case "actions-deny": + return { + id: request.id, + ok: true, + result: db.denyAction(request.actionId, request.deniedBy ?? "user"), + }; + case "actions-execute": { + const executed = db.executeApprovedAction(request.actionId, request.executedBy ?? "daemon"); + return { + id: request.id, + ok: true, + result: { + ...executed, + projection: actionRequiresProjectionRebuild(executed.action) + ? rebuildProjectedState(db) + : null, + }, + }; + } + case "actions-run-approved": + return { + id: request.id, + ok: true, + result: runApprovedActions(db, { + limit: request.limit, + executedBy: request.executedBy, + }), + }; case "integrations-list": { const integrationAuthService = await getIntegrationAuthService(); return { @@ -5530,25 +5662,6 @@ async function dispatchRequest( ok: true, result: requestUpdateShutdown(), }; - case "contacts-merge": - return { - id: request.id, - ok: true, - result: runQueueService.mergeContacts({ - primaryContactId: request.primaryContactId, - secondaryContactId: request.secondaryContactId, - reason: request.reason, - }), - }; - case "contacts-merge-batch": - return { - id: request.id, - ok: true, - result: runQueueService.mergeContactsBatch({ - merges: request.merges, - apply: request.apply, - }), - }; case "rebuild": return { id: request.id, diff --git a/src/runtime/doctor-status.test.ts b/src/runtime/doctor-status.test.ts index 71bbcc4b..32eafea3 100644 --- a/src/runtime/doctor-status.test.ts +++ b/src/runtime/doctor-status.test.ts @@ -39,7 +39,7 @@ describe("permission status modes", () => { const dir = mkdtempSync(join(tmpdir(), "cued-doctor-status-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/doctor.test.ts b/src/runtime/doctor.test.ts index 9827ac99..65479d9e 100644 --- a/src/runtime/doctor.test.ts +++ b/src/runtime/doctor.test.ts @@ -54,7 +54,7 @@ describe("permission status summaries", () => { const dir = mkdtempSync(join(tmpdir(), "cued-auth-doctor-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/hooks.test.ts b/src/runtime/hooks.test.ts index 38770a66..2a77392b 100644 --- a/src/runtime/hooks.test.ts +++ b/src/runtime/hooks.test.ts @@ -7,10 +7,16 @@ describe("hooks service", () => { const tempDirs: string[] = []; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalCuedHome = process.env.CUED_HOME; afterEach(() => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCuedHome === undefined) { + delete process.env.CUED_HOME; + } else { + process.env.CUED_HOME = originalCuedHome; + } while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -24,6 +30,7 @@ describe("hooks service", () => { tempDirs.push(dir); process.env.HOME = dir; process.env.USERPROFILE = dir; + process.env.CUED_HOME = join(dir, ".cued"); return dir; } diff --git a/src/runtime/ipc.ts b/src/runtime/ipc.ts index 63540e13..a6a38163 100644 --- a/src/runtime/ipc.ts +++ b/src/runtime/ipc.ts @@ -12,6 +12,27 @@ export type DaemonRequest = } | { id: string; command: "permissions-status" } | { id: string; command: "sql"; query: string } + | { + id: string; + command: "actions-propose"; + actionType: string; + actionVersion?: string; + payload: unknown; + priority?: number; + title?: string | null; + summary?: string | null; + sourceSkill?: string | null; + sourceJobId?: string | null; + createdBy?: string | null; + requiresApproval?: boolean; + dedupeKey?: string | null; + } + | { id: string; command: "actions-list"; status?: string; limit?: number } + | { id: string; command: "actions-show"; actionId: string } + | { id: string; command: "actions-approve"; actionId: string; approvedBy?: string } + | { id: string; command: "actions-deny"; actionId: string; deniedBy?: string } + | { id: string; command: "actions-execute"; actionId: string; executedBy?: string } + | { id: string; command: "actions-run-approved"; limit?: number; executedBy?: string } | { id: string; command: "integrations-list" } | { id: string; command: "integrations-refresh" } | { id: string; command: "integrations-interaction-start"; sessionId?: string; ttlMs?: number } @@ -51,23 +72,6 @@ export type DaemonRequest = | { id: string; command: "sync-run"; source?: string } | { id: string; command: "sync-resume" } | { id: string; command: "shutdown-for-update" } - | { - id: string; - command: "contacts-merge"; - primaryContactId: string; - secondaryContactId: string; - reason?: string; - } - | { - id: string; - command: "contacts-merge-batch"; - merges: Array<{ - primaryContactId: string; - secondaryContactId: string; - reason?: string | null; - }>; - apply: boolean; - } | { id: string; command: "rebuild" } | { id: string; command: "reset"; source: string }; diff --git a/src/runtime/onboarding.test.ts b/src/runtime/onboarding.test.ts index 1fa6faec..fb3e1eab 100644 --- a/src/runtime/onboarding.test.ts +++ b/src/runtime/onboarding.test.ts @@ -32,7 +32,7 @@ describe("onboarding snapshot", () => { function createDb(): CuedDatabase { const dir = createTempDir("cued-onboarding-db-"); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/perf/run.ts b/src/runtime/perf/run.ts index 37629769..41e13c7d 100644 --- a/src/runtime/perf/run.ts +++ b/src/runtime/perf/run.ts @@ -634,7 +634,7 @@ function runProjectionReplayBenchmark(rawEvents: ProviderRawEventInput[]): void const dir = createTempDir("cued-perf-projection-"); const db = new CuedDatabase(join(dir, "local.db")); try { - db.migrate(); + db.initializeSchema(); db.insertRawEvents(rawEvents); projectPendingRawEvents(db); } finally { @@ -647,7 +647,7 @@ function runIncrementalInsertBenchmark(rawEvents: ProviderRawEventInput[]): void const dir = createTempDir("cued-perf-incremental-insert-"); const db = new CuedDatabase(join(dir, "local.db")); try { - db.migrate(); + db.initializeSchema(); db.insertRawEvents(rawEvents); } finally { db.close(); @@ -659,7 +659,7 @@ function runIncrementalRealtimeBenchmark(rawEvents: ProviderRawEventInput[]): vo const dir = createTempDir("cued-perf-incremental-hot-"); const db = new CuedDatabase(join(dir, "local.db")); try { - db.migrate(); + db.initializeSchema(); const insertResult = db.insertRawEvents(rawEvents); if (insertResult.firstInsertedRowId != null && insertResult.lastInsertedRowId != null) { projectRealtimeRange(db, { @@ -678,7 +678,7 @@ function runIncrementalCatchupBenchmark(rawEvents: ProviderRawEventInput[]): voi const dir = createTempDir("cued-perf-incremental-catchup-"); const db = new CuedDatabase(join(dir, "local.db")); try { - db.migrate(); + db.initializeSchema(); const insertResult = db.insertRawEvents(rawEvents); if (insertResult.firstInsertedRowId != null && insertResult.lastInsertedRowId != null) { projectRealtimeRange(db, { diff --git a/src/runtime/projection/events.test.ts b/src/runtime/projection/events.test.ts index a8b9ac02..d6e13903 100644 --- a/src/runtime/projection/events.test.ts +++ b/src/runtime/projection/events.test.ts @@ -74,54 +74,6 @@ describe("normalized raw event registry", () => { }); }); - it("canonicalizes legacy event kinds when normalized schema is missing", () => { - const normalized = normalizeStoredRawEventForProjection( - { - entityKind: "message", - eventKind: "message_created", - }, - { - sourceMessageKey: "message-1", - sourceConversationKey: "conversation-1", - senderSourceKey: "contact-1", - sentAt: 1, - content: "hello", - }, - ); - - expect(normalized).toMatchObject({ - entityKind: "message", - eventKind: "created", - normalizedSchema: "message.created@1", - }); - }); - - it("upcasts legacy system-message payloads during projection normalization", () => { - const normalized = normalizeStoredRawEventForProjection( - { - entityKind: "message", - eventKind: "message_observed", - }, - { - sourceMessageKey: "message-1", - sourceConversationKey: "conversation-1", - sentAt: 1, - content: "Ava joined", - }, - ); - - expect(normalized).toMatchObject({ - entityKind: "timeline_event", - eventKind: "system_message", - normalizedSchema: "timeline_event.system_message@1", - payload: expect.objectContaining({ - sourceEventKey: "message-1", - sourceConversationKey: "conversation-1", - eventKind: "system_message", - }), - }); - }); - it("rejects non-canonical schemas for new writes", () => { expect(() => assertCanonicalNormalizedSchemaForWrite("message.created@1")).not.toThrow(); expect(() => assertCanonicalNormalizedSchemaForWrite("call.observed@1")).not.toThrow(); @@ -186,26 +138,22 @@ describe("normalized raw event registry", () => { ); }); - it("canonicalizes legacy normalized schemas during projection normalization", () => { - const normalized = normalizeStoredRawEventForProjection( - { - entityKind: "message", - eventKind: "created", - normalizedSchema: "message.message_created@1", - }, - { - sourceMessageKey: "message-1", - sourceConversationKey: "conversation-1", - senderSourceKey: "contact-1", - sentAt: 1, - content: "hello", - }, - ); - - expect(normalized).toMatchObject({ - entityKind: "message", - eventKind: "created", - normalizedSchema: "message.created@1", - }); + it("rejects non-canonical stored schemas during projection normalization", () => { + expect(() => + normalizeStoredRawEventForProjection( + { + entityKind: "message", + eventKind: "created", + normalizedSchema: "message.message_created@1", + }, + { + sourceMessageKey: "message-1", + sourceConversationKey: "conversation-1", + senderSourceKey: "contact-1", + sentAt: 1, + content: "hello", + }, + ), + ).toThrow("Unsupported normalized raw event schema 'message.message_created@1'"); }); }); diff --git a/src/runtime/projection/events.ts b/src/runtime/projection/events.ts index 92a64095..51eb9bc0 100644 --- a/src/runtime/projection/events.ts +++ b/src/runtime/projection/events.ts @@ -1,10 +1,8 @@ import { buildNormalizedRawEventSchema, - type ParticipantPayload, type ProviderRawEventInput, type RawEventEntityKind, type RawEventPayload, - type TimelineEventPayload, } from "../../core/types/provider.js"; type StoredRawEvent = Pick & { @@ -130,212 +128,20 @@ function parseNormalizedSchema(schema: string): { }; } -function buildSystemMessagePayload( - payload: RawEventPayload, - legacyEventKind: string, -): TimelineEventPayload { - if ( - typeof payload === "object" && - payload !== null && - "sourceEventKey" in payload && - typeof payload.sourceEventKey === "string" && - "sourceConversationKey" in payload && - typeof payload.sourceConversationKey === "string" && - "eventAt" in payload && - typeof payload.eventAt === "number" - ) { - const timelinePayload = payload as TimelineEventPayload; - return { - ...timelinePayload, - eventKind: "system_message", - metadata: { - ...(timelinePayload.metadata ?? {}), - legacyEventKind, - }, - }; - } - - if ( - typeof payload === "object" && - payload !== null && - "sourceMessageKey" in payload && - typeof payload.sourceMessageKey === "string" && - "sourceConversationKey" in payload && - typeof payload.sourceConversationKey === "string" && - "sentAt" in payload && - typeof payload.sentAt === "number" - ) { - const messagePayload = payload as Record; - return { - sourceEventKey: String(messagePayload.sourceMessageKey), - sourceConversationKey: String(messagePayload.sourceConversationKey), - eventKind: "system_message", - eventAt: Number(messagePayload.sentAt), - text: typeof messagePayload.content === "string" ? messagePayload.content : null, - metadata: { - legacyEventKind, - sourceMessageKey: messagePayload.sourceMessageKey, - }, - }; - } - - throw new Error( - `Unable to upcast payload for legacy normalized raw event schema '${legacyEventKind}'`, - ); -} - -function buildParticipantPayload( - payload: RawEventPayload, - eventKind: "joined" | "left", -): ParticipantPayload { - if ( - typeof payload !== "object" || - payload === null || - !("sourceConversationKey" in payload) || - typeof payload.sourceConversationKey !== "string" - ) { - throw new Error(`Unable to upcast payload for legacy participant schema '${eventKind}'`); - } - const timelinePayload = payload as TimelineEventPayload; - const participantSourceKey = - typeof timelinePayload.subjectSourceKey === "string" && - timelinePayload.subjectSourceKey.length > 0 - ? timelinePayload.subjectSourceKey - : typeof timelinePayload.actorSourceKey === "string" && - timelinePayload.actorSourceKey.length > 0 - ? timelinePayload.actorSourceKey - : ""; - if (!participantSourceKey) { - throw new Error(`Unable to upcast payload for legacy participant schema '${eventKind}'`); - } - return { - sourceConversationKey: timelinePayload.sourceConversationKey, - participantSourceKey, - eventAt: timelinePayload.eventAt, - metadata: { - ...(timelinePayload.metadata ?? {}), - legacyTimelineEventKind: timelinePayload.eventKind, - }, - }; -} - -function canonicalizeProjectedSchema( +function normalizeCanonicalSchema( normalizedSchema: string, payload: RawEventPayload, ): NormalizedProjectedRawEvent { - switch (normalizedSchema) { - case "message.message_created@1": - return { - entityKind: "message", - eventKind: "created", - normalizedSchema: buildNormalizedRawEventSchema("message", "created"), - payload, - }; - case "message.message_updated@1": - return { - entityKind: "message", - eventKind: "updated", - normalizedSchema: buildNormalizedRawEventSchema("message", "updated"), - payload, - }; - case "message.message_deleted@1": - return { - entityKind: "message", - eventKind: "deleted", - normalizedSchema: buildNormalizedRawEventSchema("message", "deleted"), - payload, - }; - case "message.message_read_receipt@1": - return { - entityKind: "message", - eventKind: "read_receipt", - normalizedSchema: buildNormalizedRawEventSchema("message", "read_receipt"), - payload, - }; - case "message.message_observed@1": - return { - entityKind: "timeline_event", - eventKind: "system_message", - normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), - payload: buildSystemMessagePayload(payload, "message_observed"), - }; - case "reaction.created@1": { - const reactionEventKind = - typeof payload === "object" && - payload !== null && - "isActive" in payload && - payload.isActive === false - ? "removed" - : "added"; - return { - entityKind: "reaction", - eventKind: reactionEventKind, - normalizedSchema: buildNormalizedRawEventSchema("reaction", reactionEventKind), - payload, - }; - } - case "reaction.reaction_added@1": - return { - entityKind: "reaction", - eventKind: "added", - normalizedSchema: buildNormalizedRawEventSchema("reaction", "added"), - payload, - }; - case "reaction.reaction_removed@1": - return { - entityKind: "reaction", - eventKind: "removed", - normalizedSchema: buildNormalizedRawEventSchema("reaction", "removed"), - payload, - }; - case "timeline_event.linkedin_system_message@1": - return { - entityKind: "timeline_event", - eventKind: "system_message", - normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), - payload: buildSystemMessagePayload(payload, "linkedin_system_message"), - }; - case "timeline_event.linkedin_group_read_receipt@1": - return { - entityKind: "timeline_event", - eventKind: "system_message", - normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), - payload: buildSystemMessagePayload(payload, "linkedin_group_read_receipt"), - }; - case "timeline_event.linkedin_conversation_removed@1": - return { - entityKind: "timeline_event", - eventKind: "system_message", - normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), - payload: buildSystemMessagePayload(payload, "linkedin_conversation_removed"), - }; - case "timeline_event.participant_joined@1": - return { - entityKind: "participant", - eventKind: "joined", - normalizedSchema: buildNormalizedRawEventSchema("participant", "joined"), - payload: buildParticipantPayload(payload, "joined"), - }; - case "timeline_event.participant_left@1": - return { - entityKind: "participant", - eventKind: "left", - normalizedSchema: buildNormalizedRawEventSchema("participant", "left"), - payload: buildParticipantPayload(payload, "left"), - }; - default: { - const parsed = parseNormalizedSchema(normalizedSchema); - if (!SUPPORTED_NORMALIZED_SCHEMAS.has(normalizedSchema)) { - throw new Error(`Unsupported normalized raw event schema '${normalizedSchema}'`); - } - return { - entityKind: parsed.entityKind, - eventKind: parsed.eventKind, - normalizedSchema, - payload, - }; - } + const parsed = parseNormalizedSchema(normalizedSchema); + if (!SUPPORTED_NORMALIZED_SCHEMAS.has(normalizedSchema)) { + throw new Error(`Unsupported normalized raw event schema '${normalizedSchema}'`); } + return { + entityKind: parsed.entityKind, + eventKind: parsed.eventKind, + normalizedSchema, + payload, + }; } export function normalizeStoredRawEventForProjection( @@ -345,5 +151,5 @@ export function normalizeStoredRawEventForProjection( const normalizedSchema = event.normalizedSchema?.trim() || buildNormalizedRawEventSchema(event.entityKind, event.eventKind); - return canonicalizeProjectedSchema(normalizedSchema, payload); + return normalizeCanonicalSchema(normalizedSchema, payload); } diff --git a/src/runtime/projection/projector.ts b/src/runtime/projection/projector.ts index 26d4c923..c5617e87 100644 --- a/src/runtime/projection/projector.ts +++ b/src/runtime/projection/projector.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { eq, type SQL, sql } from "drizzle-orm"; +import { listContactMergeAliases } from "../../actions/contact-merge-effects.js"; import type { CallPayload, ContactObservationPayload, @@ -613,7 +614,7 @@ function hydrateProjectionCache(db: CuedDatabase, cache: ProjectionCache): void cache.contactNameMap.clear(); cache.conversationNameMap.clear(); - for (const row of db.listContactMergeAliases()) { + for (const row of listContactMergeAliases(db)) { cache.contactMergeAliasMap.set(row.contact_id, row.canonical_contact_id); } @@ -668,7 +669,7 @@ function getProjectionCache(db: CuedDatabase): ProjectionCache { function resetProjectionCache(db: CuedDatabase): ProjectionCache { const cache = createProjectionCache(); - for (const row of db.listContactMergeAliases()) { + for (const row of listContactMergeAliases(db)) { cache.contactMergeAliasMap.set(row.contact_id, row.canonical_contact_id); } cache.initialized = true; diff --git a/src/runtime/projection/replay.test.ts b/src/runtime/projection/replay.test.ts index e003b4dd..9e37ac66 100644 --- a/src/runtime/projection/replay.test.ts +++ b/src/runtime/projection/replay.test.ts @@ -27,7 +27,7 @@ describe("projection replay", () => { const dir = mkdtempSync(join(tmpdir(), "cued-projection-replay-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } diff --git a/src/runtime/run-queue.ts b/src/runtime/run-queue.ts index d57bdafa..9e815e21 100644 --- a/src/runtime/run-queue.ts +++ b/src/runtime/run-queue.ts @@ -4,8 +4,7 @@ import { isAdapterPlatform, isPlatform, } from "../core/types/provider.js"; -import type { ContactMergeBatchInput, CuedDatabase } from "../db/database.js"; -import { rebuildProjectedState } from "./projection/projector.js"; +import type { CuedDatabase } from "../db/database.js"; type RunQueueSchedulers = { wakeIngest?: () => void; @@ -176,6 +175,13 @@ export class RunQueueService { targets: queuedTargets, }; } + + return { + queued: false, + runId: null, + runIds: [], + targets: [], + }; } const runId = this.db.queueSyncRun({ @@ -274,57 +280,6 @@ export class RunQueueService { this.schedulers.wakeProjection?.(); return result; } - - mergeContacts(input: { primaryContactId: string; secondaryContactId: string; reason?: string }): { - merged: true; - decisionId: string; - primaryContactId: string; - secondaryContactId: string; - canonicalContactId: string; - projection: ReturnType; - } { - const decision = this.db.recordContactMergeDecision({ - primaryContactId: input.primaryContactId, - secondaryContactId: input.secondaryContactId, - reason: input.reason ?? null, - createdBy: "cli", - }); - const projection = rebuildProjectedState(this.db); - - return { - merged: true, - decisionId: decision.decisionId, - primaryContactId: decision.primaryContactId, - secondaryContactId: decision.secondaryContactId, - canonicalContactId: decision.canonicalContactId, - projection, - }; - } - - mergeContactsBatch(input: { merges: ContactMergeBatchInput[]; apply: boolean }): { - applied: boolean; - mergeCount: number; - decisions: ReturnType; - projection?: ReturnType; - } { - if (!input.apply) { - const decisions = this.db.planContactMergeDecisions(input.merges); - return { - applied: false, - mergeCount: decisions.length, - decisions, - }; - } - - const decisions = this.db.recordContactMergeDecisionsBatch(input.merges, { createdBy: "cli" }); - const projection = rebuildProjectedState(this.db); - return { - applied: true, - mergeCount: decisions.length, - decisions, - projection, - }; - } } function parseRunTargetKey( diff --git a/src/runtime/updater/service.test.ts b/src/runtime/updater/service.test.ts index d8484a1b..f88cc316 100644 --- a/src/runtime/updater/service.test.ts +++ b/src/runtime/updater/service.test.ts @@ -31,7 +31,7 @@ describe("updater service", () => { const dir = mkdtempSync(join(tmpdir(), "cued-updater-")); tempDirs.push(dir); const db = new CuedDatabase(join(dir, "local.db")); - db.migrate(); + db.initializeSchema(); return db; } @@ -267,7 +267,6 @@ describe("updater service", () => { dbBackupPath: "/tmp/backup/local.db", targetVersion: "0.2.0", releaseUrl: null, - migrateLegacyLaunchAgent: false, stagingRoot: "/tmp/staging", }); diff --git a/src/skills/install.test.ts b/src/skills/install.test.ts index b93d85cc..08b44868 100644 --- a/src/skills/install.test.ts +++ b/src/skills/install.test.ts @@ -17,8 +17,11 @@ vi.mock("node:child_process", async (importOriginal) => { import { getGlobalCuedSkillStatus, + getLocalCuedSkillStatus, installGlobalCuedSkill, + installLocalCuedSkill, resolveCuedSkillSourcePath, + resolveLocalCuedSkillInstallPath, resolveNvmNpxPath, } from "./install.js"; @@ -92,6 +95,23 @@ describe("cued skill installer", () => { "---\nname: cued\ndescription: test skill\n---\n", "utf8", ); + mkdirSync(join(skillDir, "actions"), { recursive: true }); + writeFileSync( + join(skillDir, "actions", "test.echo.json"), + JSON.stringify({ + type: "test.echo", + version: "1", + description: "Echo test action", + module: "actions/test-echo.cjs", + payload: { required: {}, optional: {} }, + }), + "utf8", + ); + writeFileSync( + join(skillDir, "actions", "test-echo.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + "utf8", + ); return appPath; } @@ -179,6 +199,84 @@ describe("cued skill installer", () => { ); }); + it("installs the bundled cued skill into the daemon-local skill root", () => { + const homeDir = setTempHome(); + const appPath = createAppBundle(createTempDir("cued-skill-app-")); + process.env.CUED_APP_PATH = appPath; + + expect(resolveLocalCuedSkillInstallPath()).toBe(join(homeDir, ".cued", "skills", "cued")); + expect(getLocalCuedSkillStatus()).toEqual( + expect.objectContaining({ + installed: false, + status: "needs_action", + installedPath: join(homeDir, ".cued", "skills", "cued"), + }), + ); + expect(installLocalCuedSkill()).toEqual( + expect.objectContaining({ + ok: true, + scope: "daemon-local", + sourcePath: join(appPath, "Contents", "Resources", "skills", "cued"), + installedPath: join(homeDir, ".cued", "skills", "cued"), + actionDefinitionCount: 1, + executorCount: 1, + }), + ); + expect(getLocalCuedSkillStatus()).toEqual( + expect.objectContaining({ + installed: true, + status: "installed", + actionDefinitionCount: 1, + executorCount: 1, + }), + ); + }); + + it("installs arbitrary skill roots into the daemon-local skill root", () => { + const homeDir = setTempHome(); + const sourceRoot = join(createTempDir("cued-custom-skill-source-"), "custom-actions"); + mkdirSync(join(sourceRoot, "actions"), { recursive: true }); + writeFileSync(join(sourceRoot, "SKILL.md"), "---\nname: custom-actions\n---\n", "utf8"); + writeFileSync( + join(sourceRoot, "actions", "custom.note.json"), + JSON.stringify({ + type: "custom.note", + version: "1", + description: "Custom note action", + module: "actions/custom-note.cjs", + payload: { required: {}, optional: {} }, + }), + "utf8", + ); + writeFileSync( + join(sourceRoot, "actions", "custom-note.cjs"), + "module.exports = { execute: () => ({ result: { ok: true }, effects: [] }) };\n", + "utf8", + ); + + expect(installLocalCuedSkill(sourceRoot)).toEqual( + expect.objectContaining({ + ok: true, + skillName: "custom-actions", + scope: "daemon-local", + sourcePath: sourceRoot, + installedPath: join(homeDir, ".cued", "skills", "custom-actions"), + actionDefinitionCount: 1, + executorCount: 1, + }), + ); + expect(getLocalCuedSkillStatus("custom-actions")).toEqual( + expect.objectContaining({ + installed: true, + skillName: "custom-actions", + sourcePath: null, + installedPath: join(homeDir, ".cued", "skills", "custom-actions"), + actionDefinitionCount: 1, + executorCount: 1, + }), + ); + }); + it("prefers the newest NVM npx version by semantic version order", () => { const homeDir = setTempHome(); const appPath = createAppBundle(createTempDir("cued-skill-app-")); diff --git a/src/skills/install.ts b/src/skills/install.ts index 7a756e7f..ad8f2f44 100644 --- a/src/skills/install.ts +++ b/src/skills/install.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { getCurrentAppPath } from "../macos/install.js"; @@ -31,6 +31,28 @@ export interface GlobalCuedSkillStatusResult { error?: string; } +export interface LocalSkillInstallResult { + ok: boolean; + skillName: string; + scope: "daemon-local"; + sourcePath: string | null; + installedPath: string; + actionDefinitionCount: number; + executorCount: number; + error?: string; +} + +export interface LocalSkillStatusResult { + installed: boolean; + status: "installed" | "needs_action"; + summary: string; + skillName: string; + sourcePath: string | null; + installedPath: string; + actionDefinitionCount: number; + executorCount: number; +} + function repoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); } @@ -39,6 +61,33 @@ function hasSkillDefinition(path: string): boolean { return existsSync(join(path, "SKILL.md")); } +function cuedHome(): string { + return process.env.CUED_HOME ?? join(homedir(), ".cued"); +} + +export function resolveLocalCuedSkillInstallPath(): string { + return resolveLocalSkillInstallPath(CUED_SKILL_NAME); +} + +export function resolveLocalSkillInstallPath(skillName: string): string { + return join(cuedHome(), "skills", skillName); +} + +function countFiles(path: string, suffix: string): number { + if (!existsSync(path)) { + return 0; + } + return readdirSync(path).filter((fileName) => fileName.endsWith(suffix)).length; +} + +function countSkillActionDefinitions(path: string): number { + return countFiles(join(path, "actions"), ".json"); +} + +function countSkillExecutors(path: string): number { + return countFiles(join(path, "actions"), ".cjs"); +} + export function resolveCuedSkillSourcePath(): string | null { const bundledAppPath = getCurrentAppPath(); const candidates = [ @@ -243,6 +292,26 @@ export function getGlobalCuedSkillStatus(): GlobalCuedSkillStatusResult { } } +export function getLocalCuedSkillStatus(skillName = CUED_SKILL_NAME): LocalSkillStatusResult { + const sourcePath = skillName === CUED_SKILL_NAME ? resolveCuedSkillSourcePath() : null; + const installedPath = resolveLocalSkillInstallPath(skillName); + const installed = hasSkillDefinition(installedPath); + const actionDefinitionCount = countSkillActionDefinitions(installedPath); + const executorCount = countSkillExecutors(installedPath); + return { + installed, + status: installed ? "installed" : "needs_action", + summary: installed + ? `${skillName} daemon skill is installed locally.` + : `Install the ${skillName} daemon skill to enable local action loading.`, + skillName, + sourcePath, + installedPath, + actionDefinitionCount, + executorCount, + }; +} + export function installGlobalCuedSkill(): GlobalCuedSkillInstallResult { const sourcePath = resolveCuedSkillSourcePath(); if (!sourcePath) { @@ -311,3 +380,63 @@ export function installGlobalCuedSkill(): GlobalCuedSkillInstallResult { }; } } + +export function installLocalCuedSkill(sourcePathInput?: string): LocalSkillInstallResult { + const sourcePath = sourcePathInput ? resolve(sourcePathInput) : resolveCuedSkillSourcePath(); + const skillName = sourcePath ? basename(sourcePath) : CUED_SKILL_NAME; + const installedPath = resolveLocalSkillInstallPath(skillName); + if (!sourcePath) { + return { + ok: false, + skillName, + scope: "daemon-local", + sourcePath: null, + installedPath, + actionDefinitionCount: 0, + executorCount: 0, + error: "Bundled Cued skill not found.", + }; + } + if (!hasSkillDefinition(sourcePath)) { + return { + ok: false, + skillName, + scope: "daemon-local", + sourcePath, + installedPath, + actionDefinitionCount: 0, + executorCount: 0, + error: `Skill definition not found at ${sourcePath}.`, + }; + } + + try { + mkdirSync(dirname(installedPath), { recursive: true }); + if (resolve(sourcePath) !== resolve(installedPath)) { + rmSync(installedPath, { recursive: true, force: true }); + cpSync(sourcePath, installedPath, { recursive: true }); + rmSync(join(installedPath, "cued-workspace"), { recursive: true, force: true }); + rmSync(join(installedPath, "evals", "runs"), { recursive: true, force: true }); + } + return { + ok: true, + skillName, + scope: "daemon-local", + sourcePath, + installedPath, + actionDefinitionCount: countSkillActionDefinitions(installedPath), + executorCount: countSkillExecutors(installedPath), + }; + } catch (error) { + return { + ok: false, + skillName, + scope: "daemon-local", + sourcePath, + installedPath, + actionDefinitionCount: 0, + executorCount: 0, + error: error instanceof Error ? error.message : String(error), + }; + } +} From 056b878a4b02b149748cbec4f703c94fc7ad4a19 Mon Sep 17 00:00:00 2001 From: Theo Tarr Date: Thu, 7 May 2026 09:03:11 -0400 Subject: [PATCH 2/8] Fix pnpm lockfile workspace setting --- pnpm-lock.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d997ac1..5a3bfa52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,6 +3,7 @@ lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false + injectWorkspacePackages: true importers: From 04f70b48ce2afdc6e8cca18b8742956a34545637 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 20:50:00 -0400 Subject: [PATCH 3/8] Deduplicate action execution residue --- .../actions/contact-enrichment-recommend.cjs | 30 - .../actions/contact-followup-recommend.cjs | 29 - .../contact-introduction-recommend.cjs | 37 -- skills/cued/actions/contact-merge.cjs | 32 +- skills/cued/actions/contact-message-draft.cjs | 30 - .../actions/contact.enrichment.recommend.json | 12 +- .../actions/contact.followup.recommend.json | 9 +- .../contact.introduction.recommend.json | 11 +- .../cued/actions/contact.message.draft.json | 9 +- .../conversation-followup-recommend.cjs | 29 - .../actions/conversation-summary-draft.cjs | 30 - .../conversation.followup.recommend.json | 9 +- .../actions/conversation.summary.draft.json | 9 +- skills/cued/actions/record-effect.cjs | 73 +++ src/actions/contact-merge-effects.ts | 34 -- src/actions/execution.ts | 15 +- src/actions/registry.test.ts | 12 +- src/actions/registry.ts | 117 +++- src/cli.ts | 39 +- src/db/database.test.ts | 556 +++++++++++++++++- src/db/database.ts | 79 ++- src/runtime/daemon/server.ts | 50 +- src/runtime/projection/projector.ts | 5 +- 23 files changed, 896 insertions(+), 360 deletions(-) delete mode 100644 skills/cued/actions/contact-enrichment-recommend.cjs delete mode 100644 skills/cued/actions/contact-followup-recommend.cjs delete mode 100644 skills/cued/actions/contact-introduction-recommend.cjs delete mode 100644 skills/cued/actions/contact-message-draft.cjs delete mode 100644 skills/cued/actions/conversation-followup-recommend.cjs delete mode 100644 skills/cued/actions/conversation-summary-draft.cjs create mode 100644 skills/cued/actions/record-effect.cjs delete mode 100644 src/actions/contact-merge-effects.ts diff --git a/skills/cued/actions/contact-enrichment-recommend.cjs b/skills/cued/actions/contact-enrichment-recommend.cjs deleted file mode 100644 index 09d4d0bf..00000000 --- a/skills/cued/actions/contact-enrichment-recommend.cjs +++ /dev/null @@ -1,30 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const contactId = helpers.requiredStringPayload(payload, "contactId", action); - if (!db.contactExists(contactId)) { - throw new Error(`Contact not found: ${contactId}`); - } - - const recommendation = { - contactId, - field: helpers.requiredStringPayload(payload, "field", action), - value: helpers.requiredStringPayload(payload, "value", action), - sourceKind: helpers.optionalStringPayload(payload, "sourceKind", action) ?? "local", - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - confidence: helpers.optionalNumberPayload(payload, "confidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "contact.enrichment.recommended", - targetTable: "contacts", - targetId: contactId, - payload: recommendation, - }); - - return { - result: { recommendation }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/contact-followup-recommend.cjs b/skills/cued/actions/contact-followup-recommend.cjs deleted file mode 100644 index 4029bf63..00000000 --- a/skills/cued/actions/contact-followup-recommend.cjs +++ /dev/null @@ -1,29 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const contactId = helpers.requiredStringPayload(payload, "contactId", action); - if (!db.contactExists(contactId)) { - throw new Error(`Contact not found: ${contactId}`); - } - - const recommendation = { - contactId, - reason: helpers.requiredStringPayload(payload, "reason", action), - suggestedMessage: helpers.optionalStringPayload(payload, "suggestedMessage", action), - dueAt: helpers.optionalNumberPayload(payload, "dueAt", action), - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "contact.followup.recommended", - targetTable: "contacts", - targetId: contactId, - payload: recommendation, - }); - - return { - result: { recommendation }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/contact-introduction-recommend.cjs b/skills/cued/actions/contact-introduction-recommend.cjs deleted file mode 100644 index 078425c7..00000000 --- a/skills/cued/actions/contact-introduction-recommend.cjs +++ /dev/null @@ -1,37 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const fromContactId = helpers.requiredStringPayload(payload, "fromContactId", action); - const toContactId = helpers.requiredStringPayload(payload, "toContactId", action); - if (fromContactId === toContactId) { - throw new Error("Introduction recommendation requires two different contacts."); - } - if (!db.contactExists(fromContactId)) { - throw new Error(`From contact not found: ${fromContactId}`); - } - if (!db.contactExists(toContactId)) { - throw new Error(`To contact not found: ${toContactId}`); - } - - const recommendation = { - fromContactId, - toContactId, - reason: helpers.requiredStringPayload(payload, "reason", action), - suggestedIntro: helpers.optionalStringPayload(payload, "suggestedIntro", action), - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - confidence: helpers.optionalNumberPayload(payload, "confidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "contact.introduction.recommended", - targetTable: "contacts", - targetId: fromContactId, - payload: recommendation, - }); - - return { - result: { recommendation }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/contact-merge.cjs b/skills/cued/actions/contact-merge.cjs index fb4aaf01..3f45b225 100644 --- a/skills/cued/actions/contact-merge.cjs +++ b/skills/cued/actions/contact-merge.cjs @@ -12,43 +12,13 @@ function resolveCanonicalContactId(contactId, aliasMap) { throw new Error(`Contact merge alias cycle detected at ${current}`); } -function parseMergeEffectPayload(payloadJson) { - if (!payloadJson) { - return null; - } - const payload = JSON.parse(payloadJson); - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; - } - if ( - typeof payload.secondaryContactId !== "string" || - typeof payload.canonicalContactId !== "string" - ) { - return null; - } - return { - contact_id: payload.secondaryContactId, - canonical_contact_id: payload.canonicalContactId, - }; -} - -function listContactMergeAliases(db) { - return db - .listActiveActionEffects({ - actionType: "contact.merge", - effectType: "contact.merge.recorded", - }) - .map((effect) => parseMergeEffectPayload(effect.payload_json)) - .filter((alias) => alias !== null); -} - function planContactMerges(db, input, randomUUID) { if (input.length === 0) { throw new Error("At least one contact merge is required."); } const aliasMap = new Map( - listContactMergeAliases(db).map((row) => [row.contact_id, row.canonical_contact_id]), + db.listContactMergeAliases().map((row) => [row.contact_id, row.canonical_contact_id]), ); const planned = []; for (const merge of input) { diff --git a/skills/cued/actions/contact-message-draft.cjs b/skills/cued/actions/contact-message-draft.cjs deleted file mode 100644 index 2080d450..00000000 --- a/skills/cued/actions/contact-message-draft.cjs +++ /dev/null @@ -1,30 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const contactId = helpers.requiredStringPayload(payload, "contactId", action); - if (!db.contactExists(contactId)) { - throw new Error(`Contact not found: ${contactId}`); - } - - const draft = { - contactId, - body: helpers.requiredStringPayload(payload, "body", action), - reason: helpers.requiredStringPayload(payload, "reason", action), - channelHint: helpers.optionalStringPayload(payload, "channelHint", action), - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - confidence: helpers.optionalNumberPayload(payload, "confidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "contact.message.drafted", - targetTable: "contacts", - targetId: contactId, - payload: draft, - }); - - return { - result: { draft }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/contact.enrichment.recommend.json b/skills/cued/actions/contact.enrichment.recommend.json index 38e2a3e3..f1bc5829 100644 --- a/skills/cued/actions/contact.enrichment.recommend.json +++ b/skills/cued/actions/contact.enrichment.recommend.json @@ -2,8 +2,18 @@ "type": "contact.enrichment.recommend", "version": "1", "description": "Record a local-data-backed contact enrichment recommendation.", - "module": "actions/contact-enrichment-recommend.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.enrichment.recommended", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "contactId", + "resultKey": "recommendation", + "defaults": { + "sourceKind": "local" + } + }, "payload": { "required": { "contactId": "string", diff --git a/skills/cued/actions/contact.followup.recommend.json b/skills/cued/actions/contact.followup.recommend.json index 0da0a5c6..bbb44d64 100644 --- a/skills/cued/actions/contact.followup.recommend.json +++ b/skills/cued/actions/contact.followup.recommend.json @@ -2,8 +2,15 @@ "type": "contact.followup.recommend", "version": "1", "description": "Record a harmless follow-up recommendation for a contact.", - "module": "actions/contact-followup-recommend.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.followup.recommended", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "contactId", + "resultKey": "recommendation" + }, "payload": { "required": { "contactId": "string", diff --git a/skills/cued/actions/contact.introduction.recommend.json b/skills/cued/actions/contact.introduction.recommend.json index 936d1c44..ade58796 100644 --- a/skills/cued/actions/contact.introduction.recommend.json +++ b/skills/cued/actions/contact.introduction.recommend.json @@ -2,8 +2,17 @@ "type": "contact.introduction.recommend", "version": "1", "description": "Record a harmless introduction recommendation between two contacts.", - "module": "actions/contact-introduction-recommend.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.introduction.recommended", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "fromContactId", + "requireExistingFields": ["toContactId"], + "distinctFieldPairs": [["fromContactId", "toContactId"]], + "resultKey": "recommendation" + }, "payload": { "required": { "fromContactId": "string", diff --git a/skills/cued/actions/contact.message.draft.json b/skills/cued/actions/contact.message.draft.json index b7556582..87d9811d 100644 --- a/skills/cued/actions/contact.message.draft.json +++ b/skills/cued/actions/contact.message.draft.json @@ -2,8 +2,15 @@ "type": "contact.message.draft", "version": "1", "description": "Record a harmless drafted message for a contact without sending it.", - "module": "actions/contact-message-draft.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.message.drafted", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "contactId", + "resultKey": "draft" + }, "payload": { "required": { "contactId": "string", diff --git a/skills/cued/actions/conversation-followup-recommend.cjs b/skills/cued/actions/conversation-followup-recommend.cjs deleted file mode 100644 index bd8c149f..00000000 --- a/skills/cued/actions/conversation-followup-recommend.cjs +++ /dev/null @@ -1,29 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const conversationId = helpers.requiredStringPayload(payload, "conversationId", action); - if (!db.conversationExists(conversationId)) { - throw new Error(`Conversation not found: ${conversationId}`); - } - - const recommendation = { - conversationId, - reason: helpers.requiredStringPayload(payload, "reason", action), - suggestedNextStep: helpers.optionalStringPayload(payload, "suggestedNextStep", action), - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - confidence: helpers.optionalNumberPayload(payload, "confidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "conversation.followup.recommended", - targetTable: "conversations", - targetId: conversationId, - payload: recommendation, - }); - - return { - result: { recommendation }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/conversation-summary-draft.cjs b/skills/cued/actions/conversation-summary-draft.cjs deleted file mode 100644 index d62fdfc7..00000000 --- a/skills/cued/actions/conversation-summary-draft.cjs +++ /dev/null @@ -1,30 +0,0 @@ -function execute({ action, db, helpers }) { - const payload = helpers.parseActionPayloadObject(action); - const conversationId = helpers.requiredStringPayload(payload, "conversationId", action); - if (!db.conversationExists(conversationId)) { - throw new Error(`Conversation not found: ${conversationId}`); - } - - const draft = { - conversationId, - summary: helpers.requiredStringPayload(payload, "summary", action), - reason: helpers.requiredStringPayload(payload, "reason", action), - timeWindow: helpers.optionalStringPayload(payload, "timeWindow", action), - evidence: helpers.optionalObjectPayload(payload, "evidence", action), - confidence: helpers.optionalNumberPayload(payload, "confidence", action), - }; - const effect = db.recordActionEffect({ - actionId: action.id, - effectType: "conversation.summary.drafted", - targetTable: "conversations", - targetId: conversationId, - payload: draft, - }); - - return { - result: { draft }, - effects: [effect], - }; -} - -module.exports = { execute }; diff --git a/skills/cued/actions/conversation.followup.recommend.json b/skills/cued/actions/conversation.followup.recommend.json index ff935db5..61286db5 100644 --- a/skills/cued/actions/conversation.followup.recommend.json +++ b/skills/cued/actions/conversation.followup.recommend.json @@ -2,8 +2,15 @@ "type": "conversation.followup.recommend", "version": "1", "description": "Record a harmless follow-up recommendation for a conversation.", - "module": "actions/conversation-followup-recommend.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "conversation.followup.recommended", + "targetTable": "conversations", + "targetExists": "conversation", + "targetIdField": "conversationId", + "resultKey": "recommendation" + }, "payload": { "required": { "conversationId": "string", diff --git a/skills/cued/actions/conversation.summary.draft.json b/skills/cued/actions/conversation.summary.draft.json index d6aa8b8a..6ce38db4 100644 --- a/skills/cued/actions/conversation.summary.draft.json +++ b/skills/cued/actions/conversation.summary.draft.json @@ -2,8 +2,15 @@ "type": "conversation.summary.draft", "version": "1", "description": "Record a harmless drafted summary for a conversation without modifying messages.", - "module": "actions/conversation-summary-draft.cjs", + "module": "actions/record-effect.cjs", "requiresApprovalDefault": true, + "effect": { + "effectType": "conversation.summary.drafted", + "targetTable": "conversations", + "targetExists": "conversation", + "targetIdField": "conversationId", + "resultKey": "draft" + }, "payload": { "required": { "conversationId": "string", diff --git a/skills/cued/actions/record-effect.cjs b/skills/cued/actions/record-effect.cjs new file mode 100644 index 00000000..2575f1d0 --- /dev/null +++ b/skills/cued/actions/record-effect.cjs @@ -0,0 +1,73 @@ +function getRequiredString(payload, field, action) { + const value = payload[field]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Action '${action.action_type}' payload field '${field}' must be a string.`); + } + return value; +} + +function assertTargetExists(db, targetKind, id, label) { + const exists = targetKind === "contact" ? db.contactExists(id) : db.conversationExists(id); + if (!exists) { + throw new Error(`${label} not found: ${id}`); + } +} + +function buildEffectPayload(payload, definition) { + const effectPayload = {}; + for (const field of Object.keys(definition.payload.required)) { + effectPayload[field] = payload[field]; + } + for (const field of Object.keys(definition.payload.optional)) { + effectPayload[field] = + payload[field] === undefined || payload[field] === null + ? (definition.effect.defaults[field] ?? null) + : payload[field]; + } + return effectPayload; +} + +function execute({ action, definition, db, helpers }) { + const effectDefinition = definition.effect; + if (!effectDefinition) { + throw new Error(`Action '${action.action_type}' is missing an effect definition.`); + } + const payload = helpers.parseActionPayloadObject(action); + + const targetId = getRequiredString(payload, effectDefinition.targetIdField, action); + assertTargetExists(db, effectDefinition.targetExists, targetId, effectDefinition.targetIdField); + for (const field of effectDefinition.requireExistingFields) { + assertTargetExists( + db, + effectDefinition.targetExists, + getRequiredString(payload, field, action), + field, + ); + } + for (const [leftField, rightField] of effectDefinition.distinctFieldPairs) { + if ( + getRequiredString(payload, leftField, action) === + getRequiredString(payload, rightField, action) + ) { + throw new Error( + `Action '${action.action_type}' requires different ${leftField} and ${rightField}.`, + ); + } + } + + const effectPayload = buildEffectPayload(payload, definition); + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: effectDefinition.effectType, + targetTable: effectDefinition.targetTable, + targetId, + payload: effectPayload, + }); + + return { + result: { [effectDefinition.resultKey]: effectPayload }, + effects: [effect], + }; +} + +module.exports = { execute }; diff --git a/src/actions/contact-merge-effects.ts b/src/actions/contact-merge-effects.ts deleted file mode 100644 index 7853c221..00000000 --- a/src/actions/contact-merge-effects.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CuedDatabase } from "../db/database.js"; - -export interface ContactMergeAlias { - contact_id: string; - canonical_contact_id: string; -} - -function parseMergeEffectPayload(payloadJson: string | null): ContactMergeAlias | null { - if (!payloadJson) { - return null; - } - const payload = JSON.parse(payloadJson) as unknown; - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; - } - const row = payload as Record; - if (typeof row.secondaryContactId !== "string" || typeof row.canonicalContactId !== "string") { - return null; - } - return { - contact_id: row.secondaryContactId, - canonical_contact_id: row.canonicalContactId, - }; -} - -export function listContactMergeAliases(db: CuedDatabase): ContactMergeAlias[] { - return db - .listActiveActionEffects({ - actionType: "contact.merge", - effectType: "contact.merge.recorded", - }) - .map((effect) => parseMergeEffectPayload(effect.payload_json)) - .filter((alias): alias is ContactMergeAlias => alias !== null); -} diff --git a/src/actions/execution.ts b/src/actions/execution.ts index a9e1106a..bb45f4c8 100644 --- a/src/actions/execution.ts +++ b/src/actions/execution.ts @@ -1,6 +1,12 @@ import { randomUUID } from "node:crypto"; import { safeParseJson } from "../db/codecs.js"; -import type { ActionEffectRow, ActionRow, ContactMemoryRow } from "../db/database.js"; +import type { + ActionEffectRow, + ActionRow, + ContactMemoryRow, + ContactMergeAlias, +} from "../db/database.js"; +import type { ActionDefinition } from "./registry.js"; export interface ActionSkillDatabase { addContactMemory(input: { @@ -28,15 +34,12 @@ export interface ActionSkillDatabase { payload?: unknown; appliedAt?: number; }): ActionEffectRow; - listActiveActionEffects(input?: { - actionType?: string; - effectType?: string; - limit?: number; - }): ActionEffectRow[]; + listContactMergeAliases(): ContactMergeAlias[]; } export interface ActionExecutionContext { action: ActionRow; + definition: ActionDefinition; db: ActionSkillDatabase; executedBy: string; helpers: ActionExecutionHelpers; diff --git a/src/actions/registry.test.ts b/src/actions/registry.test.ts index 22641e63..6444a614 100644 --- a/src/actions/registry.test.ts +++ b/src/actions/registry.test.ts @@ -47,15 +47,15 @@ describe("action definition registry", () => { .list() .map((definition) => `${definition.type}@${definition.version}:${definition.module}`), ).toEqual([ - "contact.enrichment.recommend@1:actions/contact-enrichment-recommend.cjs", - "contact.followup.recommend@1:actions/contact-followup-recommend.cjs", - "contact.introduction.recommend@1:actions/contact-introduction-recommend.cjs", + "contact.enrichment.recommend@1:actions/record-effect.cjs", + "contact.followup.recommend@1:actions/record-effect.cjs", + "contact.introduction.recommend@1:actions/record-effect.cjs", "contact.memory.add@1:actions/contact-memory-add.cjs", "contact.memory.stale@1:actions/contact-memory-stale.cjs", "contact.merge@1:actions/contact-merge.cjs", - "contact.message.draft@1:actions/contact-message-draft.cjs", - "conversation.followup.recommend@1:actions/conversation-followup-recommend.cjs", - "conversation.summary.draft@1:actions/conversation-summary-draft.cjs", + "contact.message.draft@1:actions/record-effect.cjs", + "conversation.followup.recommend@1:actions/record-effect.cjs", + "conversation.summary.draft@1:actions/record-effect.cjs", ]); expect(registry.get("contact.merge", "1")?.skillName).toBe("cued"); expect( diff --git a/src/actions/registry.ts b/src/actions/registry.ts index ccb3d6a1..b92381f6 100644 --- a/src/actions/registry.ts +++ b/src/actions/registry.ts @@ -22,6 +22,16 @@ export interface ActionDefinition { required: Record; optional: Record; }; + effect?: { + effectType: string; + targetTable: string; + targetExists: "contact" | "conversation"; + targetIdField: string; + requireExistingFields: string[]; + distinctFieldPairs: Array<[string, string]>; + resultKey: string; + defaults: Record; + }; } interface ActionDefinitionManifest { @@ -139,6 +149,102 @@ function parsePayloadShape(value: unknown, actionType: string): ActionDefinition }; } +function parseStringArray(value: unknown, context: string): string[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error(`${context} must be an array of strings.`); + } + return value; +} + +function parseDistinctFieldPairs(value: unknown, context: string): Array<[string, string]> { + if (value === undefined) { + return []; + } + if ( + !Array.isArray(value) || + !value.every( + (entry): entry is [string, string] => + Array.isArray(entry) && + entry.length === 2 && + typeof entry[0] === "string" && + typeof entry[1] === "string", + ) + ) { + throw new Error(`${context} must be an array of two-string arrays.`); + } + return value; +} + +function parseEffectDefaults( + value: unknown, + context: string, +): Record { + if (value === undefined) { + return {}; + } + if (!isPlainObject(value)) { + throw new Error(`${context} must be an object.`); + } + const defaults: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + typeof entry !== "string" && + typeof entry !== "number" && + typeof entry !== "boolean" && + entry !== null + ) { + throw new Error(`${context}.${key} must be string, number, boolean, or null.`); + } + defaults[key] = entry; + } + return defaults; +} + +function parseEffectDefinition(value: unknown, actionType: string): ActionDefinition["effect"] { + if (value === undefined) { + return undefined; + } + if (!isPlainObject(value)) { + throw new Error(`Action '${actionType}' effect definition must be an object.`); + } + const effectType = typeof value.effectType === "string" ? value.effectType.trim() : ""; + const targetTable = typeof value.targetTable === "string" ? value.targetTable.trim() : ""; + const targetIdField = typeof value.targetIdField === "string" ? value.targetIdField.trim() : ""; + const resultKey = typeof value.resultKey === "string" ? value.resultKey.trim() : ""; + const targetExists = value.targetExists; + if ( + !effectType || + !targetTable || + !targetIdField || + !resultKey || + (targetExists !== "contact" && targetExists !== "conversation") + ) { + throw new Error( + `Action '${actionType}' effect definition must include effectType, targetTable, targetExists, targetIdField, and resultKey.`, + ); + } + + return { + effectType, + targetTable, + targetExists, + targetIdField, + requireExistingFields: parseStringArray( + value.requireExistingFields, + `Action '${actionType}' effect requireExistingFields`, + ), + distinctFieldPairs: parseDistinctFieldPairs( + value.distinctFieldPairs, + `Action '${actionType}' effect distinctFieldPairs`, + ), + resultKey, + defaults: parseEffectDefaults(value.defaults, `Action '${actionType}' effect defaults`), + }; +} + function parseActionDefinition( raw: unknown, index: number, @@ -172,6 +278,7 @@ function parseActionDefinition( }, requiresApprovalDefault: raw.requiresApprovalDefault !== false, payload: parsePayloadShape(raw.payload, type), + effect: parseEffectDefinition(raw.effect, type), }; } @@ -306,16 +413,6 @@ export class ActionDefinitionRegistry { } } -export function actionRequiresProjectionRebuild(action: { - action_type: string; - action_version: string; -}): boolean { - return ( - ActionDefinitionRegistry.load().get(action.action_type, action.action_version)?.postExecution - .rebuildProjection === true - ); -} - function matchesFieldType(value: unknown, expectedType: ActionPayloadFieldType): boolean { switch (expectedType) { case "object": diff --git a/src/cli.ts b/src/cli.ts index 1da90be3..b116e4ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { ActionDefinitionRegistry, actionRequiresProjectionRebuild } from "./actions/registry.js"; +import { ActionDefinitionRegistry } from "./actions/registry.js"; import { type DaemonRequestInput, DaemonRequestTimeoutError, sendDaemonRequest } from "./client.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "./core/app-metadata.js"; import { CUED_DB_PATH, CUED_SOCKET_PATH, ensureCuedDirs } from "./core/config.js"; @@ -1186,9 +1186,7 @@ async function main(): Promise { const executed = db.executeApprovedAction(actionId, executedBy); printJson({ ...executed, - projection: actionRequiresProjectionRebuild(executed.action) - ? rebuildProjectedState(db) - : null, + projection: executed.requiresProjectionRebuild ? rebuildProjectedState(db) : null, }); } finally { db.close(); @@ -1208,28 +1206,17 @@ async function main(): Promise { } const db = openCuedDatabase(); try { - const approved = db.listApprovedPendingActions(limit ?? 25); - const results = approved.map((action) => { - try { - const executed = db.executeApprovedAction(action.id, executedBy); - if (actionRequiresProjectionRebuild(executed.action)) { - rebuildProjectedState(db); - } - return { actionId: action.id, ok: true, result: executed }; - } catch (error) { - return { - actionId: action.id, - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - } - }); - printJson({ - attempted: results.length, - succeeded: results.filter((result) => result.ok).length, - failed: results.filter((result) => !result.ok).length, - results, - }); + printJson( + db.runApprovedActions({ + limit: limit ?? undefined, + executedBy, + afterAction: (executed) => { + if (executed.requiresProjectionRebuild) { + rebuildProjectedState(db); + } + }, + }), + ); } finally { db.close(); } diff --git a/src/db/database.test.ts b/src/db/database.test.ts index 1d19a1aa..ac973d70 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -98,6 +98,27 @@ describe("CuedDatabase", () => { .run(input.id, input.name, timestamp, timestamp); } + function executeContactMerge( + db: CuedDatabase, + input: { primaryContactId: string; secondaryContactId: string; reason?: string | null }, + ) { + const action = db.createAction({ + actionType: "contact.merge", + payload: { + primaryContactId: input.primaryContactId, + secondaryContactId: input.secondaryContactId, + reason: input.reason ?? null, + }, + requiresApproval: false, + }); + return db.executeApprovedAction(action.id, "test").result as { + decisionId: string; + primaryContactId: string; + secondaryContactId: string; + canonicalContactId: string; + }; + } + function insertHandle( db: CuedDatabase, input: { @@ -247,6 +268,523 @@ describe("CuedDatabase", () => { db.close(); }); + it("records the full action lifecycle and execution effects", () => { + const db = createDb(); + + const action = db.createAction({ + actionType: "contact.merge", + actionVersion: "1", + payload: { + primaryContactId: "contact-a", + secondaryContactId: "contact-b", + evidence: ["same normalized phone"], + }, + priority: 10, + title: "Merge Ava duplicate", + summary: "Exact handle overlap.", + sourceSkill: "cued", + createdBy: "agent", + dedupeKey: "contact.merge:contact-a:contact-b", + }); + + expect(action).toMatchObject({ + action_type: "contact.merge", + action_version: "1", + status: "proposed", + approval_status: "pending", + execution_status: "pending", + priority: 10, + source_skill: "cued", + created_by: "agent", + requires_approval: 1, + payload_hash: expect.stringMatching(/^[a-f0-9]{64}$/), + dedupe_key: "contact.merge:contact-a:contact-b", + }); + expect(JSON.parse(action.payload_json)).toEqual({ + primaryContactId: "contact-a", + secondaryContactId: "contact-b", + evidence: ["same normalized phone"], + }); + + const approved = db.approveAction(action.id, "soham"); + expect(approved).toMatchObject({ + status: "approved", + approval_status: "approved", + approved_by: "soham", + approved_at: expect.any(Number), + }); + + const locked = db.lockActionForExecution(action.id, "daemon"); + expect(locked).toMatchObject({ + status: "executing", + execution_status: "running", + executed_by: "daemon", + locked_at: expect.any(Number), + started_at: expect.any(Number), + }); + expect(() => + db.updateActionPayload(action.id, { + primaryContactId: "contact-c", + secondaryContactId: "contact-b", + }), + ).toThrow("Cannot update an action payload after execution has started."); + + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.merge.recorded", + targetTable: "contacts", + targetId: "contact-a", + payload: { canonicalContactId: "contact-a" }, + appliedAt: 1234, + }); + expect(effect).toMatchObject({ + action_id: action.id, + effect_type: "contact.merge.recorded", + target_table: "contacts", + target_id: "contact-a", + payload_json: JSON.stringify({ canonicalContactId: "contact-a" }), + applied_at: 1234, + reverted_at: null, + }); + + const completed = db.completeAction(action.id, { decisionId: "decision-1" }); + expect(completed).toMatchObject({ + status: "executed", + execution_status: "succeeded", + result_json: JSON.stringify({ decisionId: "decision-1" }), + executed_at: expect.any(Number), + }); + expect(db.listActionEffects(action.id).map((row) => row.id)).toEqual([effect.id]); + + db.close(); + }); + + it("enforces action approval and failure transitions", () => { + const db = createDb(); + + const pending = db.createAction({ + actionType: "contact.memory.add", + payload: { contactId: "contact-a", body: "Works on applied AI." }, + createdBy: "agent", + }); + expect(() => db.lockActionForExecution(pending.id)).toThrow( + "Action must be approved before execution.", + ); + + const updated = db.updateActionPayload(pending.id, { + contactId: "contact-a", + body: "Works on applied AI and founder tooling.", + }); + expect(JSON.parse(updated.payload_json)).toEqual({ + contactId: "contact-a", + body: "Works on applied AI and founder tooling.", + }); + expect(updated.payload_hash).not.toBe(pending.payload_hash); + + const denied = db.denyAction(pending.id, "soham"); + expect(denied).toMatchObject({ + status: "denied", + approval_status: "denied", + approved_by: null, + denied_by: "soham", + denied_at: expect.any(Number), + }); + expect(() => db.approveAction(pending.id)).toThrow("Cannot approve a denied action."); + + const autoApproved = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "memory-1" }, + requiresApproval: false, + createdBy: "system", + }); + expect(autoApproved).toMatchObject({ + status: "approved", + approval_status: "auto_approved", + approved_by: "system", + approved_at: expect.any(Number), + }); + db.lockActionForExecution(autoApproved.id, "daemon"); + const failed = db.failAction(autoApproved.id, new Error("memory missing")); + expect(failed).toMatchObject({ + status: "failed", + execution_status: "failed", + error_json: expect.stringContaining("memory missing"), + executed_at: expect.any(Number), + }); + + db.close(); + }); + + it("executes approved contact merge actions and records effects", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + insertContact(db, { id: "contact-b", name: "Ava Duplicate" }); + + const action = db.createAction({ + actionType: "contact.merge", + payload: { + primaryContactId: "contact-a", + secondaryContactId: "contact-b", + reason: "same normalized phone", + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + executed_by: "action-runner", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + action_id: action.id, + effect_type: "contact.merge.recorded", + target_table: "contacts", + target_id: "contact-a", + }); + expect(db.listActionEffects(action.id)).toEqual([ + expect.objectContaining({ action_id: action.id, target_id: "contact-a" }), + ]); + + db.close(); + }); + + it("validates action payloads again at execution time", () => { + const db = createDb(); + const action = db.createAction({ + actionType: "contact.memory.add", + payload: { contactId: "contact-a" }, + requiresApproval: false, + }); + + expect(() => db.executeApprovedAction(action.id, "action-runner")).toThrow( + "Action payload failed validation: Missing required payload field 'body'.", + ); + expect(db.getAction(action.id)).toMatchObject({ + status: "failed", + execution_status: "failed", + error_json: expect.stringContaining("Missing required payload field 'body'."), + }); + + db.close(); + }); + + it("executes approved contact memory actions and records effects", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + + const addAction = db.createAction({ + actionType: "contact.memory.add", + payload: { + contactId: "contact-a", + body: "Works on applied AI.", + sourceKind: "local_messages", + evidence: { messageIds: ["message-1"] }, + confidence: 90, + }, + requiresApproval: false, + }); + const added = db.executeApprovedAction(addAction.id, "action-runner"); + expect(added.action).toMatchObject({ status: "executed", execution_status: "succeeded" }); + expect(added.effects[0]).toMatchObject({ + effect_type: "contact_memory.added", + target_table: "contact_memories", + }); + const memoryId = added.effects[0]!.target_id!; + expect(db.getContactMemory(memoryId)).toMatchObject({ + contact_id: "contact-a", + body: "Works on applied AI.", + source_kind: "local_messages", + confidence: 90, + created_by: "action-runner", + }); + + const staleAction = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId }, + requiresApproval: false, + }); + const staled = db.executeApprovedAction(staleAction.id, "action-runner"); + expect(staled.effects[0]).toMatchObject({ + effect_type: "contact_memory.marked_stale", + target_table: "contact_memories", + target_id: memoryId, + }); + expect(db.getContactMemory(memoryId)).toMatchObject({ + id: memoryId, + stale_at: expect.any(Number), + }); + + db.close(); + }); + + it("executes approved follow-up recommendation actions without mutating contact state", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + + const action = db.createAction({ + actionType: "contact.followup.recommend", + payload: { + contactId: "contact-a", + reason: "They replied last week and no follow-up has been sent.", + suggestedMessage: "Following up on our last thread.", + dueAt: 1_735_689_600_000, + evidence: { messageIds: ["message-1"] }, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "contact.followup.recommended", + target_table: "contacts", + target_id: "contact-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + contactId: "contact-a", + reason: "They replied last week and no follow-up has been sent.", + suggestedMessage: "Following up on our last thread.", + dueAt: 1_735_689_600_000, + evidence: { messageIds: ["message-1"] }, + }); + + db.close(); + }); + + it("executes approved enrichment recommendation actions as effects only", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + + const action = db.createAction({ + actionType: "contact.enrichment.recommend", + payload: { + contactId: "contact-a", + field: "linkedin", + value: "https://www.linkedin.com/in/ava-chen", + sourceKind: "contact_sources", + evidence: { sourceEntityKey: "linkedin:ava-chen" }, + confidence: 95, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "contact.enrichment.recommended", + target_table: "contacts", + target_id: "contact-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + contactId: "contact-a", + field: "linkedin", + value: "https://www.linkedin.com/in/ava-chen", + sourceKind: "contact_sources", + evidence: { sourceEntityKey: "linkedin:ava-chen" }, + confidence: 95, + }); + + db.close(); + }); + + it("executes approved introduction recommendation actions as effects only", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + insertContact(db, { id: "contact-b", name: "Ben Ross" }); + + const action = db.createAction({ + actionType: "contact.introduction.recommend", + payload: { + fromContactId: "contact-a", + toContactId: "contact-b", + reason: "Both have discussed local-first agent infrastructure.", + suggestedIntro: "You should meet because you are working on overlapping problems.", + evidence: { sharedTopics: ["agents", "local context"] }, + confidence: 75, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "contact.introduction.recommended", + target_table: "contacts", + target_id: "contact-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + fromContactId: "contact-a", + toContactId: "contact-b", + reason: "Both have discussed local-first agent infrastructure.", + suggestedIntro: "You should meet because you are working on overlapping problems.", + evidence: { sharedTopics: ["agents", "local context"] }, + confidence: 75, + }); + + db.close(); + }); + + it("executes approved message draft actions as effects only", () => { + const db = createDb(); + insertContact(db, { id: "contact-a", name: "Ava Chen" }); + + const action = db.createAction({ + actionType: "contact.message.draft", + payload: { + contactId: "contact-a", + body: "Following up on our last thread.", + reason: "Recent inbound message has no newer outbound reply.", + channelHint: "imessage", + evidence: { messageIds: ["message-1"] }, + confidence: 70, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "contact.message.drafted", + target_table: "contacts", + target_id: "contact-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + contactId: "contact-a", + body: "Following up on our last thread.", + reason: "Recent inbound message has no newer outbound reply.", + channelHint: "imessage", + evidence: { messageIds: ["message-1"] }, + confidence: 70, + }); + + db.close(); + }); + + it("executes approved conversation summary draft actions as effects only", () => { + const db = createDb(); + insertConversation(db, "conversation-a", "Ava / Ben"); + + const action = db.createAction({ + actionType: "conversation.summary.draft", + payload: { + conversationId: "conversation-a", + summary: "Discussed local-first agent context and action queues.", + reason: "Recent conversation summary requested by agent smoke.", + timeWindow: "last_30_days", + evidence: { messageCount: 12 }, + confidence: 65, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "conversation.summary.drafted", + target_table: "conversations", + target_id: "conversation-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + conversationId: "conversation-a", + summary: "Discussed local-first agent context and action queues.", + reason: "Recent conversation summary requested by agent smoke.", + timeWindow: "last_30_days", + evidence: { messageCount: 12 }, + confidence: 65, + }); + + db.close(); + }); + + it("executes approved conversation follow-up recommendation actions as effects only", () => { + const db = createDb(); + insertConversation(db, "conversation-a", "Ava / Ben"); + + const action = db.createAction({ + actionType: "conversation.followup.recommend", + payload: { + conversationId: "conversation-a", + reason: "The thread has recent activity and needs a next step.", + suggestedNextStep: "Review the thread and decide whether to reply.", + evidence: { messageCount: 12 }, + confidence: 60, + }, + requiresApproval: false, + }); + + const executed = db.executeApprovedAction(action.id, "action-runner"); + expect(executed.action).toMatchObject({ + status: "executed", + execution_status: "succeeded", + }); + expect(executed.effects).toHaveLength(1); + expect(executed.effects[0]).toMatchObject({ + effect_type: "conversation.followup.recommended", + target_table: "conversations", + target_id: "conversation-a", + }); + expect(JSON.parse(executed.effects[0]!.payload_json!)).toEqual({ + conversationId: "conversation-a", + reason: "The thread has recent activity and needs a next step.", + suggestedNextStep: "Review the thread and decide whether to reply.", + evidence: { messageCount: 12 }, + confidence: 60, + }); + + db.close(); + }); + + it("lists approved pending actions in execution order", () => { + const db = createDb(); + const proposed = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "memory-proposed" }, + }); + const lowPriority = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "memory-low" }, + priority: 10, + requiresApproval: false, + }); + const highPriority = db.createAction({ + actionType: "contact.memory.stale", + payload: { memoryId: "memory-high" }, + priority: -1, + requiresApproval: false, + }); + db.approveAction(proposed.id, "soham"); + db.lockActionForExecution(lowPriority.id, "runner"); + + expect(db.listApprovedPendingActions().map((action) => action.id)).toEqual([ + highPriority.id, + proposed.id, + ]); + + db.close(); + }); + it("queues and claims message FTS indexing work independently", () => { const db = createDb(); @@ -1205,7 +1743,7 @@ describe("CuedDatabase", () => { db.close(); }); - it("records manual merge decisions and resolves canonical contact chains", () => { + it("records contact merge actions and resolves canonical contact chains", () => { const db = createDb(); insertContact(db, { id: "contact-a", name: "Ava Prime" }); @@ -1213,7 +1751,7 @@ describe("CuedDatabase", () => { insertContact(db, { id: "contact-c", name: "Ava Canonical" }); expect( - db.recordContactMergeDecision({ + executeContactMerge(db, { primaryContactId: "contact-a", secondaryContactId: "contact-b", reason: "same phone", @@ -1224,9 +1762,8 @@ describe("CuedDatabase", () => { secondaryContactId: "contact-b", canonicalContactId: "contact-a", }); - expect( - db.recordContactMergeDecision({ + executeContactMerge(db, { primaryContactId: "contact-c", secondaryContactId: "contact-a", reason: "prefer contacts source", @@ -1238,10 +1775,11 @@ describe("CuedDatabase", () => { canonicalContactId: "contact-c", }); - expect(db.resolveCanonicalContactId("contact-b")).toBe("contact-c"); - expect(db.resolveCanonicalContactId("contact-a")).toBe("contact-c"); - expect(db.resolveCanonicalContactId("contact-c")).toBe("contact-c"); - expect(db.listContactMergeDecisions()).toHaveLength(2); + expect(db.listContactMergeAliases()).toEqual([ + { contact_id: "contact-b", canonical_contact_id: "contact-a" }, + { contact_id: "contact-a", canonical_contact_id: "contact-c" }, + ]); + expect(db.listContactMergeAliases()).toHaveLength(2); db.close(); }); @@ -1257,7 +1795,7 @@ describe("CuedDatabase", () => { createdBy: "test", }); - db.recordContactMergeDecision({ + executeContactMerge(db, { primaryContactId: "contact-a", secondaryContactId: "contact-b", reason: "same email", diff --git a/src/db/database.ts b/src/db/database.ts index beed9455..ae94c196 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -98,6 +98,27 @@ function hashActionPayload(payloadJson: string): string { return createHash("sha256").update(payloadJson).digest("hex"); } +function parseContactMergeAlias(payloadJson: string | null): ContactMergeAlias | null { + const payload = safeParseJson | null>( + payloadJson, + "contact.merge.recorded:payload", + null, + (value): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value), + ); + if ( + !payload || + typeof payload.secondaryContactId !== "string" || + typeof payload.canonicalContactId !== "string" + ) { + return null; + } + return { + contact_id: payload.secondaryContactId, + canonical_contact_id: payload.canonicalContactId, + }; +} + export interface DaemonStatusRow { singleton_key: "daemon"; pid: number | null; @@ -202,10 +223,23 @@ export interface ActionEffectRow { reverted_at: number | null; } +export interface ContactMergeAlias { + contact_id: string; + canonical_contact_id: string; +} + export interface ExecutedActionResult { action: ActionRow; effects: ActionEffectRow[]; result: unknown; + requiresProjectionRebuild: boolean; +} + +export interface RunApprovedActionsResult { + attempted: number; + succeeded: number; + failed: number; + results: Array<{ actionId: string; ok: boolean; result?: ExecutedActionResult; error?: string }>; } const ACTION_ROW_COLUMNS = ` @@ -1041,7 +1075,7 @@ export class CuedDatabase { .all(actionId) as ActionEffectRow[]; } - listActiveActionEffects( + private listActiveActionEffects( input: { actionType?: string; effectType?: string; limit?: number } = {}, ): ActionEffectRow[] { const filters = [ @@ -1086,6 +1120,15 @@ export class CuedDatabase { .all(...params) as ActionEffectRow[]; } + listContactMergeAliases(): ContactMergeAlias[] { + return this.listActiveActionEffects({ + actionType: "contact.merge", + effectType: "contact.merge.recorded", + }) + .map((effect) => parseContactMergeAlias(effect.payload_json)) + .filter((alias): alias is ContactMergeAlias => alias !== null); + } + executeApprovedAction(id: string, executedBy = "daemon"): ExecutedActionResult { const locked = this.lockActionForExecution(id, executedBy); try { @@ -1116,6 +1159,7 @@ export class CuedDatabase { } const { result, effects } = executor({ action: locked, + definition, db: this.createActionSkillDatabase(), executedBy, helpers: actionExecutionHelpers, @@ -1125,6 +1169,7 @@ export class CuedDatabase { action: this.completeAction(locked.id, result), effects, result, + requiresProjectionRebuild: definition.postExecution.rebuildProjection === true, }; })(); } catch (error) { @@ -1133,6 +1178,36 @@ export class CuedDatabase { } } + runApprovedActions( + input: { + limit?: number; + executedBy?: string | null; + afterAction?: (executed: ExecutedActionResult) => void; + } = {}, + ): RunApprovedActionsResult { + const actions = this.listApprovedPendingActions(input.limit ?? 25); + const results: RunApprovedActionsResult["results"] = []; + for (const action of actions) { + try { + const executed = this.executeApprovedAction(action.id, input.executedBy ?? "daemon"); + input.afterAction?.(executed); + results.push({ actionId: action.id, ok: true, result: executed }); + } catch (error) { + results.push({ + actionId: action.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { + attempted: results.length, + succeeded: results.filter((result) => result.ok).length, + failed: results.filter((result) => !result.ok).length, + results, + }; + } + private createActionSkillDatabase(): ActionSkillDatabase { return { addContactMemory: (input) => this.addContactMemory(input), @@ -1141,7 +1216,7 @@ export class CuedDatabase { contactExists: (id) => this.contactExists(id), conversationExists: (id) => this.conversationExists(id), recordActionEffect: (input) => this.recordActionEffect(input), - listActiveActionEffects: (input) => this.listActiveActionEffects(input), + listContactMergeAliases: () => this.listContactMergeAliases(), }; } diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index 8abb5491..f8e30418 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -3,10 +3,7 @@ import { existsSync, type FSWatcher, rmSync, watch } from "node:fs"; import { createConnection, createServer, type Socket } from "node:net"; import { basename, dirname } from "node:path"; import process from "node:process"; -import { - ActionDefinitionRegistry, - actionRequiresProjectionRebuild, -} from "../../actions/registry.js"; +import { ActionDefinitionRegistry } from "../../actions/registry.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "../../core/app-metadata.js"; import { CUED_DAEMON_LOCK_PATH, CUED_SOCKET_PATH } from "../../core/config.js"; import { createLogger } from "../../core/logging.js"; @@ -5025,40 +5022,6 @@ function stopActiveAuthSessionsForIntegration( } } -function runApprovedActions( - db: ReturnType, - input: { limit?: number; executedBy?: string | null } = {}, -): { - attempted: number; - succeeded: number; - failed: number; - results: Array<{ actionId: string; ok: boolean; result?: unknown; error?: string }>; -} { - const actions = db.listApprovedPendingActions(input.limit ?? 25); - const results: Array<{ actionId: string; ok: boolean; result?: unknown; error?: string }> = []; - for (const action of actions) { - try { - const executed = db.executeApprovedAction(action.id, input.executedBy ?? "daemon"); - if (actionRequiresProjectionRebuild(executed.action)) { - rebuildProjectedState(db); - } - results.push({ actionId: action.id, ok: true, result: executed }); - } catch (error) { - results.push({ - actionId: action.id, - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - } - } - return { - attempted: results.length, - succeeded: results.filter((result) => result.ok).length, - failed: results.filter((result) => !result.ok).length, - results, - }; -} - export async function dispatchRequest( db: ReturnType, request: DaemonRequest, @@ -5420,9 +5383,7 @@ export async function dispatchRequest( ok: true, result: { ...executed, - projection: actionRequiresProjectionRebuild(executed.action) - ? rebuildProjectedState(db) - : null, + projection: executed.requiresProjectionRebuild ? rebuildProjectedState(db) : null, }, }; } @@ -5430,9 +5391,14 @@ export async function dispatchRequest( return { id: request.id, ok: true, - result: runApprovedActions(db, { + result: db.runApprovedActions({ limit: request.limit, executedBy: request.executedBy, + afterAction: (executed) => { + if (executed.requiresProjectionRebuild) { + rebuildProjectedState(db); + } + }, }), }; case "integrations-list": { diff --git a/src/runtime/projection/projector.ts b/src/runtime/projection/projector.ts index c5617e87..26d4c923 100644 --- a/src/runtime/projection/projector.ts +++ b/src/runtime/projection/projector.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; import { eq, type SQL, sql } from "drizzle-orm"; -import { listContactMergeAliases } from "../../actions/contact-merge-effects.js"; import type { CallPayload, ContactObservationPayload, @@ -614,7 +613,7 @@ function hydrateProjectionCache(db: CuedDatabase, cache: ProjectionCache): void cache.contactNameMap.clear(); cache.conversationNameMap.clear(); - for (const row of listContactMergeAliases(db)) { + for (const row of db.listContactMergeAliases()) { cache.contactMergeAliasMap.set(row.contact_id, row.canonical_contact_id); } @@ -669,7 +668,7 @@ function getProjectionCache(db: CuedDatabase): ProjectionCache { function resetProjectionCache(db: CuedDatabase): ProjectionCache { const cache = createProjectionCache(); - for (const row of listContactMergeAliases(db)) { + for (const row of db.listContactMergeAliases()) { cache.contactMergeAliasMap.set(row.contact_id, row.canonical_contact_id); } cache.initialized = true; From 03d30831627beef920ae4ca7c1686679d6af99d9 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 20:56:30 -0400 Subject: [PATCH 4/8] Remove stale action packaging references --- scripts/smoke-actions-local.ts | 25 +++++++++++++++-------- scripts/validate-cued-release-artifact.sh | 7 +------ src/db/database.ts | 10 +++++++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/scripts/smoke-actions-local.ts b/scripts/smoke-actions-local.ts index 1b8ad28c..4728e4ce 100644 --- a/scripts/smoke-actions-local.ts +++ b/scripts/smoke-actions-local.ts @@ -1,4 +1,3 @@ -import { listContactMergeAliases } from "../src/actions/contact-merge-effects.js"; import { loadActionExecutor } from "../src/actions/executor-loader.js"; import { ActionDefinitionRegistry } from "../src/actions/registry.js"; import { openCuedDatabaseReadOnly } from "../src/db/database.js"; @@ -130,7 +129,7 @@ try { `contact.message.draft payload failed validation: ${messageDraftValidation?.errors.join("; ")}`, ); - const aliases = listContactMergeAliases(db); + const aliases = db.listContactMergeAliases(); const conversations = db.executeReadOnlySql(` SELECT c.id, COUNT(m.id) AS message_count FROM conversations c @@ -178,12 +177,22 @@ try { "; ", )}`, ); - const recentActions = db.executeReadOnlySql(` - SELECT action_type, status, approval_status, execution_status, queued_at - FROM actions - ORDER BY queued_at DESC - LIMIT 5 - `); + const hasActionsTable = + db.executeReadOnlySql(` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'actions' + LIMIT 1 + `).length > 0; + const recentActions = hasActionsTable + ? db.executeReadOnlySql(` + SELECT action_type, status, approval_status, execution_status, queued_at + FROM actions + ORDER BY queued_at DESC + LIMIT 5 + `) + : []; process.stdout.write( `${JSON.stringify( diff --git a/scripts/validate-cued-release-artifact.sh b/scripts/validate-cued-release-artifact.sh index 9dc5cc96..7d64f660 100755 --- a/scripts/validate-cued-release-artifact.sh +++ b/scripts/validate-cued-release-artifact.sh @@ -35,13 +35,9 @@ fi REQUIRED_SKILL_ACTION_FILES=( contact.enrichment.recommend.json - contact-enrichment-recommend.cjs contact.followup.recommend.json - contact-followup-recommend.cjs contact.introduction.recommend.json - contact-introduction-recommend.cjs contact.message.draft.json - contact-message-draft.cjs contact.merge.json contact-merge.cjs contact.memory.add.json @@ -49,9 +45,8 @@ REQUIRED_SKILL_ACTION_FILES=( contact.memory.stale.json contact-memory-stale.cjs conversation.followup.recommend.json - conversation-followup-recommend.cjs conversation.summary.draft.json - conversation-summary-draft.cjs + record-effect.cjs ) for file in "${REQUIRED_SKILL_ACTION_FILES[@]}"; do diff --git a/src/db/database.ts b/src/db/database.ts index ae94c196..a822bc41 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1075,9 +1075,19 @@ export class CuedDatabase { .all(actionId) as ActionEffectRow[]; } + private tableExists(tableName: string): boolean { + const row = this.sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1") + .get(tableName) as { name: string } | undefined; + return row?.name === tableName; + } + private listActiveActionEffects( input: { actionType?: string; effectType?: string; limit?: number } = {}, ): ActionEffectRow[] { + if (!this.tableExists("actions") || !this.tableExists("action_effects")) { + return []; + } const filters = [ "a.status = 'executed'", "a.execution_status = 'succeeded'", From a7e616d985c2e36e8902229c19cf7e9f63ba2072 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 21:11:17 -0400 Subject: [PATCH 5/8] Remove stale merge decision payload id --- skills/cued/actions/contact-merge.cjs | 35 ++++++++++-------------- src/db/database.test.ts | 15 +++++++--- src/runtime/projection/projector.test.ts | 21 ++++++++------ 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/skills/cued/actions/contact-merge.cjs b/skills/cued/actions/contact-merge.cjs index 3f45b225..cbebdb39 100644 --- a/skills/cued/actions/contact-merge.cjs +++ b/skills/cued/actions/contact-merge.cjs @@ -12,7 +12,7 @@ function resolveCanonicalContactId(contactId, aliasMap) { throw new Error(`Contact merge alias cycle detected at ${current}`); } -function planContactMerges(db, input, randomUUID) { +function planContactMerges(db, input) { if (input.length === 0) { throw new Error("At least one contact merge is required."); } @@ -48,7 +48,6 @@ function planContactMerges(db, input, randomUUID) { aliasMap.set(canonicalSecondary, canonicalPrimary); resolveCanonicalContactId(canonicalSecondary, aliasMap); planned.push({ - decisionId: randomUUID(), primaryContactId: canonicalPrimary, secondaryContactId: canonicalSecondary, canonicalContactId: canonicalPrimary, @@ -60,35 +59,31 @@ function planContactMerges(db, input, randomUUID) { function execute({ action, db, helpers }) { const payload = helpers.parseActionPayloadObject(action); - const [decision] = planContactMerges( - db, - [ - { - primaryContactId: helpers.requiredStringPayload(payload, "primaryContactId", action), - secondaryContactId: helpers.requiredStringPayload(payload, "secondaryContactId", action), - reason: helpers.optionalStringPayload(payload, "reason", action), - }, - ], - helpers.randomUUID, - ); - if (!decision) { - throw new Error("Contact merge action did not produce a merge decision."); + const [merge] = planContactMerges(db, [ + { + primaryContactId: helpers.requiredStringPayload(payload, "primaryContactId", action), + secondaryContactId: helpers.requiredStringPayload(payload, "secondaryContactId", action), + reason: helpers.optionalStringPayload(payload, "reason", action), + }, + ]); + if (!merge) { + throw new Error("Contact merge action did not produce a merge record."); } db.moveContactMemoriesToContact({ - fromContactId: decision.secondaryContactId, - toContactId: decision.canonicalContactId, + fromContactId: merge.secondaryContactId, + toContactId: merge.canonicalContactId, }); const effect = db.recordActionEffect({ actionId: action.id, effectType: "contact.merge.recorded", targetTable: "contacts", - targetId: decision.canonicalContactId, - payload: decision, + targetId: merge.canonicalContactId, + payload: merge, }); return { - result: { decision }, + result: { merge }, effects: [effect], }; } diff --git a/src/db/database.test.ts b/src/db/database.test.ts index ac973d70..26defa0f 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -334,7 +334,7 @@ describe("CuedDatabase", () => { effectType: "contact.merge.recorded", targetTable: "contacts", targetId: "contact-a", - payload: { canonicalContactId: "contact-a" }, + payload: { secondaryContactId: "contact-b", canonicalContactId: "contact-a" }, appliedAt: 1234, }); expect(effect).toMatchObject({ @@ -342,16 +342,23 @@ describe("CuedDatabase", () => { effect_type: "contact.merge.recorded", target_table: "contacts", target_id: "contact-a", - payload_json: JSON.stringify({ canonicalContactId: "contact-a" }), + payload_json: JSON.stringify({ + secondaryContactId: "contact-b", + canonicalContactId: "contact-a", + }), applied_at: 1234, reverted_at: null, }); - const completed = db.completeAction(action.id, { decisionId: "decision-1" }); + const completed = db.completeAction(action.id, { + merge: { secondaryContactId: "contact-b", canonicalContactId: "contact-a" }, + }); expect(completed).toMatchObject({ status: "executed", execution_status: "succeeded", - result_json: JSON.stringify({ decisionId: "decision-1" }), + result_json: JSON.stringify({ + merge: { secondaryContactId: "contact-b", canonicalContactId: "contact-a" }, + }), executed_at: expect.any(Number), }); expect(db.listActionEffects(action.id).map((row) => row.id)).toEqual([effect.id]); diff --git a/src/runtime/projection/projector.test.ts b/src/runtime/projection/projector.test.ts index f90ae4af..76611d14 100644 --- a/src/runtime/projection/projector.test.ts +++ b/src/runtime/projection/projector.test.ts @@ -2736,17 +2736,22 @@ describe("projector", () => { const primaryContactId = contactsBefore[0]!.id; const secondaryContactId = contactsBefore[1]!.id; - expect( - db.recordContactMergeDecision({ + const action = db.createAction({ + actionType: "contact.merge", + payload: { primaryContactId, secondaryContactId, reason: "manual merge", - }), - ).toEqual({ - decisionId: expect.any(String), - primaryContactId, - secondaryContactId, - canonicalContactId: primaryContactId, + }, + requiresApproval: false, + }); + expect(db.executeApprovedAction(action.id, "test").result).toMatchObject({ + merge: { + primaryContactId, + secondaryContactId, + canonicalContactId: primaryContactId, + reason: "manual merge", + }, }); const projection = rebuildProjectedState(db); From 353ff836d1621ff11897c119d4839e346731c564 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 21:33:52 -0400 Subject: [PATCH 6/8] Remove stale action substrate residue --- src/actions/execution.ts | 3 -- src/actions/registry.ts | 63 +++++++++---------------------- src/db/database.test.ts | 73 ++++++++++++++++++++++++++++++++++++ src/db/database.ts | 24 ++++++++++-- src/db/schema-bootstrap.ts | 1 - src/db/schema.ts | 1 - src/runtime/daemon/server.ts | 1 - src/runtime/ipc.ts | 1 - 8 files changed, 110 insertions(+), 57 deletions(-) diff --git a/src/actions/execution.ts b/src/actions/execution.ts index bb45f4c8..ce20ccb3 100644 --- a/src/actions/execution.ts +++ b/src/actions/execution.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { safeParseJson } from "../db/codecs.js"; import type { ActionEffectRow, @@ -53,7 +52,6 @@ export interface ActionExecutionResult { export type ActionExecutor = (context: ActionExecutionContext) => ActionExecutionResult; export interface ActionExecutionHelpers { - randomUUID: typeof randomUUID; parseActionPayloadObject: typeof parseActionPayloadObject; requiredStringPayload: typeof requiredStringPayload; optionalStringPayload: typeof optionalStringPayload; @@ -62,7 +60,6 @@ export interface ActionExecutionHelpers { } export const actionExecutionHelpers: ActionExecutionHelpers = { - randomUUID, parseActionPayloadObject, requiredStringPayload, optionalStringPayload, diff --git a/src/actions/registry.ts b/src/actions/registry.ts index b92381f6..99a1cff5 100644 --- a/src/actions/registry.ts +++ b/src/actions/registry.ts @@ -34,11 +34,6 @@ export interface ActionDefinition { }; } -interface ActionDefinitionManifest { - version: number; - actions: ActionDefinition[]; -} - export interface ActionPayloadValidationResult { ok: boolean; errors: string[]; @@ -293,52 +288,28 @@ function assertUniqueDefinitions(actions: ActionDefinition[]): void { } } -function parseManifest( - raw: unknown, - sourcePath: string, - skillRoot: string, -): ActionDefinitionManifest { - if (!isPlainObject(raw)) { - throw new Error(`Action definition manifest must be an object: ${sourcePath}`); - } - if (raw.version !== 1) { - throw new Error(`Unsupported action definition manifest version in ${sourcePath}.`); - } - if (!Array.isArray(raw.actions)) { - throw new Error(`Action definition manifest must include an actions array: ${sourcePath}`); - } - - const actions = raw.actions.map((value, index) => - parseActionDefinition(value, index, sourcePath, skillRoot), - ); - assertUniqueDefinitions(actions); - - return { version: 1, actions }; -} - function loadActionDefinitions(path: string): ActionDefinition[] { if (!existsSync(path)) { return []; } - if (statSync(path).isDirectory()) { - const skillRoot = dirname(path); - const actions = readdirSync(path) - .filter((fileName) => fileName.endsWith(".json")) - .sort() - .map((fileName, index) => { - const filePath = join(path, fileName); - return parseActionDefinition( - JSON.parse(readFileSync(filePath, "utf8")) as unknown, - index, - filePath, - skillRoot, - ); - }); - assertUniqueDefinitions(actions); - return actions; + if (!statSync(path).isDirectory()) { + throw new Error(`Action definitions path must be a directory: ${path}`); } - return parseManifest(JSON.parse(readFileSync(path, "utf8")) as unknown, path, dirname(path)) - .actions; + const skillRoot = dirname(path); + const actions = readdirSync(path) + .filter((fileName) => fileName.endsWith(".json")) + .sort() + .map((fileName, index) => { + const filePath = join(path, fileName); + return parseActionDefinition( + JSON.parse(readFileSync(filePath, "utf8")) as unknown, + index, + filePath, + skillRoot, + ); + }); + assertUniqueDefinitions(actions); + return actions; } function loadDefaultActionDefinitions(): ActionDefinition[] { diff --git a/src/db/database.test.ts b/src/db/database.test.ts index 26defa0f..d4ffbd05 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -191,6 +191,79 @@ describe("CuedDatabase", () => { authSessions: 0, messageBreakdown: [], }); + expect( + (sqlite(db).prepare("PRAGMA table_info(actions)").all() as Array<{ name: string }>).map( + (row) => row.name, + ), + ).toEqual([ + "id", + "action_type", + "action_version", + "status", + "approval_status", + "execution_status", + "priority", + "title", + "summary", + "payload_json", + "payload_hash", + "result_json", + "error_json", + "source_skill", + "created_by", + "approved_by", + "denied_by", + "executed_by", + "requires_approval", + "queued_at", + "approved_at", + "denied_at", + "locked_at", + "started_at", + "executed_at", + "updated_at", + "dedupe_key", + ]); + expect( + ( + sqlite(db).prepare("PRAGMA table_info(action_effects)").all() as Array<{ name: string }> + ).map((row) => row.name), + ).toEqual([ + "id", + "action_id", + "effect_type", + "target_table", + "target_id", + "payload_json", + "applied_at", + "reverted_at", + ]); + db.close(); + }); + + it("drops legacy action substrate residue during schema initialization", () => { + const db = createDb(); + sqlite(db).exec(` + ALTER TABLE actions ADD COLUMN source_job_id TEXT; + CREATE TABLE contact_merge_decisions ( + id TEXT PRIMARY KEY + ); + `); + + db.initializeSchema(); + + expect( + (sqlite(db).prepare("PRAGMA table_info(actions)").all() as Array<{ name: string }>).map( + (row) => row.name, + ), + ).not.toContain("source_job_id"); + expect( + sqlite(db) + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contact_merge_decisions'", + ) + .get(), + ).toBeUndefined(); db.close(); }); diff --git a/src/db/database.ts b/src/db/database.ts index a822bc41..27e4be25 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -196,7 +196,6 @@ export interface ActionRow { result_json: string | null; error_json: string | null; source_skill: string | null; - source_job_id: string | null; created_by: string; approved_by: string | null; denied_by: string | null; @@ -257,7 +256,6 @@ const ACTION_ROW_COLUMNS = ` result_json, error_json, source_skill, - source_job_id, created_by, approved_by, denied_by, @@ -682,6 +680,7 @@ export class CuedDatabase { initializeSchema(): void { this.sqlite.exec(CURRENT_SCHEMA_SQL); + this.dropLegacyActionResidue(); } close(): void { @@ -715,7 +714,6 @@ export class CuedDatabase { title?: string | null; summary?: string | null; sourceSkill?: string | null; - sourceJobId?: string | null; createdBy?: string | null; requiresApproval?: boolean; dedupeKey?: string | null; @@ -755,7 +753,6 @@ export class CuedDatabase { resultJson: null, errorJson: null, sourceSkill: input.sourceSkill ?? null, - sourceJobId: input.sourceJobId ?? null, createdBy, approvedBy: requiresApproval ? null : "system", deniedBy: null, @@ -1082,6 +1079,25 @@ export class CuedDatabase { return row?.name === tableName; } + private columnExists(tableName: string, columnName: string): boolean { + const rows = this.sqlite.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + name: string; + }>; + return rows.some((row) => row.name === columnName); + } + + private dropColumnIfExists(tableName: string, columnName: string): void { + if (!this.tableExists(tableName) || !this.columnExists(tableName, columnName)) { + return; + } + this.sqlite.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`); + } + + private dropLegacyActionResidue(): void { + this.sqlite.exec("DROP TABLE IF EXISTS contact_merge_decisions"); + this.dropColumnIfExists("actions", "source_job_id"); + } + private listActiveActionEffects( input: { actionType?: string; effectType?: string; limit?: number } = {}, ): ActionEffectRow[] { diff --git a/src/db/schema-bootstrap.ts b/src/db/schema-bootstrap.ts index a95410a8..28f18184 100644 --- a/src/db/schema-bootstrap.ts +++ b/src/db/schema-bootstrap.ts @@ -114,7 +114,6 @@ export const CURRENT_SCHEMA_SQL = ` result_json TEXT, error_json TEXT, source_skill TEXT, - source_job_id TEXT, created_by TEXT NOT NULL, approved_by TEXT, denied_by TEXT, diff --git a/src/db/schema.ts b/src/db/schema.ts index f4aff501..dc7a4fb2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -141,7 +141,6 @@ export const actions = sqliteTable("actions", { resultJson: text("result_json"), errorJson: text("error_json"), sourceSkill: text("source_skill"), - sourceJobId: text("source_job_id"), createdBy: text("created_by").notNull(), approvedBy: text("approved_by"), deniedBy: text("denied_by"), diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index f8e30418..8923b4f9 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -5331,7 +5331,6 @@ export async function dispatchRequest( title: request.title ?? null, summary: request.summary ?? null, sourceSkill: request.sourceSkill ?? definition.skillName, - sourceJobId: request.sourceJobId ?? null, createdBy: request.createdBy ?? "daemon", requiresApproval: request.requiresApproval ?? definition.requiresApprovalDefault, dedupeKey: request.dedupeKey ?? null, diff --git a/src/runtime/ipc.ts b/src/runtime/ipc.ts index a6a38163..49b3a8f9 100644 --- a/src/runtime/ipc.ts +++ b/src/runtime/ipc.ts @@ -22,7 +22,6 @@ export type DaemonRequest = title?: string | null; summary?: string | null; sourceSkill?: string | null; - sourceJobId?: string | null; createdBy?: string | null; requiresApproval?: boolean; dedupeKey?: string | null; From a5e242b0325e7a3685f3aea98e6066e4bf07f7a5 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 22:09:00 -0400 Subject: [PATCH 7/8] Preserve legacy contact merge aliases --- src/db/database.test.ts | 32 +- src/db/database.ts | 84 +++- src/db/migrations.ts | 71 +++ src/db/schema-bootstrap.ts | 913 ------------------------------------- 4 files changed, 173 insertions(+), 927 deletions(-) delete mode 100644 src/db/schema-bootstrap.ts diff --git a/src/db/database.test.ts b/src/db/database.test.ts index d4ffbd05..2b057651 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -241,12 +241,28 @@ describe("CuedDatabase", () => { db.close(); }); - it("drops legacy action substrate residue during schema initialization", () => { + it("drops stale action substrate columns without deleting legacy merge aliases", () => { const db = createDb(); sqlite(db).exec(` ALTER TABLE actions ADD COLUMN source_job_id TEXT; - CREATE TABLE contact_merge_decisions ( - id TEXT PRIMARY KEY + INSERT INTO contact_merge_decisions ( + id, + decision_type, + primary_contact_id, + secondary_contact_id, + canonical_contact_id, + reason, + created_by, + created_at + ) VALUES ( + 'decision-1', + 'merge', + 'contact-a', + 'contact-b', + 'contact-a', + 'same phone', + 'cli', + 1234 ); `); @@ -257,13 +273,9 @@ describe("CuedDatabase", () => { (row) => row.name, ), ).not.toContain("source_job_id"); - expect( - sqlite(db) - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contact_merge_decisions'", - ) - .get(), - ).toBeUndefined(); + expect(db.listContactMergeAliases()).toEqual([ + { contact_id: "contact-b", canonical_contact_id: "contact-a" }, + ]); db.close(); }); diff --git a/src/db/database.ts b/src/db/database.ts index 27e4be25..c97fc6ac 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -45,8 +45,8 @@ import type { UpdateStatusSnapshot, } from "../runtime/updater/types.js"; import { safeParseJson, safeStringifyJson } from "./codecs.js"; +import { MIGRATIONS } from "./migrations.js"; import * as schema from "./schema.js"; -import { CURRENT_SCHEMA_SQL } from "./schema-bootstrap.js"; import { openSqliteDatabase } from "./sqlite.js"; const { @@ -679,7 +679,42 @@ export class CuedDatabase { } initializeSchema(): void { - this.sqlite.exec(CURRENT_SCHEMA_SQL); + this.migrate(); + } + + migrate(): void { + for (const migration of MIGRATIONS) { + const alreadyApplied = this.sqlite + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'") + .get() as { 1: number } | undefined; + + if (alreadyApplied) { + const migrationIds = [migration.id, ...(migration.legacyIds ?? [])]; + const placeholders = migrationIds.map(() => "?").join(", "); + const applied = this.sqlite + .prepare(`SELECT id FROM schema_migrations WHERE id IN (${placeholders}) LIMIT 1`) + .get(...migrationIds) as { id: string } | undefined; + if (applied) { + continue; + } + } + + this.sqlite.exec("BEGIN"); + try { + if (migration.apply) { + migration.apply(this.sqlite); + } else if (migration.sql) { + this.sqlite.exec(migration.sql); + } + this.sqlite + .prepare("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)") + .run(migration.id, now()); + this.sqlite.exec("COMMIT"); + } catch (error) { + this.sqlite.exec("ROLLBACK"); + throw error; + } + } this.dropLegacyActionResidue(); } @@ -1094,10 +1129,49 @@ export class CuedDatabase { } private dropLegacyActionResidue(): void { - this.sqlite.exec("DROP TABLE IF EXISTS contact_merge_decisions"); this.dropColumnIfExists("actions", "source_job_id"); } + private legacyContactMergeColumn(primaryName: string, fallbackName?: string): string | null { + if (this.columnExists("contact_merge_decisions", primaryName)) { + return primaryName; + } + if (fallbackName && this.columnExists("contact_merge_decisions", fallbackName)) { + return fallbackName; + } + return null; + } + + private listLegacyContactMergeAliases(): ContactMergeAlias[] { + if (!this.tableExists("contact_merge_decisions")) { + return []; + } + const secondaryColumn = this.legacyContactMergeColumn( + "secondary_contact_id", + "right_contact_id", + ); + const canonicalColumn = this.legacyContactMergeColumn("canonical_contact_id"); + if (!secondaryColumn || !canonicalColumn) { + return []; + } + const decisionTypeColumn = this.legacyContactMergeColumn("decision_type"); + const createdAtColumn = this.legacyContactMergeColumn("created_at"); + return this.sqlite + .prepare( + ` + SELECT + ${secondaryColumn} AS contact_id, + ${canonicalColumn} AS canonical_contact_id + FROM contact_merge_decisions + WHERE ${secondaryColumn} IS NOT NULL + AND ${canonicalColumn} IS NOT NULL + ${decisionTypeColumn ? `AND ${decisionTypeColumn} = 'merge'` : ""} + ORDER BY ${createdAtColumn ?? "id"} ASC, id ASC + `, + ) + .all() as ContactMergeAlias[]; + } + private listActiveActionEffects( input: { actionType?: string; effectType?: string; limit?: number } = {}, ): ActionEffectRow[] { @@ -1147,12 +1221,14 @@ export class CuedDatabase { } listContactMergeAliases(): ContactMergeAlias[] { - return this.listActiveActionEffects({ + const legacyAliases = this.listLegacyContactMergeAliases(); + const actionAliases = this.listActiveActionEffects({ actionType: "contact.merge", effectType: "contact.merge.recorded", }) .map((effect) => parseContactMergeAlias(effect.payload_json)) .filter((alias): alias is ContactMergeAlias => alias !== null); + return [...legacyAliases, ...actionAliases]; } executeApprovedAction(id: string, executedBy = "daemon"): ExecutedActionResult { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 0a14ba55..3de32eb2 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -32,6 +32,13 @@ function addColumnIfMissing(db: MigrationDatabase, tableName: string, definition db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`); } +function dropColumnIfExists(db: MigrationDatabase, tableName: string, columnName: string): void { + if (!columnExists(db, tableName, columnName)) { + return; + } + db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`); +} + function buildSyncScopeId( platform: string, accountKey: string, @@ -2229,4 +2236,68 @@ export const MIGRATIONS: Migration[] = [ `); }, }, + { + id: "0021_add_action_substrate", + apply: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, + action_type TEXT NOT NULL, + action_version TEXT NOT NULL, + status TEXT NOT NULL, + approval_status TEXT NOT NULL, + execution_status TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + title TEXT, + summary TEXT, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + result_json TEXT, + error_json TEXT, + source_skill TEXT, + created_by TEXT NOT NULL, + approved_by TEXT, + denied_by TEXT, + executed_by TEXT, + requires_approval INTEGER NOT NULL DEFAULT 1, + queued_at INTEGER NOT NULL, + approved_at INTEGER, + denied_at INTEGER, + locked_at INTEGER, + started_at INTEGER, + executed_at INTEGER, + updated_at INTEGER NOT NULL, + dedupe_key TEXT + ); + + CREATE TABLE IF NOT EXISTS action_effects ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE, + effect_type TEXT NOT NULL, + target_table TEXT, + target_id TEXT, + payload_json TEXT, + applied_at INTEGER NOT NULL, + reverted_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_actions_status_queue + ON actions(status, approval_status, execution_status, queued_at); + + CREATE INDEX IF NOT EXISTS idx_actions_type_status + ON actions(action_type, status, queued_at); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_actions_dedupe + ON actions(dedupe_key) + WHERE dedupe_key IS NOT NULL; + + CREATE INDEX IF NOT EXISTS idx_action_effects_action + ON action_effects(action_id, applied_at); + + CREATE INDEX IF NOT EXISTS idx_action_effects_target + ON action_effects(target_table, target_id, applied_at); + `); + dropColumnIfExists(db, "actions", "source_job_id"); + }, + }, ]; diff --git a/src/db/schema-bootstrap.ts b/src/db/schema-bootstrap.ts deleted file mode 100644 index 28f18184..00000000 --- a/src/db/schema-bootstrap.ts +++ /dev/null @@ -1,913 +0,0 @@ -export const CURRENT_SCHEMA_SQL = ` - CREATE TABLE IF NOT EXISTS source_accounts ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - display_name TEXT, - status TEXT NOT NULL DEFAULT 'active', - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key) - ); - - CREATE TABLE IF NOT EXISTS sync_checkpoints ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - source_cursor_json TEXT, - raw_ingest_watermark INTEGER NOT NULL DEFAULT 0, - sync_mode TEXT NOT NULL DEFAULT 'full', - last_full_sync_at INTEGER, - last_success_at INTEGER, - last_error_at INTEGER, - last_error_summary TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key) - ); - - CREATE TABLE IF NOT EXISTS sync_scopes ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - scope_kind TEXT NOT NULL, - scope_key TEXT NOT NULL, - parent_scope_id TEXT REFERENCES sync_scopes(id) ON DELETE CASCADE, - display_name TEXT, - metadata_json TEXT, - first_discovered_at INTEGER NOT NULL, - last_observed_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, scope_kind, scope_key) - ); - - CREATE TABLE IF NOT EXISTS sync_proofs ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - scope_id TEXT NOT NULL REFERENCES sync_scopes(id) ON DELETE CASCADE, - proof_kind TEXT NOT NULL, - status TEXT NOT NULL, - sync_mode TEXT, - run_started_at INTEGER, - last_observed_at INTEGER NOT NULL, - completed_at INTEGER, - fresh_until INTEGER, - resume_cursor_json TEXT, - coverage_json TEXT, - stats_json TEXT, - error_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, scope_id, proof_kind) - ); - - CREATE TABLE IF NOT EXISTS sync_runs ( - id TEXT PRIMARY KEY, - platform TEXT, - account_key TEXT, - run_type TEXT NOT NULL, - status TEXT NOT NULL, - trigger TEXT NOT NULL, - queued_at INTEGER NOT NULL, - scheduled_at INTEGER NOT NULL DEFAULT 0, - started_at INTEGER, - finished_at INTEGER, - details_json TEXT - ); - - CREATE TABLE IF NOT EXISTS jobs ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - platform TEXT, - account_key TEXT, - priority INTEGER NOT NULL, - status TEXT NOT NULL, - trigger TEXT NOT NULL, - queued_at INTEGER NOT NULL, - scheduled_at INTEGER NOT NULL, - started_at INTEGER, - finished_at INTEGER, - attempt INTEGER NOT NULL DEFAULT 0, - owner_id TEXT, - lease_expires_at INTEGER, - last_progress_at INTEGER, - checkpoint_json TEXT, - progress_json TEXT, - error_json TEXT - ); - - CREATE TABLE IF NOT EXISTS actions ( - id TEXT PRIMARY KEY, - action_type TEXT NOT NULL, - action_version TEXT NOT NULL, - status TEXT NOT NULL, - approval_status TEXT NOT NULL, - execution_status TEXT NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, - title TEXT, - summary TEXT, - payload_json TEXT NOT NULL, - payload_hash TEXT NOT NULL, - result_json TEXT, - error_json TEXT, - source_skill TEXT, - created_by TEXT NOT NULL, - approved_by TEXT, - denied_by TEXT, - executed_by TEXT, - requires_approval INTEGER NOT NULL DEFAULT 1, - queued_at INTEGER NOT NULL, - approved_at INTEGER, - denied_at INTEGER, - locked_at INTEGER, - started_at INTEGER, - executed_at INTEGER, - updated_at INTEGER NOT NULL, - dedupe_key TEXT - ); - - CREATE TABLE IF NOT EXISTS action_effects ( - id TEXT PRIMARY KEY, - action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE, - effect_type TEXT NOT NULL, - target_table TEXT, - target_id TEXT, - payload_json TEXT, - applied_at INTEGER NOT NULL, - reverted_at INTEGER - ); - - CREATE TABLE IF NOT EXISTS sync_run_errors ( - id TEXT PRIMARY KEY, - sync_run_id TEXT NOT NULL REFERENCES sync_runs(id) ON DELETE CASCADE, - platform TEXT, - account_key TEXT, - error_code TEXT, - error_message TEXT NOT NULL, - details_json TEXT, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS message_fts_index_queue ( - message_id TEXT PRIMARY KEY, - reason TEXT NOT NULL, - status TEXT NOT NULL, - attempt INTEGER NOT NULL DEFAULT 0, - queued_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_error TEXT - ); - - CREATE TABLE IF NOT EXISTS daemon_state ( - singleton_key TEXT PRIMARY KEY CHECK (singleton_key = 'daemon'), - pid INTEGER, - started_at INTEGER, - updated_at INTEGER, - status TEXT NOT NULL, - version TEXT, - details_json TEXT - ); - - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value TEXT, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS projection_state ( - singleton_key TEXT PRIMARY KEY CHECK (singleton_key = 'global'), - projection_watermark INTEGER NOT NULL DEFAULT 0, - last_projected_at INTEGER, - last_rebuild_at INTEGER, - updated_at INTEGER NOT NULL - ); - - INSERT OR IGNORE INTO projection_state ( - singleton_key, projection_watermark, last_projected_at, last_rebuild_at, updated_at - ) VALUES ('global', 0, NULL, NULL, strftime('%s','now') * 1000); - - CREATE TABLE IF NOT EXISTS raw_events ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - entity_kind TEXT NOT NULL, - event_kind TEXT NOT NULL, - external_event_id TEXT, - external_entity_id TEXT, - conversation_external_id TEXT, - occurred_at INTEGER, - observed_at INTEGER NOT NULL, - cursor_json TEXT, - dedupe_key TEXT NOT NULL, - payload_json TEXT NOT NULL, - normalized_schema TEXT, - provenance_json TEXT, - source_version TEXT, - UNIQUE(platform, account_key, dedupe_key) - ); - - CREATE TABLE IF NOT EXISTS contacts ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL DEFAULT 'person', - name TEXT, - photo_url TEXT, - company TEXT, - archived INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS contact_handles ( - id TEXT PRIMARY KEY, - contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, - type TEXT NOT NULL, - value TEXT NOT NULL, - normalized_value TEXT NOT NULL, - platform TEXT, - account_key TEXT, - is_deterministic INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS contact_sources ( - id TEXT PRIMARY KEY, - contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - source_entity_key TEXT NOT NULL, - profile_url TEXT, - metadata_json TEXT, - first_seen_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - UNIQUE(platform, account_key, source_entity_key) - ); - - CREATE TABLE IF NOT EXISTS contact_memories ( - id TEXT PRIMARY KEY, - contact_id TEXT NOT NULL, - body TEXT NOT NULL, - source_kind TEXT NOT NULL, - evidence_json TEXT, - confidence INTEGER, - supersedes_memory_id TEXT REFERENCES contact_memories(id) ON DELETE SET NULL, - stale_at INTEGER, - created_by TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 100)) - ); - - CREATE TABLE IF NOT EXISTS conversations ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - source_conversation_key TEXT NOT NULL, - native_conversation_key TEXT, - type TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - removal_reason TEXT, - service TEXT, - name TEXT, - topic TEXT, - participant_names TEXT, - last_message_id TEXT, - last_message_at INTEGER, - last_message_preview TEXT, - unread_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, source_conversation_key) - ); - - CREATE TABLE IF NOT EXISTS conversation_participants ( - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, - source_participant_key TEXT, - participant_name TEXT, - role TEXT, - is_self INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 1, - joined_at INTEGER, - left_at INTEGER, - updated_at INTEGER NOT NULL, - PRIMARY KEY (conversation_id, contact_id, source_participant_key) - ); - - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - platform_message_id TEXT NOT NULL, - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - sender_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, - sender_source_key TEXT, - sender_name TEXT, - conversation_name TEXT, - sent_at INTEGER NOT NULL, - service TEXT, - status TEXT, - is_from_me INTEGER NOT NULL DEFAULT 0, - content TEXT, - delivered_at INTEGER, - read_at INTEGER, - edited_at INTEGER, - deleted_at INTEGER, - reply_to_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL, - is_deleted INTEGER NOT NULL DEFAULT 0, - is_edited INTEGER NOT NULL DEFAULT 0, - attachment_count INTEGER NOT NULL DEFAULT 0, - reaction_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, platform_message_id) - ); - - CREATE TABLE IF NOT EXISTS message_attachments ( - id TEXT PRIMARY KEY, - message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - source_attachment_key TEXT NOT NULL, - kind TEXT, - mime_type TEXT, - filename TEXT, - title TEXT, - local_path TEXT, - remote_url TEXT, - size_bytes INTEGER, - text_content TEXT, - access_kind TEXT, - access_ref_json TEXT, - preview_ref_json TEXT, - availability_status TEXT, - provider_metadata_json TEXT, - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, source_attachment_key) - ); - - CREATE TABLE IF NOT EXISTS message_reactions ( - id TEXT PRIMARY KEY, - message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - source_reaction_key TEXT NOT NULL, - reactor_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, - reactor_source_key TEXT, - reactor_name TEXT, - emoji TEXT NOT NULL, - reaction_type TEXT, - is_active INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, source_reaction_key) - ); - - CREATE TABLE IF NOT EXISTS attachment_cache ( - id TEXT PRIMARY KEY, - attachment_id TEXT NOT NULL REFERENCES message_attachments(id) ON DELETE CASCADE, - variant TEXT NOT NULL, - status TEXT NOT NULL, - cache_path TEXT, - mime_type TEXT, - size_bytes INTEGER, - sha256 TEXT, - fetched_at INTEGER, - last_accessed_at INTEGER, - expires_at INTEGER, - last_error TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(attachment_id, variant) - ); - - CREATE TABLE IF NOT EXISTS attachment_content ( - attachment_id TEXT PRIMARY KEY REFERENCES message_attachments(id) ON DELETE CASCADE, - extractor TEXT, - status TEXT NOT NULL, - text_content TEXT, - mime_type TEXT, - extracted_at INTEGER, - last_error TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS timeline_events ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - source_event_key TEXT NOT NULL, - event_kind TEXT NOT NULL, - actor_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, - actor_source_key TEXT, - actor_name TEXT, - subject_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL, - subject_source_key TEXT, - event_at INTEGER NOT NULL, - text TEXT, - system_kind TEXT, - call_provider TEXT, - call_direction TEXT, - call_status TEXT, - call_medium TEXT, - call_started_at INTEGER, - call_duration_seconds INTEGER, - call_ended_at INTEGER, - call_disconnected_cause TEXT, - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key, source_event_key) - ); - - CREATE TABLE IF NOT EXISTS integration_states ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - display_name TEXT, - auth_state TEXT NOT NULL, - enabled INTEGER NOT NULL, - connection_kind TEXT NOT NULL, - sync_capable INTEGER NOT NULL, - launch_strategy TEXT, - launch_target TEXT, - imported_from TEXT, - artifact_paths_json TEXT, - metadata_json TEXT, - last_seen_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - UNIQUE(platform, account_key) - ); - - CREATE TABLE IF NOT EXISTS auth_sessions ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - integration_state_id TEXT NOT NULL REFERENCES integration_states(id) ON DELETE CASCADE, - state TEXT NOT NULL, - native_pid INTEGER, - requested_at INTEGER NOT NULL, - started_at INTEGER, - finished_at INTEGER, - keychain_service TEXT, - keychain_account TEXT, - result_summary_json TEXT, - error_summary TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS outbound_messages ( - id TEXT PRIMARY KEY, - platform TEXT NOT NULL, - account_key TEXT NOT NULL, - target TEXT NOT NULL, - thread_id TEXT, - text TEXT NOT NULL, - status TEXT NOT NULL, - attempt_count INTEGER NOT NULL DEFAULT 0, - scheduled_for INTEGER NOT NULL, - started_at INTEGER, - finished_at INTEGER, - last_error TEXT, - metadata_json TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( - message_id UNINDEXED, - sender_name, - conversation_name, - participant_names, - attachment_text, - content - ); - - CREATE VIRTUAL TABLE IF NOT EXISTS attachment_content_fts USING fts5( - attachment_id UNINDEXED, - filename, - title, - content - ); - - CREATE INDEX IF NOT EXISTS idx_source_accounts_lookup - ON source_accounts(platform, account_key); - - CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_lookup - ON sync_checkpoints(platform, account_key); - - CREATE INDEX IF NOT EXISTS idx_sync_scopes_lookup - ON sync_scopes(platform, account_key, scope_kind, scope_key); - - CREATE INDEX IF NOT EXISTS idx_sync_scopes_parent - ON sync_scopes(parent_scope_id, updated_at); - - CREATE INDEX IF NOT EXISTS idx_sync_proofs_lookup - ON sync_proofs(platform, account_key, scope_id, proof_kind); - - CREATE INDEX IF NOT EXISTS idx_sync_proofs_status - ON sync_proofs(platform, account_key, status, updated_at); - - CREATE INDEX IF NOT EXISTS idx_sync_runs_status_type_queue - ON sync_runs(status, run_type, scheduled_at, queued_at); - - CREATE INDEX IF NOT EXISTS idx_sync_runs_platform_account_status_queue - ON sync_runs(platform, account_key, status, scheduled_at, queued_at); - - CREATE INDEX IF NOT EXISTS idx_jobs_claim - ON jobs(status, scheduled_at, priority, queued_at); - - CREATE INDEX IF NOT EXISTS idx_jobs_owner_lease - ON jobs(owner_id, lease_expires_at); - - CREATE INDEX IF NOT EXISTS idx_jobs_platform_account_status - ON jobs(platform, account_key, status, scheduled_at); - - CREATE INDEX IF NOT EXISTS idx_actions_status_queue - ON actions(status, approval_status, execution_status, queued_at); - - CREATE INDEX IF NOT EXISTS idx_actions_type_status - ON actions(action_type, status, queued_at); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_actions_dedupe - ON actions(dedupe_key) - WHERE dedupe_key IS NOT NULL; - - CREATE INDEX IF NOT EXISTS idx_action_effects_action - ON action_effects(action_id, applied_at); - - CREATE INDEX IF NOT EXISTS idx_action_effects_target - ON action_effects(target_table, target_id, applied_at); - - CREATE INDEX IF NOT EXISTS idx_message_fts_index_queue_status - ON message_fts_index_queue(status, queued_at); - - CREATE INDEX IF NOT EXISTS idx_raw_events_lookup - ON raw_events(platform, account_key, observed_at); - - CREATE INDEX IF NOT EXISTS idx_raw_events_schema_lookup - ON raw_events(platform, entity_kind, event_kind, normalized_schema); - - CREATE INDEX IF NOT EXISTS idx_contact_handles_lookup - ON contact_handles(type, normalized_value, account_key); - - CREATE INDEX IF NOT EXISTS idx_contact_sources_contact - ON contact_sources(contact_id, platform, account_key); - - CREATE INDEX IF NOT EXISTS idx_contact_memories_contact_current - ON contact_memories(contact_id, stale_at, created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_contact_memories_created - ON contact_memories(created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_conversation_participants_contact - ON conversation_participants(contact_id); - - CREATE INDEX IF NOT EXISTS idx_timeline_events_actor_contact - ON timeline_events(actor_contact_id); - - CREATE INDEX IF NOT EXISTS idx_message_reactions_reactor_contact - ON message_reactions(reactor_contact_id); - - CREATE INDEX IF NOT EXISTS idx_conversations_lookup - ON conversations(platform, account_key, source_conversation_key); - - CREATE INDEX IF NOT EXISTS idx_messages_conversation - ON messages(conversation_id, sent_at); - - CREATE INDEX IF NOT EXISTS idx_messages_sender - ON messages(sender_contact_id, sent_at); - - CREATE INDEX IF NOT EXISTS idx_messages_platform_message - ON messages(platform, account_key, platform_message_id); - - CREATE INDEX IF NOT EXISTS idx_message_attachments_message - ON message_attachments(message_id); - - CREATE INDEX IF NOT EXISTS idx_message_reactions_message - ON message_reactions(message_id, is_active); - - CREATE INDEX IF NOT EXISTS idx_attachment_cache_status_accessed - ON attachment_cache(status, last_accessed_at, updated_at); - - CREATE INDEX IF NOT EXISTS idx_timeline_events_conversation - ON timeline_events(conversation_id, event_at); - - CREATE INDEX IF NOT EXISTS idx_integration_states_platform - ON integration_states(platform, enabled); - - CREATE INDEX IF NOT EXISTS idx_auth_sessions_platform_account - ON auth_sessions(platform, account_key, requested_at DESC); - - CREATE INDEX IF NOT EXISTS idx_auth_sessions_state - ON auth_sessions(state, requested_at DESC); - - CREATE INDEX IF NOT EXISTS idx_outbound_messages_status_scheduled - ON outbound_messages(status, scheduled_for, created_at); - - CREATE INDEX IF NOT EXISTS idx_outbound_messages_platform_account_status - ON outbound_messages(platform, account_key, status, scheduled_for); - - CREATE VIEW IF NOT EXISTS message_fts_source AS - SELECT - m.rowid AS message_rowid, - m.id AS message_id, - COALESCE(m.sender_name, '') AS sender_name, - COALESCE(m.conversation_name, '') AS conversation_name, - COALESCE(conv.participant_names, '') AS participant_names, - COALESCE(( - SELECT GROUP_CONCAT( - TRIM(COALESCE(ma.filename, '') || ' ' || COALESCE(ma.title, '') || ' ' || COALESCE(ma.text_content, '')), - ' ' - ) - FROM message_attachments ma - WHERE ma.message_id = m.id - ), '') AS attachment_text, - COALESCE(m.content, '') AS content - FROM messages m - JOIN conversations conv ON conv.id = m.conversation_id; - - CREATE TRIGGER IF NOT EXISTS trg_messages_inserted_fts - AFTER INSERT ON messages - BEGIN - DELETE FROM messages_fts WHERE rowid = NEW.rowid; - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id = NEW.id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_messages_updated_fts - AFTER UPDATE OF sender_name, conversation_name, content ON messages - BEGIN - DELETE FROM messages_fts WHERE rowid = NEW.rowid; - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id = NEW.id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_messages_deleted_fts - AFTER DELETE ON messages - BEGIN - DELETE FROM messages_fts WHERE rowid = OLD.rowid; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_attachments_inserted - AFTER INSERT ON message_attachments - BEGIN - UPDATE messages - SET - attachment_count = ( - SELECT COUNT(*) FROM message_attachments WHERE message_id = NEW.message_id - ), - updated_at = MAX(updated_at, NEW.updated_at) - WHERE id = NEW.message_id; - DELETE FROM messages_fts - WHERE rowid IN (SELECT rowid FROM messages WHERE id = NEW.message_id); - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id = NEW.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_attachments_updated - AFTER UPDATE ON message_attachments - BEGIN - UPDATE messages - SET - attachment_count = ( - SELECT COUNT(*) FROM message_attachments WHERE message_id = NEW.message_id - ), - updated_at = MAX(updated_at, NEW.updated_at) - WHERE id = NEW.message_id; - DELETE FROM messages_fts - WHERE rowid IN (SELECT rowid FROM messages WHERE id = NEW.message_id); - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id = NEW.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_attachments_deleted - AFTER DELETE ON message_attachments - BEGIN - UPDATE messages - SET attachment_count = ( - SELECT COUNT(*) FROM message_attachments WHERE message_id = OLD.message_id - ) - WHERE id = OLD.message_id; - DELETE FROM messages_fts - WHERE rowid IN (SELECT rowid FROM messages WHERE id = OLD.message_id); - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id = OLD.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_reactions_inserted - AFTER INSERT ON message_reactions - BEGIN - UPDATE messages - SET reaction_count = ( - SELECT COUNT(*) FROM message_reactions WHERE message_id = NEW.message_id AND is_active = 1 - ) - WHERE id = NEW.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_reactions_updated - AFTER UPDATE ON message_reactions - BEGIN - UPDATE messages - SET reaction_count = ( - SELECT COUNT(*) FROM message_reactions WHERE message_id = NEW.message_id AND is_active = 1 - ) - WHERE id = NEW.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_message_reactions_deleted - AFTER DELETE ON message_reactions - BEGIN - UPDATE messages - SET reaction_count = ( - SELECT COUNT(*) FROM message_reactions WHERE message_id = OLD.message_id AND is_active = 1 - ) - WHERE id = OLD.message_id; - END; - - CREATE TRIGGER IF NOT EXISTS trg_contacts_name_updated - AFTER UPDATE OF name ON contacts - BEGIN - UPDATE messages - SET sender_name = NEW.name - WHERE sender_contact_id = NEW.id; - - UPDATE conversation_participants - SET participant_name = NEW.name - WHERE contact_id = NEW.id; - - UPDATE timeline_events - SET actor_name = NEW.name - WHERE actor_contact_id = NEW.id; - - UPDATE message_reactions - SET reactor_name = NEW.name - WHERE reactor_contact_id = NEW.id; - - UPDATE conversations - SET participant_names = ( - SELECT GROUP_CONCAT(cp.participant_name, ' | ') - FROM conversation_participants cp - WHERE cp.conversation_id = conversations.id - AND cp.is_active = 1 - AND cp.participant_name IS NOT NULL - AND cp.participant_name <> '' - ) - WHERE id IN ( - SELECT DISTINCT conversation_id - FROM conversation_participants - WHERE contact_id = NEW.id - ); - - DELETE FROM messages_fts - WHERE rowid IN ( - SELECT rowid FROM messages WHERE sender_contact_id = NEW.id - UNION - SELECT m.rowid - FROM messages m - JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id - WHERE cp.contact_id = NEW.id - ); - - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id IN ( - SELECT id FROM messages WHERE sender_contact_id = NEW.id - UNION - SELECT m.id - FROM messages m - JOIN conversation_participants cp ON cp.conversation_id = m.conversation_id - WHERE cp.contact_id = NEW.id - ); - END; - - CREATE TRIGGER IF NOT EXISTS trg_conversations_name_updated - AFTER UPDATE OF name ON conversations - BEGIN - UPDATE messages - SET conversation_name = NEW.name - WHERE conversation_id = NEW.id; - - DELETE FROM messages_fts - WHERE rowid IN ( - SELECT rowid FROM messages WHERE conversation_id = NEW.id - ); - - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id IN ( - SELECT id FROM messages WHERE conversation_id = NEW.id - ); - END; - - CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_inserted - AFTER INSERT ON conversation_participants - BEGIN - UPDATE conversation_participants - SET participant_name = COALESCE( - (SELECT name FROM contacts WHERE id = NEW.contact_id), - participant_name - ) - WHERE conversation_id = NEW.conversation_id - AND contact_id = NEW.contact_id - AND COALESCE(source_participant_key, '') = COALESCE(NEW.source_participant_key, ''); - - UPDATE conversations - SET participant_names = ( - SELECT GROUP_CONCAT(cp.participant_name, ' | ') - FROM conversation_participants cp - WHERE cp.conversation_id = NEW.conversation_id - AND cp.is_active = 1 - AND cp.participant_name IS NOT NULL - AND cp.participant_name <> '' - ) - WHERE id = NEW.conversation_id; - - DELETE FROM messages_fts - WHERE rowid IN ( - SELECT rowid FROM messages WHERE conversation_id = NEW.conversation_id - ); - - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id IN ( - SELECT id FROM messages WHERE conversation_id = NEW.conversation_id - ); - END; - - CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_updated - AFTER UPDATE ON conversation_participants - BEGIN - UPDATE conversations - SET participant_names = ( - SELECT GROUP_CONCAT(cp.participant_name, ' | ') - FROM conversation_participants cp - WHERE cp.conversation_id = NEW.conversation_id - AND cp.is_active = 1 - AND cp.participant_name IS NOT NULL - AND cp.participant_name <> '' - ) - WHERE id = NEW.conversation_id; - - DELETE FROM messages_fts - WHERE rowid IN ( - SELECT rowid FROM messages WHERE conversation_id = NEW.conversation_id - ); - - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id IN ( - SELECT id FROM messages WHERE conversation_id = NEW.conversation_id - ); - END; - - CREATE TRIGGER IF NOT EXISTS trg_conversation_participants_deleted - AFTER DELETE ON conversation_participants - BEGIN - UPDATE conversations - SET participant_names = ( - SELECT GROUP_CONCAT(cp.participant_name, ' | ') - FROM conversation_participants cp - WHERE cp.conversation_id = OLD.conversation_id - AND cp.is_active = 1 - AND cp.participant_name IS NOT NULL - AND cp.participant_name <> '' - ) - WHERE id = OLD.conversation_id; - - DELETE FROM messages_fts - WHERE rowid IN ( - SELECT rowid FROM messages WHERE conversation_id = OLD.conversation_id - ); - - INSERT INTO messages_fts (rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content) - SELECT message_rowid, message_id, sender_name, conversation_name, participant_names, attachment_text, content - FROM message_fts_source - WHERE message_id IN ( - SELECT id FROM messages WHERE conversation_id = OLD.conversation_id - ); - END; - `; From 17801ebb7865be139eaf9decf3561687c67c789c Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Wed, 27 May 2026 00:50:51 -0400 Subject: [PATCH 8/8] Clean rebased action substrate --- skills/cued/SKILL.md | 2 +- src/cli.ts | 2 +- src/core/identity.ts | 7 + src/db/database.test.ts | 49 ++++-- src/macos/install.ts | 104 +++++++++++- src/platforms/core/secrets/keychain.ts | 6 +- src/platforms/whatsapp/sync/worker.ts | 2 +- src/runtime/daemon/server.test.ts | 10 ++ src/runtime/projection/events.test.ts | 38 +++-- src/runtime/projection/events.ts | 216 +++++++++++++++++++++++-- src/runtime/run-queue.test.ts | 181 +-------------------- src/runtime/updater/service.test.ts | 1 + 12 files changed, 395 insertions(+), 223 deletions(-) diff --git a/skills/cued/SKILL.md b/skills/cued/SKILL.md index 6824c1ec..e1aeea05 100644 --- a/skills/cued/SKILL.md +++ b/skills/cued/SKILL.md @@ -195,7 +195,7 @@ Platform connection status. Key columns: `platform`, `account_key`, `auth_state` - **Cross-platform view**: Join `conversation_participants` → `conversations` for a contact to see all their threads across platforms. - **Duplicate detection**: Match `contact_handles.normalized_value` across different `contact_id`s, or match `contacts.name` case-insensitively. Many contacts have phone numbers as names (e.g. `+1347...`) because they were discovered via iMessage before being linked. - **Merge duplicates**: Use `cued actions propose contact.merge --payload '{"primaryContactId":"...","secondaryContactId":"...","reason":"..."}' --source-skill cued` to propose a merge. Direct `cued contacts merge` commands do not exist. -- **Merge audit trail**: Manual merges are recorded as executed `contact.merge` actions with `contact.merge.recorded` rows in `action_effects`; there is no separate merge-decision table. +- **Merge audit trail**: New manual merges are recorded as executed `contact.merge` actions with `contact.merge.recorded` rows in `action_effects`; older local databases may still have `contact_merge_decisions`, which Cued reads for rebuild continuity. - **Contact memories**: Use `contact_memories` for compact, evidence-backed agent memory. `cued contacts memory add/stale` proposes actions by default; use `--execute` only when the user explicitly asked to apply the mutation now. Do not write arbitrary SQL. - **Follow-up recommendations**: Use `contact.followup.recommend` for harmless follow-up queueing. It records an executed `contact.followup.recommended` effect only; it does not send messages. - **Enrichment recommendations**: Use `contact.enrichment.recommend` for local-data-backed profile enrichment suggestions. It records an executed `contact.enrichment.recommended` effect only; it does not rewrite contact fields. diff --git a/src/cli.ts b/src/cli.ts index b116e4ec..8a62054c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; diff --git a/src/core/identity.ts b/src/core/identity.ts index f9b0527d..2b221dcb 100644 --- a/src/core/identity.ts +++ b/src/core/identity.ts @@ -1,9 +1,16 @@ export const CUED_APP_BUNDLE_IDENTIFIER = "so.cued.desktop"; +export const CUED_LEGACY_APP_BUNDLE_IDENTIFIERS = ["dev.cued.app"] as const; export const CUED_LAUNCH_AGENT_LABEL = "so.cued.desktop.daemon"; +export const CUED_LEGACY_LAUNCH_AGENT_LABELS = ["dev.cued.daemon"] as const; export const CUED_DB_KEYCHAIN_SERVICE = "so.cued.desktop.db"; +export const CUED_LEGACY_DB_KEYCHAIN_SERVICES = ["dev.cued.db"] as const; export function cuedAuthKeychainService(platform: string): string { return `so.cued.desktop.auth.${platform}`; } + +export function legacyCuedAuthKeychainService(platform: string): string { + return `dev.cued.auth.${platform}`; +} diff --git a/src/db/database.test.ts b/src/db/database.test.ts index 2b057651..003d9988 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -112,10 +112,12 @@ describe("CuedDatabase", () => { requiresApproval: false, }); return db.executeApprovedAction(action.id, "test").result as { - decisionId: string; - primaryContactId: string; - secondaryContactId: string; - canonicalContactId: string; + merge: { + primaryContactId: string; + secondaryContactId: string; + canonicalContactId: string; + reason: string | null; + }; }; } @@ -164,6 +166,21 @@ describe("CuedDatabase", () => { ); } + function insertConversation(db: CuedDatabase, id: string, name: string): void { + const timestamp = Date.now(); + sqlite(db) + .prepare( + ` + INSERT INTO conversations ( + id, platform, account_key, source_conversation_key, native_conversation_key, type, + is_active, removal_reason, service, name, topic, participant_names, last_message_id, + last_message_at, last_message_preview, unread_count, created_at, updated_at + ) VALUES (?, 'imessage', 'default', ?, NULL, 'dm', 1, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, 0, ?, ?) + `, + ) + .run(id, `imessage:${id}`, name, timestamp, timestamp); + } + function sqlite(db: CuedDatabase) { return ( db as unknown as { @@ -1848,11 +1865,13 @@ describe("CuedDatabase", () => { secondaryContactId: "contact-b", reason: "same phone", }), - ).toEqual({ - decisionId: expect.any(String), - primaryContactId: "contact-a", - secondaryContactId: "contact-b", - canonicalContactId: "contact-a", + ).toMatchObject({ + merge: { + primaryContactId: "contact-a", + secondaryContactId: "contact-b", + canonicalContactId: "contact-a", + reason: "same phone", + }, }); expect( executeContactMerge(db, { @@ -1860,11 +1879,13 @@ describe("CuedDatabase", () => { secondaryContactId: "contact-a", reason: "prefer contacts source", }), - ).toEqual({ - decisionId: expect.any(String), - primaryContactId: "contact-c", - secondaryContactId: "contact-a", - canonicalContactId: "contact-c", + ).toMatchObject({ + merge: { + primaryContactId: "contact-c", + secondaryContactId: "contact-a", + canonicalContactId: "contact-c", + reason: "prefer contacts source", + }, }); expect(db.listContactMergeAliases()).toEqual([ diff --git a/src/macos/install.ts b/src/macos/install.ts index 3f1b8759..0a925832 100644 --- a/src/macos/install.ts +++ b/src/macos/install.ts @@ -3,12 +3,13 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { CUED_APP_BUNDLE_IDENTIFIER } from "../core/identity.js"; +import { CUED_APP_BUNDLE_IDENTIFIER, CUED_LEGACY_LAUNCH_AGENT_LABELS } from "../core/identity.js"; import { resolveMacOSNativeBinary } from "../runtime/native-binary.js"; import { terminateCompetingDaemons } from "./competing-daemons.js"; const APP_NAME = "Cued.app"; const APP_EXECUTABLE_NAME = "CuedDaemon"; +const LEGACY_LAUNCH_AGENT_LABEL = CUED_LEGACY_LAUNCH_AGENT_LABELS[0]; type NativeLoginItemStatus = { enabled: boolean; @@ -17,11 +18,26 @@ type NativeLoginItemStatus = { found: boolean; }; +export type LegacyLaunchAgentStatus = { + label: string; + plistPath: string; + installed: boolean; + loaded: boolean; + details?: string; +}; + export type LoginItemStatus = NativeLoginItemStatus & { appPath: string | null; + legacyLaunchAgent: LegacyLaunchAgentStatus; +}; + +export type LoginItemCommandResult = LoginItemStatus & { + migratedLegacyLaunchAgent: boolean; }; -export type LoginItemCommandResult = LoginItemStatus; +function currentUid(): number { + return typeof process.getuid === "function" ? process.getuid() : 0; +} function repoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -209,6 +225,23 @@ export function installMacOSAppFromSource( }; } +function launchAgentPath(): string { + return join(homedir(), "Library", "LaunchAgents", `${LEGACY_LAUNCH_AGENT_LABEL}.plist`); +} + +export function getLaunchAgentPlistPath(): string { + return launchAgentPath(); +} + +function legacyLaunchAgentServiceTarget(): string { + return `gui/${currentUid()}/${LEGACY_LAUNCH_AGENT_LABEL}`; +} + +function parseLaunchAgentPath(details: string): string | null { + const match = details.match(/^\s*path = (.+)$/m); + return match?.[1]?.trim() ?? null; +} + function appExecutablePath(appPath: string): string { return join(appPath, "Contents", "MacOS", APP_EXECUTABLE_NAME); } @@ -258,11 +291,69 @@ function runNativeLoginItemCommand( }; } +export function bootoutLaunchAgent(): { plistPath: string; bootedOut: boolean } { + const status = getLegacyLaunchAgentStatus(); + if (!status.installed && !status.loaded) { + return { plistPath: status.plistPath, bootedOut: false }; + } + + try { + execFileSync("launchctl", ["bootout", legacyLaunchAgentServiceTarget()], { stdio: "ignore" }); + return { plistPath: status.plistPath, bootedOut: true }; + } catch { + return { plistPath: status.plistPath, bootedOut: false }; + } +} + +export function getLegacyLaunchAgentStatus(): LegacyLaunchAgentStatus { + const fallbackPath = launchAgentPath(); + try { + const details = execFileSync("launchctl", ["print", legacyLaunchAgentServiceTarget()], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const plistPath = parseLaunchAgentPath(details) ?? fallbackPath; + return { + label: LEGACY_LAUNCH_AGENT_LABEL, + plistPath, + installed: existsSync(plistPath), + loaded: true, + details, + }; + } catch (error) { + return { + label: LEGACY_LAUNCH_AGENT_LABEL, + plistPath: fallbackPath, + installed: existsSync(fallbackPath), + loaded: false, + details: error instanceof Error ? error.message : String(error), + }; + } +} + +export function removeLegacyLaunchAgent(): { + plistPath: string; + bootedOut: boolean; + removed: boolean; +} { + const { plistPath, bootedOut } = bootoutLaunchAgent(); + const removed = existsSync(plistPath); + if (removed) { + rmSync(plistPath, { force: true }); + } + return { + plistPath, + bootedOut, + removed, + }; +} + export function getLoginItemStatus(appPath?: string): LoginItemStatus { const resolvedAppPath = appPath ?? resolveInstalledAppPath(); return { ...runNativeLoginItemCommand("status", resolvedAppPath ?? undefined), appPath: resolvedAppPath, + legacyLaunchAgent: getLegacyLaunchAgentStatus(), }; } @@ -275,11 +366,17 @@ export function enableLoginItem(appPath?: string): LoginItemCommandResult { expectedExecutablePath: appExecutablePath(resolvedAppPath), }); + const legacyBefore = getLegacyLaunchAgentStatus(); const native = runNativeLoginItemCommand("enable", resolvedAppPath); + if (legacyBefore.installed) { + removeLegacyLaunchAgent(); + } return { ...native, appPath: resolvedAppPath, + legacyLaunchAgent: getLegacyLaunchAgentStatus(), + migratedLegacyLaunchAgent: legacyBefore.installed, }; } @@ -290,10 +387,13 @@ export function disableLoginItem(appPath?: string): LoginItemCommandResult { nativeStatus.status === "enabled" || nativeStatus.status === "requires_approval" ? runNativeLoginItemCommand("disable", resolvedAppPath ?? undefined) : nativeStatus; + const legacy = removeLegacyLaunchAgent(); return { ...native, appPath: resolvedAppPath, + legacyLaunchAgent: getLegacyLaunchAgentStatus(), + migratedLegacyLaunchAgent: legacy.removed || legacy.bootedOut, }; } diff --git a/src/platforms/core/secrets/keychain.ts b/src/platforms/core/secrets/keychain.ts index df885c91..e058d430 100644 --- a/src/platforms/core/secrets/keychain.ts +++ b/src/platforms/core/secrets/keychain.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { cuedAuthKeychainService } from "../../../core/identity.js"; +import { cuedAuthKeychainService, legacyCuedAuthKeychainService } from "../../../core/identity.js"; import type { Platform } from "../../../core/types/provider.js"; import { openCuedDatabaseReadOnly } from "../../../db/database.js"; @@ -14,6 +14,10 @@ export function authKeychainService(platform: Platform): string { return cuedAuthKeychainService(platform); } +export function legacyAuthKeychainService(platform: Platform): string { + return legacyCuedAuthKeychainService(platform); +} + export function loadKeychainSecret( service: string, account: string, diff --git a/src/platforms/whatsapp/sync/worker.ts b/src/platforms/whatsapp/sync/worker.ts index 536b09ba..4032b0c3 100644 --- a/src/platforms/whatsapp/sync/worker.ts +++ b/src/platforms/whatsapp/sync/worker.ts @@ -4,7 +4,7 @@ import { buildWhatsAppDesktopSyncBundle } from "../desktop.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("whatsapp"); + const invocation = readAdapterInvocationEnv(); if ( process.env.CUED_WHATSAPP_SYNC_SOURCE === "desktop_db" || process.env.CUED_WHATSAPP_DESKTOP_SOURCE_PATH diff --git a/src/runtime/daemon/server.test.ts b/src/runtime/daemon/server.test.ts index e930c91b..1dad796c 100644 --- a/src/runtime/daemon/server.test.ts +++ b/src/runtime/daemon/server.test.ts @@ -40,6 +40,12 @@ function createDispatchHarness() { finishedAt: Date.now(), error: null, }; + const authRuntimeStatus = { + active: false, + expiresAt: null, + projectionBatchSize: 500, + ingestConcurrency: 2, + }; const dispatch = (request: Parameters[1]) => dispatchRequest( db, @@ -57,7 +63,11 @@ function createDispatchHarness() { () => ({ shuttingDown: false, requestedAt: null }), () => false, () => {}, + () => authRuntimeStatus, + () => authRuntimeStatus, + () => {}, () => {}, + () => true, ); return { db, diff --git a/src/runtime/projection/events.test.ts b/src/runtime/projection/events.test.ts index d6e13903..20fa423f 100644 --- a/src/runtime/projection/events.test.ts +++ b/src/runtime/projection/events.test.ts @@ -138,22 +138,26 @@ describe("normalized raw event registry", () => { ); }); - it("rejects non-canonical stored schemas during projection normalization", () => { - expect(() => - normalizeStoredRawEventForProjection( - { - entityKind: "message", - eventKind: "created", - normalizedSchema: "message.message_created@1", - }, - { - sourceMessageKey: "message-1", - sourceConversationKey: "conversation-1", - senderSourceKey: "contact-1", - sentAt: 1, - content: "hello", - }, - ), - ).toThrow("Unsupported normalized raw event schema 'message.message_created@1'"); + it("upcasts legacy stored schemas during projection normalization", () => { + const normalized = normalizeStoredRawEventForProjection( + { + entityKind: "message", + eventKind: "created", + normalizedSchema: "message.message_created@1", + }, + { + sourceMessageKey: "message-1", + sourceConversationKey: "conversation-1", + senderSourceKey: "contact-1", + sentAt: 1, + content: "hello", + }, + ); + + expect(normalized).toMatchObject({ + entityKind: "message", + eventKind: "created", + normalizedSchema: "message.created@1", + }); }); }); diff --git a/src/runtime/projection/events.ts b/src/runtime/projection/events.ts index 51eb9bc0..92a64095 100644 --- a/src/runtime/projection/events.ts +++ b/src/runtime/projection/events.ts @@ -1,8 +1,10 @@ import { buildNormalizedRawEventSchema, + type ParticipantPayload, type ProviderRawEventInput, type RawEventEntityKind, type RawEventPayload, + type TimelineEventPayload, } from "../../core/types/provider.js"; type StoredRawEvent = Pick & { @@ -128,22 +130,214 @@ function parseNormalizedSchema(schema: string): { }; } -function normalizeCanonicalSchema( - normalizedSchema: string, +function buildSystemMessagePayload( payload: RawEventPayload, -): NormalizedProjectedRawEvent { - const parsed = parseNormalizedSchema(normalizedSchema); - if (!SUPPORTED_NORMALIZED_SCHEMAS.has(normalizedSchema)) { - throw new Error(`Unsupported normalized raw event schema '${normalizedSchema}'`); + legacyEventKind: string, +): TimelineEventPayload { + if ( + typeof payload === "object" && + payload !== null && + "sourceEventKey" in payload && + typeof payload.sourceEventKey === "string" && + "sourceConversationKey" in payload && + typeof payload.sourceConversationKey === "string" && + "eventAt" in payload && + typeof payload.eventAt === "number" + ) { + const timelinePayload = payload as TimelineEventPayload; + return { + ...timelinePayload, + eventKind: "system_message", + metadata: { + ...(timelinePayload.metadata ?? {}), + legacyEventKind, + }, + }; + } + + if ( + typeof payload === "object" && + payload !== null && + "sourceMessageKey" in payload && + typeof payload.sourceMessageKey === "string" && + "sourceConversationKey" in payload && + typeof payload.sourceConversationKey === "string" && + "sentAt" in payload && + typeof payload.sentAt === "number" + ) { + const messagePayload = payload as Record; + return { + sourceEventKey: String(messagePayload.sourceMessageKey), + sourceConversationKey: String(messagePayload.sourceConversationKey), + eventKind: "system_message", + eventAt: Number(messagePayload.sentAt), + text: typeof messagePayload.content === "string" ? messagePayload.content : null, + metadata: { + legacyEventKind, + sourceMessageKey: messagePayload.sourceMessageKey, + }, + }; + } + + throw new Error( + `Unable to upcast payload for legacy normalized raw event schema '${legacyEventKind}'`, + ); +} + +function buildParticipantPayload( + payload: RawEventPayload, + eventKind: "joined" | "left", +): ParticipantPayload { + if ( + typeof payload !== "object" || + payload === null || + !("sourceConversationKey" in payload) || + typeof payload.sourceConversationKey !== "string" + ) { + throw new Error(`Unable to upcast payload for legacy participant schema '${eventKind}'`); + } + const timelinePayload = payload as TimelineEventPayload; + const participantSourceKey = + typeof timelinePayload.subjectSourceKey === "string" && + timelinePayload.subjectSourceKey.length > 0 + ? timelinePayload.subjectSourceKey + : typeof timelinePayload.actorSourceKey === "string" && + timelinePayload.actorSourceKey.length > 0 + ? timelinePayload.actorSourceKey + : ""; + if (!participantSourceKey) { + throw new Error(`Unable to upcast payload for legacy participant schema '${eventKind}'`); } return { - entityKind: parsed.entityKind, - eventKind: parsed.eventKind, - normalizedSchema, - payload, + sourceConversationKey: timelinePayload.sourceConversationKey, + participantSourceKey, + eventAt: timelinePayload.eventAt, + metadata: { + ...(timelinePayload.metadata ?? {}), + legacyTimelineEventKind: timelinePayload.eventKind, + }, }; } +function canonicalizeProjectedSchema( + normalizedSchema: string, + payload: RawEventPayload, +): NormalizedProjectedRawEvent { + switch (normalizedSchema) { + case "message.message_created@1": + return { + entityKind: "message", + eventKind: "created", + normalizedSchema: buildNormalizedRawEventSchema("message", "created"), + payload, + }; + case "message.message_updated@1": + return { + entityKind: "message", + eventKind: "updated", + normalizedSchema: buildNormalizedRawEventSchema("message", "updated"), + payload, + }; + case "message.message_deleted@1": + return { + entityKind: "message", + eventKind: "deleted", + normalizedSchema: buildNormalizedRawEventSchema("message", "deleted"), + payload, + }; + case "message.message_read_receipt@1": + return { + entityKind: "message", + eventKind: "read_receipt", + normalizedSchema: buildNormalizedRawEventSchema("message", "read_receipt"), + payload, + }; + case "message.message_observed@1": + return { + entityKind: "timeline_event", + eventKind: "system_message", + normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), + payload: buildSystemMessagePayload(payload, "message_observed"), + }; + case "reaction.created@1": { + const reactionEventKind = + typeof payload === "object" && + payload !== null && + "isActive" in payload && + payload.isActive === false + ? "removed" + : "added"; + return { + entityKind: "reaction", + eventKind: reactionEventKind, + normalizedSchema: buildNormalizedRawEventSchema("reaction", reactionEventKind), + payload, + }; + } + case "reaction.reaction_added@1": + return { + entityKind: "reaction", + eventKind: "added", + normalizedSchema: buildNormalizedRawEventSchema("reaction", "added"), + payload, + }; + case "reaction.reaction_removed@1": + return { + entityKind: "reaction", + eventKind: "removed", + normalizedSchema: buildNormalizedRawEventSchema("reaction", "removed"), + payload, + }; + case "timeline_event.linkedin_system_message@1": + return { + entityKind: "timeline_event", + eventKind: "system_message", + normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), + payload: buildSystemMessagePayload(payload, "linkedin_system_message"), + }; + case "timeline_event.linkedin_group_read_receipt@1": + return { + entityKind: "timeline_event", + eventKind: "system_message", + normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), + payload: buildSystemMessagePayload(payload, "linkedin_group_read_receipt"), + }; + case "timeline_event.linkedin_conversation_removed@1": + return { + entityKind: "timeline_event", + eventKind: "system_message", + normalizedSchema: buildNormalizedRawEventSchema("timeline_event", "system_message"), + payload: buildSystemMessagePayload(payload, "linkedin_conversation_removed"), + }; + case "timeline_event.participant_joined@1": + return { + entityKind: "participant", + eventKind: "joined", + normalizedSchema: buildNormalizedRawEventSchema("participant", "joined"), + payload: buildParticipantPayload(payload, "joined"), + }; + case "timeline_event.participant_left@1": + return { + entityKind: "participant", + eventKind: "left", + normalizedSchema: buildNormalizedRawEventSchema("participant", "left"), + payload: buildParticipantPayload(payload, "left"), + }; + default: { + const parsed = parseNormalizedSchema(normalizedSchema); + if (!SUPPORTED_NORMALIZED_SCHEMAS.has(normalizedSchema)) { + throw new Error(`Unsupported normalized raw event schema '${normalizedSchema}'`); + } + return { + entityKind: parsed.entityKind, + eventKind: parsed.eventKind, + normalizedSchema, + payload, + }; + } + } +} + export function normalizeStoredRawEventForProjection( event: StoredRawEvent, payload: RawEventPayload, @@ -151,5 +345,5 @@ export function normalizeStoredRawEventForProjection( const normalizedSchema = event.normalizedSchema?.trim() || buildNormalizedRawEventSchema(event.entityKind, event.eventKind); - return normalizeCanonicalSchema(normalizedSchema, payload); + return canonicalizeProjectedSchema(normalizedSchema, payload); } diff --git a/src/runtime/run-queue.test.ts b/src/runtime/run-queue.test.ts index 5f2edf23..957d76ec 100644 --- a/src/runtime/run-queue.test.ts +++ b/src/runtime/run-queue.test.ts @@ -1,10 +1,8 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { CuedDatabase } from "../db/database.js"; -import { rebuildProjectedState } from "./projection/projector.js"; import { RunQueueService } from "./run-queue.js"; describe("RunQueueService", () => { @@ -126,28 +124,19 @@ describe("RunQueueService", () => { db.close(); }); - it("preserves the legacy platform-only queue when no explicit sync targets exist", () => { + it("does not queue source syncs without an explicit target", () => { const db = createDb(); const queue = new RunQueueService(db); const result = queue.queueSyncRun("slack"); expect(result).toEqual({ - queued: true, - runId: result.runIds[0] ?? null, - runIds: expect.any(Array), - targets: ["slack"], - }); - expect(result.runIds).toHaveLength(1); - - const [run] = db.listRecentRuns(1); - expect(run).toMatchObject({ - platform: "slack", - account_key: null, - trigger: "cli", - run_type: "sync", - status: "queued", + queued: false, + runId: null, + runIds: [], + targets: [], }); + expect(db.listRecentRuns(1)).toHaveLength(0); db.close(); }); @@ -219,162 +208,4 @@ describe("RunQueueService", () => { db.close(); }); - - it("records a manual contact merge and rebuilds projected state immediately", () => { - const db = createDb(); - db.insertRawEvent({ - id: "contact-primary", - platform: "contacts", - accountKey: "local", - entityKind: "contact", - eventKind: "observed", - observedAt: 1, - dedupeKey: "contacts:primary", - payload: { - sourceEntityKey: "contacts:primary", - fields: { display_name: "Ava Chen" }, - handles: [{ type: "phone", value: "+1 (555) 123-4567", deterministic: true }], - }, - sourceVersion: "contacts-v1", - }); - db.insertRawEvent({ - id: "contact-secondary", - platform: "linkedin", - accountKey: "default", - entityKind: "contact", - eventKind: "observed", - observedAt: 2, - dedupeKey: "linkedin:secondary", - payload: { - sourceEntityKey: "linkedin:secondary", - fields: { display_name: "Ava Chen" }, - handles: [{ type: "linkedin", value: "urn:li:person:ava-chen", deterministic: true }], - }, - sourceVersion: "linkedin-v1", - }); - - rebuildProjectedState(db); - const contacts = db.orm().all<{ id: string }>(sql` - SELECT id - FROM contacts - ORDER BY created_at ASC, id ASC - `); - const queue = new RunQueueService(db); - const result = queue.mergeContacts({ - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[1]!.id, - reason: "manual test", - }); - - expect(result).toEqual({ - merged: true, - decisionId: expect.any(String), - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[1]!.id, - canonicalContactId: contacts[0]!.id, - projection: expect.objectContaining({ - contacts: 1, - projectionWatermark: 2, - }), - }); - expect(db.getOverview().contacts).toBe(1); - - db.close(); - }); - - it("validates batch contact merges before recording and rebuilds once on apply", () => { - const db = createDb(); - for (const [id, platform, handle] of [ - ["contact-a", "contacts", "+1 (555) 123-4567"], - ["contact-b", "linkedin", "urn:li:person:ava-chen"], - ["contact-c", "slack", "ava@example.com"], - ] as const) { - db.insertRawEvent({ - id, - platform, - accountKey: "default", - entityKind: "contact", - eventKind: "observed", - observedAt: 1, - dedupeKey: `${platform}:${id}`, - payload: { - sourceEntityKey: `${platform}:${id}`, - fields: { display_name: "Ava Chen" }, - handles: [ - { - type: platform === "contacts" ? "phone" : platform, - value: handle, - deterministic: true, - }, - ], - }, - sourceVersion: `${platform}-v1`, - }); - } - - rebuildProjectedState(db); - const contacts = db.orm().all<{ id: string }>(sql` - SELECT id - FROM contacts - ORDER BY created_at ASC, id ASC - `); - expect(contacts).toHaveLength(3); - - const queue = new RunQueueService(db); - const dryRun = queue.mergeContactsBatch({ - apply: false, - merges: [ - { - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[1]!.id, - reason: "batch dry-run one", - }, - { - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[2]!.id, - reason: "batch dry-run two", - }, - ], - }); - - expect(dryRun).toMatchObject({ - applied: false, - mergeCount: 2, - decisions: [ - expect.objectContaining({ canonicalContactId: contacts[0]!.id }), - expect.objectContaining({ canonicalContactId: contacts[0]!.id }), - ], - }); - expect(db.listContactMergeDecisions()).toEqual([]); - expect(db.getOverview().contacts).toBe(3); - - const applied = queue.mergeContactsBatch({ - apply: true, - merges: [ - { - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[1]!.id, - reason: "batch apply one", - }, - { - primaryContactId: contacts[0]!.id, - secondaryContactId: contacts[2]!.id, - reason: "batch apply two", - }, - ], - }); - - expect(applied).toMatchObject({ - applied: true, - mergeCount: 2, - projection: expect.objectContaining({ - contacts: 1, - projectionWatermark: 3, - }), - }); - expect(db.listContactMergeDecisions()).toHaveLength(2); - expect(db.getOverview().contacts).toBe(1); - - db.close(); - }); }); diff --git a/src/runtime/updater/service.test.ts b/src/runtime/updater/service.test.ts index f88cc316..f78337ec 100644 --- a/src/runtime/updater/service.test.ts +++ b/src/runtime/updater/service.test.ts @@ -268,6 +268,7 @@ describe("updater service", () => { targetVersion: "0.2.0", releaseUrl: null, stagingRoot: "/tmp/staging", + migrateLegacyLaunchAgent: false, }); expect(script).toContain("HELPER_CLI_PATH=");