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/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: 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..4728e4ce --- /dev/null +++ b/scripts/smoke-actions-local.ts @@ -0,0 +1,221 @@ +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 = db.listContactMergeAliases(); + 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 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( + { + 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..7d64f660 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,29 @@ if [[ ! -d "$RUNTIME_PATH/dist" || ! -d "$RUNTIME_PATH/node_modules" ]]; then exit 1 fi +REQUIRED_SKILL_ACTION_FILES=( + contact.enrichment.recommend.json + contact.followup.recommend.json + contact.introduction.recommend.json + contact.message.draft.json + 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.summary.draft.json + record-effect.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 +113,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..e1aeea05 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**: 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. +- **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-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..cbebdb39 --- /dev/null +++ b/skills/cued/actions/contact-merge.cjs @@ -0,0 +1,91 @@ +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 planContactMerges(db, input) { + if (input.length === 0) { + throw new Error("At least one contact merge is required."); + } + + const aliasMap = new Map( + db.listContactMergeAliases().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({ + primaryContactId: canonicalPrimary, + secondaryContactId: canonicalSecondary, + canonicalContactId: canonicalPrimary, + reason: merge.reason ?? null, + }); + } + return planned; +} + +function execute({ action, db, helpers }) { + const payload = helpers.parseActionPayloadObject(action); + 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: merge.secondaryContactId, + toContactId: merge.canonicalContactId, + }); + const effect = db.recordActionEffect({ + actionId: action.id, + effectType: "contact.merge.recorded", + targetTable: "contacts", + targetId: merge.canonicalContactId, + payload: merge, + }); + + return { + result: { merge }, + 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..f1bc5829 --- /dev/null +++ b/skills/cued/actions/contact.enrichment.recommend.json @@ -0,0 +1,29 @@ +{ + "type": "contact.enrichment.recommend", + "version": "1", + "description": "Record a local-data-backed contact enrichment recommendation.", + "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", + "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..bbb44d64 --- /dev/null +++ b/skills/cued/actions/contact.followup.recommend.json @@ -0,0 +1,25 @@ +{ + "type": "contact.followup.recommend", + "version": "1", + "description": "Record a harmless follow-up recommendation for a contact.", + "module": "actions/record-effect.cjs", + "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.followup.recommended", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "contactId", + "resultKey": "recommendation" + }, + "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..ade58796 --- /dev/null +++ b/skills/cued/actions/contact.introduction.recommend.json @@ -0,0 +1,28 @@ +{ + "type": "contact.introduction.recommend", + "version": "1", + "description": "Record a harmless introduction recommendation between two contacts.", + "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", + "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..87d9811d --- /dev/null +++ b/skills/cued/actions/contact.message.draft.json @@ -0,0 +1,26 @@ +{ + "type": "contact.message.draft", + "version": "1", + "description": "Record a harmless drafted message for a contact without sending it.", + "module": "actions/record-effect.cjs", + "requiresApprovalDefault": true, + "effect": { + "effectType": "contact.message.drafted", + "targetTable": "contacts", + "targetExists": "contact", + "targetIdField": "contactId", + "resultKey": "draft" + }, + "payload": { + "required": { + "contactId": "string", + "body": "string", + "reason": "string" + }, + "optional": { + "channelHint": "string", + "evidence": "object", + "confidence": "number" + } + } +} diff --git a/skills/cued/actions/conversation.followup.recommend.json b/skills/cued/actions/conversation.followup.recommend.json new file mode 100644 index 00000000..61286db5 --- /dev/null +++ b/skills/cued/actions/conversation.followup.recommend.json @@ -0,0 +1,25 @@ +{ + "type": "conversation.followup.recommend", + "version": "1", + "description": "Record a harmless follow-up recommendation for a conversation.", + "module": "actions/record-effect.cjs", + "requiresApprovalDefault": true, + "effect": { + "effectType": "conversation.followup.recommended", + "targetTable": "conversations", + "targetExists": "conversation", + "targetIdField": "conversationId", + "resultKey": "recommendation" + }, + "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..6ce38db4 --- /dev/null +++ b/skills/cued/actions/conversation.summary.draft.json @@ -0,0 +1,26 @@ +{ + "type": "conversation.summary.draft", + "version": "1", + "description": "Record a harmless drafted summary for a conversation without modifying messages.", + "module": "actions/record-effect.cjs", + "requiresApprovalDefault": true, + "effect": { + "effectType": "conversation.summary.drafted", + "targetTable": "conversations", + "targetExists": "conversation", + "targetIdField": "conversationId", + "resultKey": "draft" + }, + "payload": { + "required": { + "conversationId": "string", + "summary": "string", + "reason": "string" + }, + "optional": { + "timeWindow": "string", + "evidence": "object", + "confidence": "number" + } + } +} 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/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/execution.ts b/src/actions/execution.ts new file mode 100644 index 00000000..ce20ccb3 --- /dev/null +++ b/src/actions/execution.ts @@ -0,0 +1,139 @@ +import { safeParseJson } from "../db/codecs.js"; +import type { + ActionEffectRow, + ActionRow, + ContactMemoryRow, + ContactMergeAlias, +} from "../db/database.js"; +import type { ActionDefinition } from "./registry.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; + listContactMergeAliases(): ContactMergeAlias[]; +} + +export interface ActionExecutionContext { + action: ActionRow; + definition: ActionDefinition; + db: ActionSkillDatabase; + executedBy: string; + helpers: ActionExecutionHelpers; +} + +export interface ActionExecutionResult { + result: unknown; + effects: ActionEffectRow[]; +} + +export type ActionExecutor = (context: ActionExecutionContext) => ActionExecutionResult; + +export interface ActionExecutionHelpers { + parseActionPayloadObject: typeof parseActionPayloadObject; + requiredStringPayload: typeof requiredStringPayload; + optionalStringPayload: typeof optionalStringPayload; + optionalNumberPayload: typeof optionalNumberPayload; + optionalObjectPayload: typeof optionalObjectPayload; +} + +export const actionExecutionHelpers: ActionExecutionHelpers = { + 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..6444a614 --- /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/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/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( + 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..99a1cff5 --- /dev/null +++ b/src/actions/registry.ts @@ -0,0 +1,396 @@ +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; + }; + effect?: { + effectType: string; + targetTable: string; + targetExists: "contact" | "conversation"; + targetIdField: string; + requireExistingFields: string[]; + distinctFieldPairs: Array<[string, string]>; + resultKey: string; + defaults: Record; + }; +} + +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 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, + 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), + effect: parseEffectDefinition(raw.effect, 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 loadActionDefinitions(path: string): ActionDefinition[] { + if (!existsSync(path)) { + return []; + } + if (!statSync(path).isDirectory()) { + throw new Error(`Action definitions path must be a directory: ${path}`); + } + 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[] { + 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}`; + } +} + +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..8a62054c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,12 @@ #!/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"; -import { DaemonRequestTimeoutError, sendDaemonRequest } from "./client.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"; 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,227 @@ 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: executed.requiresProjectionRebuild ? 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 { + printJson( + db.runApprovedActions({ + limit: limit ?? undefined, + executedBy, + afterAction: (executed) => { + if (executed.requiresProjectionRebuild) { + rebuildProjectedState(db); + } + }, + }), + ); + } 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 +1429,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 +1438,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 +1484,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/db/database.test.ts b/src/db/database.test.ts index 1d19a1aa..003d9988 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -98,6 +98,29 @@ 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 { + merge: { + primaryContactId: string; + secondaryContactId: string; + canonicalContactId: string; + reason: string | null; + }; + }; + } + function insertHandle( db: CuedDatabase, input: { @@ -143,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 { @@ -170,6 +208,91 @@ 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 stale action substrate columns without deleting legacy merge aliases", () => { + const db = createDb(); + sqlite(db).exec(` + ALTER TABLE actions ADD COLUMN source_job_id TEXT; + 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 + ); + `); + + 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(db.listContactMergeAliases()).toEqual([ + { contact_id: "contact-b", canonical_contact_id: "contact-a" }, + ]); db.close(); }); @@ -247,6 +370,530 @@ 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: { secondaryContactId: "contact-b", 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({ + secondaryContactId: "contact-b", + canonicalContactId: "contact-a", + }), + applied_at: 1234, + reverted_at: null, + }); + + 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({ + merge: { secondaryContactId: "contact-b", canonicalContactId: "contact-a" }, + }), + 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 +1852,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,35 +1860,39 @@ describe("CuedDatabase", () => { insertContact(db, { id: "contact-c", name: "Ava Canonical" }); expect( - db.recordContactMergeDecision({ + executeContactMerge(db, { primaryContactId: "contact-a", 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( - db.recordContactMergeDecision({ + executeContactMerge(db, { primaryContactId: "contact-c", 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.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 +1908,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 b9e6afac..c97fc6ac 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, @@ -44,11 +50,12 @@ import * as schema from "./schema.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,31 @@ 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"); +} + +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; @@ -150,6 +181,96 @@ 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; + 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 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 = ` + 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 +`; + export interface ContactMemoryRow { id: string; contact_id: string; @@ -165,20 +286,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 +351,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 +617,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,6 +678,10 @@ export class CuedDatabase { this.db = drizzle(this.sqlite, { schema }); } + initializeSchema(): void { + this.migrate(); + } + migrate(): void { for (const migration of MIGRATIONS) { const alreadyApplied = this.sqlite @@ -632,6 +715,7 @@ export class CuedDatabase { throw error; } } + this.dropLegacyActionResidue(); } close(): void { @@ -657,165 +741,611 @@ 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, + createAction(input: { + actionType: string; + actionVersion?: string; + payload: unknown; + priority?: number; + title?: string | null; + summary?: string | null; + sourceSkill?: 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."); + } + + 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, + 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) - .orderBy(asc(contactMergeDecisions.createdAt), asc(contactMergeDecisions.id)) - .all() as ContactMergeDecisionRow[]; + .run(); + + return this.getAction(id)!; } - listContactMergeAliases(): Array<{ - contact_id: string; - canonical_contact_id: string; - }> { - return this.db - .select({ - contact_id: contactMergeDecisions.secondaryContactId, - canonical_contact_id: contactMergeDecisions.canonicalContactId, + 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 + ); + } + + 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); + + 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[]; + } + + 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[]; + } + + 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}'.`); + } + + const payloadJson = safeStringifyJson(payload); + if (payloadJson === null) { + throw new Error("Action payload must be JSON-serializable."); + } + const timestamp = now(); + this.db + .update(actions) + .set({ + payloadJson, + payloadHash: hashActionPayload(payloadJson), + updatedAt: timestamp, }) - .from(contactMergeDecisions) - .where(eq(contactMergeDecisions.decisionType, "merge")) - .orderBy(asc(contactMergeDecisions.createdAt), asc(contactMergeDecisions.id)) - .all() as Array<{ - contact_id: string; - canonical_contact_id: string; - }>; + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; } - resolveCanonicalContactId(contactId: string): string { - const aliasMap = new Map( - this.listContactMergeAliases().map((row) => [row.contact_id, row.canonical_contact_id]), - ); - return resolveCanonicalContactIdFromAliases(contactId, aliasMap); + 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)!; } - planContactMergeDecisions(input: ContactMergeBatchInput[]): PlannedContactMergeDecision[] { - if (input.length === 0) { - throw new Error("At least one contact merge is required."); + 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(); + this.db + .update(actions) + .set({ + status: "denied", + approvalStatus: "denied", + deniedBy, + deniedAt: timestamp, + updatedAt: timestamp, + }) + .where(eq(actions.id, id)) + .run(); + return this.getAction(id)!; + } - 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."); + lockActionForExecution(id: string, executedBy = "daemon"): ActionRow { + const timestamp = now(); + const result = this.sqlite + .prepare( + ` + UPDATE actions + SET status = 'executing', + execution_status = 'running', + executed_by = ?, + locked_at = ?, + started_at = ?, + updated_at = ? + WHERE id = ? + AND approval_status IN ('approved', 'auto_approved') + AND execution_status = 'pending' + AND locked_at IS NULL + `, + ) + .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 (primaryContactId === secondaryContactId) { - throw new Error("Cannot merge a contact into itself"); + 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)!; + } - this.assertContactExists(primaryContactId, "Primary"); - this.assertContactExists(secondaryContactId, "Secondary"); - - 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}`, - ); - } + 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)!; + } - aliasMap.set(canonicalSecondary, canonicalPrimary); - resolveCanonicalContactIdFromAliases(canonicalSecondary, aliasMap); - planned.push({ - decisionId: randomUUID(), - primaryContactId: canonicalPrimary, - secondaryContactId: canonicalSecondary, - canonicalContactId: canonicalPrimary, - reason: merge.reason ?? null, - }); + 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."); } - return planned; + 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)!; } - 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 }, + 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 ); - return { - decisionId: decision!.decisionId, - primaryContactId: decision!.primaryContactId, - secondaryContactId: decision!.secondaryContactId, - canonicalContactId: decision!.canonicalContactId, - }; } - recordContactMergeDecisionsBatch( - input: ContactMergeBatchInput[], - options: { createdBy?: string | null } = {}, - ): PlannedContactMergeDecision[] { - const planned = this.planContactMergeDecisions(input); - 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; - })(); + 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[]; } - private assertContactExists(contactId: string, label: "Primary" | "Secondary"): void { - const contact = this.sqlite + 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 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.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 id - FROM contacts - WHERE id = ? - LIMIT 1 + 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 `, ) - .get(contactId) as { id: string } | undefined; - if (!contact) { - throw new Error(`${label} contact not found: ${contactId}`); + .all() as ContactMergeAlias[]; + } + + 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'", + "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[]; + } + + listContactMergeAliases(): ContactMergeAlias[] { + 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 { + 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, + definition, + db: this.createActionSkillDatabase(), + executedBy, + helpers: actionExecutionHelpers, + }); + + return { + action: this.completeAction(locked.id, result), + effects, + result, + requiresProjectionRebuild: definition.postExecution.rebuildProjection === true, + }; + })(); + } catch (error) { + this.failAction(locked.id, error); + throw error; + } + } + + 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), + 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), + listContactMergeAliases: () => this.listContactMergeAliases(), + }; + } + + 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[] { @@ -1128,8 +1658,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 +1665,7 @@ export class CuedDatabase { Number(removedErrors) + Number(removedCheckpoints) + Number(removedSyncProofs) + - Number(removedSyncScopes) + - Number(removedSlackBackfillProofs) + Number(removedSyncScopes) ); }); @@ -2041,22 +2568,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 +5437,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/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.ts b/src/db/schema.ts index 6a417d38..dc7a4fb2 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,47 @@ 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"), + 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 +188,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 +296,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/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/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/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/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..1dad796c 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,66 @@ 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 authRuntimeStatus = { + active: false, + expiresAt: null, + projectionBatchSize: 500, + ingestConcurrency: 2, + }; + 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, + () => {}, + () => authRuntimeStatus, + () => authRuntimeStatus, + () => {}, + () => {}, + () => true, + ); + return { + db, + dispatch, + cleanup: () => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + describe("discord scheduler pacing", () => { const connectedStatus = { platform: "discord" as const, @@ -319,6 +384,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..8923b4f9 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -3,6 +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 } 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 +36,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 +130,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 +5022,7 @@ function stopActiveAuthSessionsForIntegration( } } -async function dispatchRequest( +export async function dispatchRequest( db: ReturnType, request: DaemonRequest, activeAuthSessions: Map, @@ -5303,6 +5305,101 @@ 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, + 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: executed.requiresProjectionRebuild ? rebuildProjectedState(db) : null, + }, + }; + } + case "actions-run-approved": + return { + id: request.id, + ok: true, + result: db.runApprovedActions({ + limit: request.limit, + executedBy: request.executedBy, + afterAction: (executed) => { + if (executed.requiresProjectionRebuild) { + rebuildProjectedState(db); + } + }, + }), + }; case "integrations-list": { const integrationAuthService = await getIntegrationAuthService(); return { @@ -5530,25 +5627,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..49b3a8f9 100644 --- a/src/runtime/ipc.ts +++ b/src/runtime/ipc.ts @@ -12,6 +12,26 @@ 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; + 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 +71,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..20fa423f 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,7 +138,7 @@ describe("normalized raw event registry", () => { ); }); - it("canonicalizes legacy normalized schemas during projection normalization", () => { + it("upcasts legacy stored schemas during projection normalization", () => { const normalized = normalizeStoredRawEventForProjection( { entityKind: "message", 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); 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.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/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..f78337ec 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,8 +267,8 @@ describe("updater service", () => { dbBackupPath: "/tmp/backup/local.db", targetVersion: "0.2.0", releaseUrl: null, - migrateLegacyLaunchAgent: false, stagingRoot: "/tmp/staging", + migrateLegacyLaunchAgent: false, }); expect(script).toContain("HELPER_CLI_PATH="); 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), + }; + } +}