import { confirm, log } from "@clack/prompts" import { defineCommand } from "../lib/command" import { globalOptions, globalOptionsSchema } from "../lib/global-options" import fs from "node:fs/promises" import path from "node:path" import { z } from "zod" import { clearExecutionData, DEFAULT_EXECUTION_DATA_SINCE, EXECUTION_DATA_SCHEMA_SQL, exportExecutionData, followExecutionData, getExecutionDataStatus, parseExecutionDataSince, syncExecutionData, } from "../lib/execution-data" import { accent, dimmed, renderTable } from "../lib/io" import { clearSpinner, startSpinner } from "../lib/spinner" import { output, safelyPrompt, sessionIntro, requireSession, useProjectScope, } from "../utils" const SCOPE_ARGS = { org: { type: "string" as const, short: "o", }, project: { type: "string" as const, short: "p", }, since: { type: "string" as const, default: DEFAULT_EXECUTION_DATA_SINCE, }, } const SCOPE_DESCRIPTIONS = { org: "Limit project selection to an organization ID, name, or slug", project: "Select a project ID or name", since: "History start as an ISO timestamp, lookback like 7d, or all", } const SCOPE_SCHEMA = globalOptionsSchema.extend({ org: z.string().optional(), project: z.string().optional(), since: z.string().default(DEFAULT_EXECUTION_DATA_SINCE), }) const CLEAR_SCHEMA = globalOptionsSchema.extend({ yes: z.boolean().default(false), }) const EXPORT_SCHEMA = globalOptionsSchema.extend({ output: z.string().optional(), }) const SCHEMA_SCHEMA = globalOptionsSchema.extend({ sql: z.boolean().default(false), }) const DATA_SYNC_COMMAND = defineCommand({ name: "sync", description: "Sync authorized execution data into local PostgreSQL", options: { ...globalOptions, ...SCOPE_ARGS, }, optionDescriptions: { ...SCOPE_DESCRIPTIONS, }, schema: globalOptionsSchema.extend(SCOPE_SCHEMA.shape), run: syncData, }) const DATA_FOLLOW_COMMAND = defineCommand({ name: "follow", description: "Keep local execution data synchronized", options: { ...globalOptions, ...SCOPE_ARGS, }, optionDescriptions: { ...SCOPE_DESCRIPTIONS, }, schema: globalOptionsSchema.extend(SCOPE_SCHEMA.shape), run: followData, }) const DATA_STATUS_COMMAND = defineCommand({ name: "status", description: "Show local execution data scope and freshness", options: globalOptions, schema: globalOptionsSchema, run: showDataStatus, }) const DATA_SCHEMA_COMMAND = defineCommand({ name: "schema", description: "Describe the local execution data SQL schema", options: { ...globalOptions, sql: { type: "boolean", default: false, }, }, optionDescriptions: { sql: "Print the complete schema SQL", }, schema: globalOptionsSchema.extend(SCHEMA_SCHEMA.shape), run: showDataSchema, }) const DATA_EXPORT_COMMAND = defineCommand({ name: "export", description: "Export the complete local execution database", options: { ...globalOptions, output: { type: "string", short: "o", }, }, optionDescriptions: { output: "Archive destination", }, schema: globalOptionsSchema.extend(EXPORT_SCHEMA.shape), run: exportData, }) const DATA_CLEAR_COMMAND = defineCommand({ name: "clear", description: "Delete the active profile's local execution data", options: { ...globalOptions, yes: { type: "boolean", short: "y", default: false, }, }, optionDescriptions: { yes: "Skip confirmation prompt", }, schema: globalOptionsSchema.extend(CLEAR_SCHEMA.shape), run: clearData, }) export const dataCommand = defineCommand({ name: "data", description: "Manage the local execution data plane", options: globalOptions, subcommands: [ DATA_CLEAR_COMMAND, DATA_EXPORT_COMMAND, DATA_FOLLOW_COMMAND, DATA_SCHEMA_COMMAND, DATA_STATUS_COMMAND, DATA_SYNC_COMMAND, ], }) /** * Synchronizes the selected execution-data scope once. * * @param opts - Scope selectors and history start. */ async function syncData(opts: z.infer) { const session = await requireSession("sync execution data") output.normal(() => sessionIntro(session)) startSpinner("Synchronizing execution data") const status = await syncExecutionData({ ...(await resolveScope(opts)), since: parseExecutionDataSince(opts.since), }) clearSpinner() output .normal(() => { if (status.recovery) { log.success( "Rebuilt corrupted local execution data. Production execution data was not changed.", ) } log.success( `Synchronized ${accent(String(status.resources.contexts.rows))} execution contexts`, ) log.info(`Local database: ${dimmed(status.databasePath)}`) }) .json(status) } /** * Follows live Electric updates for the selected execution-data scope. * * @param opts - Scope selectors and history start. */ async function followData(opts: z.infer) { const session = await requireSession("follow execution data") output.normal(() => sessionIntro(session)) const scope = await resolveScope(opts) const abortController = new AbortController() const stopFollowing = () => abortController.abort() process.once("SIGINT", stopFollowing) startSpinner("Synchronizing execution data") let ready = false try { await followExecutionData( { ...scope, since: parseExecutionDataSince(opts.since), }, abortController.signal, async () => { if (!ready) { ready = true clearSpinner() output .normal(() => log.success("Following execution data. Press Ctrl-C to stop."), ) .json({ following: true }) } }, ) } finally { process.off("SIGINT", stopFollowing) clearSpinner() } output.normal(() => log.info("Stopped following execution data.")) } /** Prints freshness, scope, storage path, and synchronized row counts. */ async function showDataStatus() { const status = await getExecutionDataStatus() output .normal(() => { log.message( renderTable<[string, number]>({ columns: [ { header: "Resource", cell: ([resource]) => resource }, { header: "Rows", cell: ([, count]) => count }, ], rows: Object.entries(status.resources).map( ([resource, state]) => [resource, state.rows] as [string, number], ), }), ) log.info(`Synced: ${new Date(status.syncedAt).toLocaleString()}`) log.info(`Database: ${dimmed(status.databasePath)}`) }) .json(status) } /** * Describes the queryable schemas or prints their initialization SQL. * * @param opts - Whether to include complete SQL. */ async function showDataSchema(opts: z.infer) { const schema = { payloads: "payload, output, key, data, value, and fields are extended JSON projections; *_encoded columns retain exact codec bytes", schema: "automate", tables: [ "projects", "automations", "contexts", "context_parents", "event_links", "events", "actions", "signal_invocations", "signal_coordinators", "signal_offers", "signal_decisions", "logs", "outputs", "search_documents", ], views: [ "runs", "context_ancestors", "context_descendants", "signal_outcomes", ], } output .normal(() => { if (opts.sql) log.message(EXECUTION_DATA_SCHEMA_SQL.trim()) else { log.message( renderTable({ columns: [ { header: "Schema", cell: (row) => row.schema }, { header: "Object", cell: (row) => row.object }, { header: "Kind", cell: (row) => row.kind }, ], rows: [ ...schema.tables.map((object) => ({ kind: "table", object, schema: "automate", })), ...schema.views.map((object) => ({ kind: "view", object, schema: "automate", })), ], }), ) log.info(schema.payloads) } }) .json({ ...schema, ...(opts.sql && { sql: EXECUTION_DATA_SCHEMA_SQL }) }) } /** * Resolves explicit scope selectors or defaults to one inferred project. * * @param opts - Organization and project selectors to resolve. */ async function resolveScope(opts: z.infer) { const { organization, project } = await useProjectScope(opts) return { organizationId: organization.id, projectId: project.id, } } /** * Exports the active profile's complete PGlite data directory. * * @param opts - Optional archive destination. */ async function exportData(opts: z.infer) { const outputPath = path.resolve( opts.output ?? `automate-execution-data-${new Date().toISOString().slice(0, 10)}.tar.gz`, ) await fs.mkdir(path.dirname(outputPath), { recursive: true }) startSpinner("Exporting execution data") // Preserve the export metadata until the spinner has been cleared. const result = await exportExecutionData(outputPath) clearSpinner() output .normal(() => log.success(`Exported execution data to ${accent(outputPath)}`), ) .json(result) } /** * Confirms and clears the active profile's sensitive local data. * * @param opts - Non-interactive confirmation option. */ async function clearData(opts: z.infer) { if ( !opts.yes && !(await safelyPrompt( () => confirm({ message: "Delete the active profile's local execution database? This cannot be undone.", initialValue: false, }), "--yes", )) ) { output.json({ cancelled: true }) return } startSpinner("Clearing local execution data") // Keep the result for the shared human and JSON response branches. const result = await clearExecutionData() clearSpinner() output .normal(() => result.cleared ? log.success("Cleared local execution data.") : log.warn("No local execution data to clear."), ) .json(result) }