@frontera-sdk/cli 1.52.12 → 1.52.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.14",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@frontera-sdk/functions": "1.52.
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
42
|
+
"@frontera-sdk/functions": "1.52.14",
|
|
43
|
+
"@frontera-sdk/core": "1.52.14",
|
|
44
44
|
"ai": "^6.0.116",
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.52.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.52.14",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"frontera/automation/manifest.ts": "import { CronExpressionParser } from 'cron-parser'\nimport { MAX_INPUT_BYTES, checkInputFieldSpec, isFileInputType } from './inputs'\n\nconst SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'\nconst NAME_RE = new RegExp(`^${SEGMENT}$`)\n\n/**\n * Runtime gate for a grant: `<namespace>:<name>[:<action>]`, every segment\n * sharing NAME_RE's grammar so the whole vocabulary is consistent.\n *\n * WIDER than the `Grant` union on namespaces — a server must not reject\n * `notify:email` merely because this build predates it. NARROWER than the\n * union's `agent:${string}:run` arm on the slug, which admits `agent::run` and\n * `agent:AGENT:run`; both are rejected here. No legitimate slug is affected —\n * this repo's agent slugs are already lowercase-kebab.\n */\nconst GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)\n\n/**\n * `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is\n * lowercase-kebab and neither value is.\n *\n * A host contains DOTS (`api.stripe.com`); a secret name is conventionally\n * SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both\n * realistic forms — found by deploying an automation that used them.\n *\n * Deliberately not solved by widening SEGMENT: that governs agent slugs too, and\n * loosening it there would admit `agent:AGENT:run`, which the comment above says\n * is rejected on purpose.\n */\nconst HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/\n// Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE\n// permissive here would let a manifest declare `secret:myKey`, validate cleanly,\n// and then never be satisfiable — no such secret can be created. A validator that\n// accepts the unsatisfiable is worse than one that is strict.\nconst SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/\n// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.\n// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept\n// `governed:Approve_Invoice`, validate cleanly, and name an Action that can\n// never exist — no published Action carries that apiName, so the grant is\n// unsatisfiable and the automation fails at its first submit instead of at\n// deploy.\nconst ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/\n\n// `plugin:<install>:<capability>`. The service does NOT validate\n// `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an\n// admin can name an install \"My CRM\" and it works fine everywhere except\n// here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog\n// default is kebab) is what makes an install's name usable from a manifest;\n// one outside it has to be renamed before an automation can grant it. The\n// capability half is deliberately wider: MCP tool names and spec capability\n// names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No\n// wildcard in either half — the manifest is the reviewable list of what the\n// automation can reach, same as `http:`.\nconst PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/\n\n/** Namespaces whose value is not a SEGMENT. */\nconst TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {\n http: { re: HOST_RE, hint: 'a hostname, e.g. \"http:api.stripe.com\" (no scheme, no path, no wildcard)' },\n secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. \"secret:STRIPE_KEY\"' },\n governed: {\n re: ACTION_API_NAME_RE,\n hint: 'one published Action apiName, e.g. \"governed:approveInvoice\" (camelCase, no wildcard)',\n },\n plugin: {\n re: PLUGIN_GRANT_RE,\n hint:\n '<install>:<capability>, e.g. \"plugin:crm:create_ticket\" — the install name from '\n + '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'\n + ' — rename the install if its name has capitals or spaces',\n },\n}\n\n/**\n * Shortest description an agent-callable automation may carry.\n *\n * Not a round number picked for looks: it is about the length of one honest\n * clause (\"Reconcile open invoices against the settlement file\"), and it is\n * chosen to be long enough that the slug restated as a sentence — \"reconcile\n * invoices\" — does not clear it. A description that only repeats the name\n * tells a model nothing it did not already have from the tool name.\n */\nexport const AGENT_DESCRIPTION_MIN_CHARS = 24\n\nconst KNOWN_KEYS = new Set([\n 'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',\n])\n\nexport interface ValidationResult {\n valid: boolean\n errors: string[]\n /**\n * Non-fatal. An unknown manifest key lands here rather than in `errors`:\n * a newer SDK must be able to add a field without an older service refusing\n * the deploy. The CLI prints these, so a typo like `concurrancy: 100` — which\n * would otherwise deploy \"successfully\" with the default of 1 — is caught at\n * author time, where the SDK and the manifest are the same version.\n */\n warnings: string[]\n}\n\n/**\n * Takes `unknown`, on purpose.\n *\n * The authoritative call site is the service, validating a manifest that\n * arrived over HTTP — untrusted, and not yet known to have any shape. Typing\n * the parameter as `AutomationManifest` would force every honest caller to\n * launder untrusted input through a cast, which is how a validator ends up\n * trusting the thing it exists to check.\n *\n * The regexes here are deliberately wider than the `Grant` union in `types.ts`:\n * that union is an author-time affordance, this is a runtime gate, and a server\n * must not reject a grant merely because this build predates it.\n */\nexport function validateManifest(input: unknown): ValidationResult {\n const errors: string[] = []\n const warnings: string[] = []\n const m = (input ?? {}) as {\n name?: unknown\n trigger?: unknown\n grants?: unknown\n inputs?: unknown\n concurrency?: unknown\n retries?: unknown\n description?: unknown\n }\n\n if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {\n errors.push('name must be lowercase kebab-case')\n } else if (m.name.length > 64) {\n errors.push('name must be 64 characters or fewer')\n }\n\n const trigger = m.trigger as\n | { cron?: string; manual?: boolean; agent?: boolean }\n | undefined\n if (\n !trigger\n || (trigger.cron === undefined && trigger.manual !== true && trigger.agent !== true)\n ) {\n errors.push('trigger must be { cron }, { manual: true }, or { agent: true }')\n } else if (trigger.agent !== undefined && trigger.agent !== true) {\n // Not folded into the arm above: `{ manual: true, agent: false }` is a\n // legal-looking manifest that means nothing. `agent` is a permission, and\n // the way to withhold a permission is to omit it, not to write it false —\n // the same rule the grant list follows.\n errors.push(\n 'trigger.agent must be true when present — omit the key to mean \"not agent-callable\"',\n )\n }\n // Separate `if`, not the old `else if`: with three arms the cron check has to\n // run whenever a cron is present, including on `{ cron, agent: true }`, and\n // an `else if` chained off the acceptance test above would skip it there.\n if (trigger?.cron !== undefined) {\n if (typeof trigger.cron !== 'string') {\n errors.push('invalid cron expression: must be a string')\n } else {\n // Both field-count branches exist because `cron-parser` accepts an\n // off-count expression rather than throwing, so neither case would ever\n // reach the `catch` below:\n // `* * * * * *` -> reads field 1 as SECONDS and fires sub-minute.\n // `0 7 * *` -> left-pads, scheduling something the author never wrote.\n // Only an exactly-5-field expression means what it looks like it means.\n const fields = trigger.cron.trim().split(/\\s+/).length\n if (fields !== 5) {\n // One message shape for one class of fault. Splitting it meant a\n // 7-field expression was told it was \"sub-minute\" — a diagnosis\n // asserted rather than derived — while a 4-field one got no diagnosis\n // at all.\n errors.push(\n `invalid cron expression: expected 5 fields, got ${fields}` +\n (fields > 5 ? '; sub-minute schedules are not supported' : ''),\n )\n } else {\n try {\n CronExpressionParser.parse(trigger.cron, { tz: 'UTC' })\n } catch (err) {\n errors.push(`invalid cron expression: ${(err as Error).message}`)\n }\n }\n }\n }\n\n if (m.grants !== undefined && !Array.isArray(m.grants)) {\n errors.push('grants must be an array')\n } else {\n for (const g of (m.grants as unknown[]) ?? []) {\n // String(g), not `${g}` — a template literal THROWS on a symbol, and a\n // validator that exists to absorb hostile input must not have a throwing\n // path. The message names the fix, not just the verdict.\n if (typeof g !== 'string') {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n continue\n }\n const colon = g.indexOf(':')\n const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined\n if (typed) {\n // A typed namespace validates its OWN value grammar. `http:` and\n // `secret:` carry hosts and secret names, neither of which is a SEGMENT.\n if (!typed.re.test(g.slice(colon + 1))) {\n errors.push(`malformed grant \"${g}\" — the part after the colon must be ${typed.hint}`)\n }\n continue\n }\n if (!GRANT_RE.test(g)) {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n }\n }\n }\n\n const c = m.concurrency\n if (c !== undefined && (!Number.isInteger(c) || (c as number) < 1 || (c as number) > 50)) {\n errors.push('concurrency must be an integer between 1 and 50')\n }\n\n // Capped at 5. Above that it is not a retry policy, it is a loop — and every\n // attempt re-runs whatever side effects the previous one already performed.\n const r = m.retries\n if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {\n errors.push('retries must be an integer between 0 and 5')\n }\n\n const inputs = m.inputs\n if (inputs !== undefined) {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {\n errors.push('inputs must be an object of { name: { type, … } }')\n } else {\n // Per-field rules (name shape, type, required/default shape, enum) live\n // in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the\n // two can never drift on what \"a well-formed input field\" means.\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const field = checkInputFieldSpec(key, raw)\n errors.push(...field.errors)\n warnings.push(...field.warnings)\n }\n // A cron fire has no one to prompt: every required field must be\n // satisfiable from defaults, or the schedule would fail on every tick.\n const trig = m.trigger as { cron?: unknown } | undefined\n if (typeof trig?.cron === 'string') {\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { type?: unknown; required?: unknown; default?: unknown }\n if (spec?.required === true && spec.default === undefined) {\n // A `file` or `files` input can never carry a default (refused\n // above), so the \"add a default\" remedy would send the author\n // straight into the next validation error — name the two remedies\n // that actually work.\n const remedy = isFileInputType(spec.type)\n ? 'A file input cannot have a default — make the input optional or the trigger manual.'\n : 'Add a default or make the trigger manual.'\n errors.push(\n `input \"${key}\" is required with no default, and the trigger is a cron — `\n + `cron has nobody to ask. ${remedy}`,\n )\n }\n }\n }\n // A run's input is capped at MAX_INPUT_BYTES when it starts. If the\n // declared defaults alone already exceed that, a cron fire (or a bare\n // Run-now) fails inside run-open before any run row exists — a silent\n // death only visible in runner logs. Catch it here, the one place the\n // author is still looking at the file.\n const defaultsOnly: Record<string, unknown> = {}\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { default?: unknown }\n if (spec && typeof spec === 'object' && spec.default !== undefined) {\n defaultsOnly[key] = spec.default\n }\n }\n try {\n const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length\n if (bytes > MAX_INPUT_BYTES) {\n errors.push(\n `input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `\n + 'run-input cap, so every run would fail at start. Slim the defaults.',\n )\n }\n } catch {\n // A default that JSON.stringify chokes on (circular, throwing toJSON)\n // is practically unreachable — the manifest itself must serialize to\n // deploy at all — but the size check must never be the thing that throws.\n }\n }\n }\n\n // `trigger: { agent: true }` turns this manifest into the source of a tool\n // definition a language model reads and decides from. Two fields that are\n // courtesies everywhere else become load-bearing here, so they are errors\n // rather than warnings: a model handed an undescribed tool, or an undescribed\n // argument, does not fail loudly — it guesses, and the guess starts a real\n // run against real systems. Checked at deploy, where the author still has the\n // file open, rather than at bind time in a Console someone else is using.\n if (trigger?.agent === true) {\n const description = m.description\n if (typeof description !== 'string' || description.trim().length < AGENT_DESCRIPTION_MIN_CHARS) {\n errors.push(\n `trigger { agent: true } requires a description of at least ${AGENT_DESCRIPTION_MIN_CHARS} `\n + 'characters — it becomes the tool description an agent reads before calling this '\n + 'automation.',\n )\n }\n const agentInputs = m.inputs\n if (agentInputs && typeof agentInputs === 'object' && !Array.isArray(agentInputs)) {\n for (const [key, raw] of Object.entries(agentInputs as Record<string, unknown>)) {\n const spec = raw as { description?: unknown; redact?: unknown } | null\n if (typeof spec?.description !== 'string' || spec.description.trim().length === 0) {\n errors.push(\n `input \"${key}\" needs a description: trigger { agent: true } publishes every input as `\n + 'a tool argument, and an agent cannot fill an argument it has no description for.',\n )\n }\n // An error, not a warning, and refused here where the author still has\n // the file open. On the agent path the MODEL produces this value as\n // tool-call arguments: it is in the conversation and in that\n // conversation's trace before a run row exists to mask. Masking the run\n // row would advertise a guarantee this path cannot keep.\n if (spec?.redact === true) {\n errors.push(\n `input \"${key}\": redact cannot be used with trigger { agent: true } — the agent `\n + 'supplies this value as a tool argument, so it is already in the conversation and '\n + 'its trace before the run exists.',\n )\n }\n }\n }\n }\n\n if (input && typeof input === 'object' && !Array.isArray(input)) {\n for (const key of Object.keys(input)) {\n if (!KNOWN_KEYS.has(key)) {\n warnings.push(`unknown manifest key \"${key}\" — ignored`)\n }\n }\n }\n\n return { valid: errors.length === 0, errors, warnings }\n}\n",
|
|
29
29
|
"frontera/automation/runtime-context.ts": "/**\n * The REAL `ctx` a handler receives — the one that talks to the service.\n *\n * It lives in the SDK rather than in the runner because it now has two\n * consumers: the deployed runner executing a bundle, and the CLI's dev worker\n * executing a file on a developer's machine. One implementation means a dev run\n * and a production run cannot drift in what they enforce or how they word a\n * refusal, which is the whole reason a dev loop is worth trusting.\n *\n * NOT re-exported from `index.ts`, and NOT in `AUTOMATION_SDK_FILES`: a\n * scaffolded project vendors the authoring surface, and this file reaches the\n * network. Authors get `createTestContext`; the two runtimes get this.\n */\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport { createHash } from 'node:crypto'\n// Wording lives in the SDK, not here: the runner refusing a call before it makes\n// it, the service's own 403, and `createTestContext` on the author's machine all\n// have to say the same sentence — a test that fails in different words than\n// production teaches the wrong lesson. Re-exported below because this module is\n// where the runner's code and tests have always reached for them.\nimport {\n agentReplayMismatchMessage,\n agentTimeoutMessage,\n duplicateStepMessage as duplicateStepMessageText,\n missingGrantMessage as missingGrantMessageText,\n submitOutsideStepMessage as submitOutsideStepMessageText,\n lostStepRowMessage as lostStepRowMessageText,\n duplicateSubmissionMessage as duplicateSubmissionMessageText,\n emptySubmissionKeyMessage as emptySubmissionKeyMessageText,\n fileHandleInStepMessage,\n signedUrlRemovedMessage,\n} from './messages'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n ConversationTranscript,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\nconst SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'\n\n/**\n * How much of a summary one step row may carry.\n *\n * A step row is telemetry, not storage: it is read by a human scanning a run,\n * and it is written on every ctx call of every run. So what it records about a\n * call is bounded rather than complete, and a value that does not fit is\n * reported as a SIZE — which tells the reader the value existed and was large,\n * instead of showing them the empty panel this cap exists to avoid.\n */\nconst DETAIL_MAX_CHARS = 4_000\n/** How much of an agent's answer a row keeps, so a trace can be read without\n * re-running the agent. */\nconst PREVIEW_MAX_CHARS = 500\n\n/** JSON length of a value, or null when it does not serialize (a cycle, a BigInt). */\nfunction jsonSize(value: unknown): number | null {\n try {\n const json = JSON.stringify(value)\n return json === undefined ? null : json.length\n } catch {\n return null\n }\n}\n\n/**\n * A NUL and an unpaired surrogate, which Postgres `jsonb` REFUSES rather than\n * escapes: `\\u0000 cannot be converted to text`, and `Unicode low surrogate\n * must follow a high surrogate`. Both verified against the repo's own Postgres.\n */\nconst NUL = /\\u0000/g\nconst UNPAIRED_SURROGATE = /[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/g\n\n/**\n * Make a value storable, by removing the two byte classes `jsonb` rejects.\n *\n * Both are reachable from what a row now carries: `preview` cuts at a fixed\n * offset and can split an emoji in half, and an author's step result may hold\n * text pulled out of a PDF or a Postgres `text` column, where a NUL is routine.\n *\n * What a refusal costs is not the detail. A rejected insert loses the whole\n * ROW — `recordStep` swallows a non-2xx by contract — and a rejected completion\n * leaves an author's step reading `running` forever inside a run that finished,\n * because the update is guarded on that status. Either is a worse trace than\n * the empty panel this file set out to fix, so the scrub sits at the write\n * rather than in each summarizer: one rule covering summaries, error messages\n * and `ctx.log`'s author-supplied data alike.\n */\nfunction jsonbSafeText(text: string): string {\n return text.replace(NUL, '').replace(UNPAIRED_SURROGATE, '')\n}\n\nfunction jsonbSafe(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {\n if (typeof value === 'string') return jsonbSafeText(value)\n if (value === null || typeof value !== 'object') return value\n // A cycle cannot be serialized at all: dropped here, named, rather than left\n // to throw inside the fetch that was carrying an otherwise-good row.\n if (seen.has(value)) return undefined\n // The ANCESTOR stack, not every node ever visited — unmarked on the way out.\n // A node marked for good cannot tell a cycle from an ordinary shared\n // reference, which `JSON.stringify` handles fine: `{ before: row, after: row }`\n // lost `after`, and `[row, row]` became `[null, null]`. That is silent data\n // loss in the very detail this file exists to make readable.\n seen.add(value)\n const safe = Array.isArray(value)\n ? value.map((item) => jsonbSafe(item, seen))\n : Object.fromEntries(\n Object.entries(value).map(([key, item]) => [jsonbSafeText(key), jsonbSafe(item, seen)]),\n )\n seen.delete(value)\n return safe\n}\n\n/**\n * `{ [key]: value }`, but only while the value stays small.\n *\n * Per key rather than over the whole detail so one enormous filter cannot cost\n * the counts standing next to it — dropping everything would leave the row\n * exactly as empty as it was before any of this existed.\n */\nfunction ifSmall(key: string, value: unknown, max: number): Record<string, unknown> {\n if (value === undefined) return {}\n const size = jsonSize(value)\n return size !== null && size <= max ? { [key]: value } : {}\n}\n\n/** The head of a text answer, marked when cut. */\nfunction preview(text: string): string {\n return text.length <= PREVIEW_MAX_CHARS ? text : `${text.slice(0, PREVIEW_MAX_CHARS)}\\u2026`\n}\n\n/**\n * Run a summarizer for a row's `detail`, defending the run from it.\n *\n * Never throws and never grows without bound. A summary is a record OF the\n * work by the same rule `recordStep` follows, so a summarizer that trips over\n * a shape it did not expect — a dry dev run answering null, a service that\n * grew a field — costs the detail, never the call it describes.\n */\nfunction summarize<T>(\n build: ((out: T) => Record<string, unknown>) | undefined,\n out: T,\n): Record<string, unknown> | undefined {\n if (!build) return undefined\n let detail: Record<string, unknown>\n try {\n detail = build(out)\n } catch {\n // Named rather than omitted: an omitted detail renders as \"This step\n // recorded no detail\", which is the sentence this whole path exists to\n // stop — and it would hide a broken summarizer behind a fixed bug's\n // symptom.\n return { omitted: 'summary unavailable' }\n }\n const size = jsonSize(detail)\n if (size === null) return { omitted: 'summary not serializable' }\n return size <= DETAIL_MAX_CHARS ? detail : { omitted: 'summary too large', chars: size }\n}\n\n/**\n * Every transcript `ctx.conversation.transcript()` has handed out, its `turns`\n * array, and each of its turns.\n *\n * A step's return value is copied into its trace row, and the trace is exactly\n * where a person's own words must not be kept. Branding the objects — not\n * inspecting their shape — is what makes the check exact: a value that merely\n * looks like a transcript is the author's own data and stays readable. Each\n * turn is branded too, so `turns.filter(…)`, `turns.slice()` or one picked\n * turn is still recognized. Held weakly, so nothing here keeps a transcript\n * alive.\n */\nconst handedOut = new WeakMap<object, 'transcript' | 'turns' | 'turn'>()\n\nfunction brandTranscript(transcript: ConversationTranscript | null): ConversationTranscript | null {\n if (transcript) {\n handedOut.set(transcript, 'transcript')\n if (Array.isArray(transcript.turns)) {\n handedOut.set(transcript.turns, 'turns')\n for (const turn of transcript.turns) {\n if (typeof turn === 'object' && turn !== null) handedOut.set(turn, 'turn')\n }\n }\n }\n return transcript\n}\n\n/** How far a step result is searched for something ctx handed out: a transcript, or a file handle. */\nconst RESULT_SEARCH_DEPTH = 4\nconst RESULT_SEARCH_NODES = 200\n\n/**\n * How many handed-out turns a step result carries, or null when it carries\n * none. Searched through arrays and plain objects up to four levels down and\n * at most 200 values — `{ data: { transcript } }` and `{ request: turns.at(-2) }`\n * are as much a leak as the transcript itself — so a large result pays a fixed\n * cost. Text copied OUT of a turn is a plain string and is not recognized.\n *\n * Fail-closed: a result too big or too deep to search completely is reported\n * as not inspected, and the step records that instead of the result.\n */\nfunction transcriptTurnsIn(\n out: unknown,\n): { kind: 'found'; turnCount: number } | { kind: 'not-inspected' } | { kind: 'clean' } {\n const turns = new Set<object>()\n let found = false\n let complete = true\n const queue: Array<{ value: object; depth: number }> = []\n if (typeof out === 'object' && out !== null) queue.push({ value: out, depth: 0 })\n // `queue` only grows up to the node budget, so the whole search allocates a\n // bounded amount however large the result is: children are walked lazily\n // and the walk stops at the first child that would not fit.\n for (let next = 0; next < queue.length; next++) {\n const { value, depth } = queue[next]!\n const brand = handedOut.get(value)\n if (brand) {\n found = true\n const held = brand === 'turn' ? [value] : brand === 'turns' ? (value as unknown[]) : (value as ConversationTranscript).turns\n for (const turn of held) if (typeof turn === 'object' && turn !== null) turns.add(turn)\n continue\n }\n // Any object is searched, not only plain ones: a class instance serializes\n // its own fields, and a turn could be one of them.\n for (const child of objectChildren(value)) {\n if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) {\n // Something here is left unsearched. The nodes already queued are\n // still visited, so a transcript among them is still counted.\n complete = false\n break\n }\n queue.push({ value: child, depth: depth + 1 })\n }\n }\n if (found) return { kind: 'found', turnCount: turns.size }\n return complete ? { kind: 'clean' } : { kind: 'not-inspected' }\n}\n\n/**\n * Every handle `ctx.file` returned. Branded, like a transcript, so the check\n * below is exact: the author's own `{ fileId, bytes }` is not a handle.\n */\nconst fileHandles = new WeakSet<object>()\n\n/**\n * Finish a handle `ctx.file` returns: brand it, and make the `signedUrl` it no\n * longer has say where it went. Exported for the test context, which builds\n * its own handles and must behave the same.\n *\n * The getter is non-enumerable, so a step result, a trace summary or\n * `JSON.stringify` never reads it; only code that asks for `signedUrl` does.\n */\nexport function asFileHandle<T extends object>(handle: T): T {\n fileHandles.add(handle)\n Object.defineProperty(handle, 'signedUrl', {\n enumerable: false,\n get: () => {\n throw new Error(signedUrlRemovedMessage())\n },\n })\n return handle\n}\n\n/**\n * Whether a step result carries a `ctx.file` handle, within the same bounds as\n * `transcriptTurnsIn`.\n *\n * A step's result is kept as JSON and replayed, and JSON cannot hold a\n * function: the handle comes back with its details and without `text()`,\n * `bytes()` or `stream()`, so the run fails one execution later with \"text is\n * not a function\". Caught here, it fails at the step that caused it.\n */\nexport function carriesFileHandle(out: unknown): boolean {\n if (typeof out !== 'object' || out === null) return false\n const queue: Array<{ value: object; depth: number }> = [{ value: out, depth: 0 }]\n for (let next = 0; next < queue.length; next++) {\n const { value, depth } = queue[next]!\n if (fileHandles.has(value)) return true\n for (const child of objectChildren(value)) {\n if (depth >= RESULT_SEARCH_DEPTH || queue.length >= RESULT_SEARCH_NODES) break\n queue.push({ value: child, depth: depth + 1 })\n }\n }\n return false\n}\n\n/** The object-valued children of an array or object, one at a time. */\nfunction* objectChildren(value: object): Generator<object> {\n if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n const child: unknown = value[i]\n if (typeof child === 'object' && child !== null) yield child\n }\n return\n }\n for (const key in value) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) continue\n const child: unknown = (value as Record<string, unknown>)[key]\n if (typeof child === 'object' && child !== null) yield child\n }\n}\n\n/**\n * What an author-declared step records about its own return value.\n *\n * The value is already JSON — the platform stores and replays it — so keeping a\n * copy asks nothing new of it, and it is what makes the row readable at all:\n * without it a step shows a name and a duration for work whose result nobody\n * can see. Bounded, and reported as a size when it does not fit, because a step\n * that returns a thousand warehouse rows must not write them a second time into\n * the audit trail.\n */\nfunction stepResultDetail(out: unknown, transcriptHandedOut = false): Record<string, unknown> | undefined {\n if (out === undefined) return undefined\n // Searched only once this execution has handed out a transcript: a run that\n // never read one cannot return one, and its steps keep recording exactly\n // what they always did — including results too large or deep to search.\n if (transcriptHandedOut) {\n const search = transcriptTurnsIn(out)\n if (search.kind === 'found') return { omitted: 'conversation transcript', turnCount: search.turnCount }\n if (search.kind === 'not-inspected') return { omitted: 'result not inspected' }\n }\n const size = jsonSize(out)\n if (size === null) return { omitted: 'result not serializable' }\n return size <= DETAIL_MAX_CHARS\n ? { result: out }\n : { resultChars: size, omitted: 'result too large' }\n}\n\n/**\n * The step tools this module needs, declared structurally rather than imported\n * from `inngest`.\n *\n * Structural because it keeps the whole file testable with a two-line stub, and\n * because it states exactly what `ctx` depends on — one method — instead of the\n * platform's entire step surface. `function-builder.ts` passes the real object\n * straight in, so the compiler still checks the two agree.\n */\nexport interface StepTools {\n /**\n * Returns `unknown`, deliberately, and not the body's own type.\n *\n * What comes back is not the value the body returned but its JSON round trip:\n * the platform stores a step's result and replays it on the next execution, so\n * a `Date` returns as a string and a class instance as a plain object. Typing\n * this as `Promise<T>` here would erase that at exactly the boundary where it\n * happens. `ctx.step.run` narrows it once, at the seam, with the same\n * reasoning `ctx.blueprint.query` narrows a warehouse row.\n */\n run<T>(id: string, fn: () => Promise<T>): Promise<unknown>\n /** Absent on hosts that predate it — the runtime falls back to an inline wait. */\n sleep?(id: string, ms: number): Promise<void>\n /**\n * Park the run until a matching event arrives or `timeout` passes, holding no\n * connection. Resolves to the event, or null on timeout.\n *\n * Absent on hosts that cannot resume a run (the CLI's dev worker): `ctx.agent`\n * then waits on one request, exactly as it did before durable waits existed.\n */\n waitForEvent?(id: string, opts: { event: string; timeout: string; if: string }): Promise<unknown>\n}\n\n/** What `ctx.agent` waits when the author names no `timeoutMs`; the service's default. */\nconst AGENT_DEFAULT_TIMEOUT_MS = 120_000\n\n/**\n * One durable wait's length before the loop reads the turn again.\n *\n * A wait only matches events sent AFTER it is registered, and a turn can\n * settle between two reads — so the loop never parks for the whole deadline on\n * one wait. A missed wake costs at most one slice, not the call.\n */\nconst AGENT_WAIT_SLICE_MS = 60_000\n\n/**\n * How long past the author's wait the loop keeps reading before it gives up\n * on its own.\n *\n * The service settles the turn AT the deadline, with the same timeout message,\n * and that is the answer the loop normally reads. This margin only matters when\n * the process watching the turn died: the service's minute-by-minute sweep then\n * settles it within about ninety seconds. Past that, the loop reports the\n * timeout itself rather than wait for an answer that is not coming.\n */\nconst AGENT_SETTLE_GRACE_MS = 120_000\n\n/** The event the service sends once when a durable agent turn settles. */\nconst AGENT_TURN_FINISHED_EVENT = 'agent/turn.finished'\n\n/**\n * Asks `/ctx/agent-run` to keep the held request alive: a 200 at once, a space\n * every few seconds while the turn runs, then one JSON envelope —\n * `{ ok: true, data }` or `{ ok: false, status, message }`. Without it the\n * request is silent, and an idle limit on the way (the server's 255 s, a\n * proxy's) drops it however long `timeoutMs` is. A service that predates the header ignores it and\n * answers plainly; both shapes are read below.\n */\nconst KEEPALIVE_HEADER = 'x-ctx-keepalive'\n\n/**\n * Every header this module sends to the service on a Function's own authority.\n *\n * The runner's forwarder passes on exactly these and drops the rest, so this\n * list is the contract between the two, owned here. The request types below\n * accept no other name: a header added to a call without adding it here fails\n * to compile, instead of being dropped by the forwarder in deployed runs only.\n */\nexport const FUNCTION_SERVICE_HEADERS = [\n 'content-type',\n 'x-automation-run-token',\n 'x-workspace-id',\n KEEPALIVE_HEADER,\n] as const\n\n/**\n * Sent only by a process holding the runner's secret (the runner itself, on\n * its own writes). A Function process never has it, so it is not a Function\n * header and the forwarder never passes it on.\n */\nconst RUNNER_TOKEN_HEADER = 'x-automation-runner-token'\n\ntype ServiceCallHeaders = Partial<Record<(typeof FUNCTION_SERVICE_HEADERS)[number] | typeof RUNNER_TOKEN_HEADER, string>>\n/** A request to the service: `RequestInit` with its headers held to the names above. */\ntype ServiceCallInit = Omit<RequestInit, 'headers'> & { headers?: ServiceCallHeaders }\n\ntype AgentRunOptions = { files?: ReadonlyArray<{ fileId: string }>; timeoutMs?: number }\n\ntype AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked' | 'cancelled'\n\n/** The start step's memoized answer. `startedAt` anchors every later deadline\n * check, so it must come from inside the step, never from a replay's clock.\n * `fingerprint` and `slug` identify the call it was made for — see\n * `agentFingerprint`. Absent on a start memoized before they existed. */\ntype AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string }) & {\n fingerprint?: string\n slug?: string\n}\n\n/**\n * What makes two `ctx.agent` calls the same call: agent, prompt, files and\n * wait. A replay recomputes it and compares with the one its start step\n * memoized.\n */\nfunction agentFingerprint(slug: string, prompt: string, options: AgentRunOptions | undefined): string {\n const files = (options?.files ?? []).map((f) => f.fileId)\n return createHash('sha256')\n .update(JSON.stringify([slug, prompt, files, options?.timeoutMs ?? null]))\n .digest('hex')\n}\n\n/** One read step's memoized answer. `now` is the read's own clock, memoized with\n * it, so the loop's decisions replay identically. */\ntype AgentRead =\n | { done: true; text: string }\n | { done: true; error: string }\n | { done: false; now: number }\n\ninterface Deps {\n runId: string\n workspaceId: string\n runToken: string\n grants: string[]\n /** The platform's step tools for THIS execution. */\n step: StepTools\n /** The run's frozen input row, or absent when the manifest declares none. */\n input?: Record<string, unknown>\n /** Zero-indexed run attempt, stamped onto every row this context writes. */\n attempt?: number\n /**\n * Where the service lives, when the caller knows better than the environment.\n *\n * The deployed runner reads `SERVICE_URL` from its own env; the CLI's dev\n * worker knows it from the origin the developer logged into, and a\n * module-level const read at import time cannot be told. Overriding here keeps\n * this module usable in both processes rather than forked for one.\n */\n serviceUrl?: string\n /**\n * The deployment-wide runner secret, or absent.\n *\n * PASSED IN, never read from the environment here. This module now runs in two\n * processes, and only one of them may hold this token: the runner does, a\n * developer's laptop must not. Reading `process.env` inside shared code moves\n * that decision into an environment nobody reviews — a developer who has the\n * variable exported for any reason, a copied env file, a locally-run runner,\n * would have `automation dev` sending a workspace-wide credential from their\n * machine with nothing on screen to say so.\n *\n * As a parameter the rule is structural: the dev worker cannot send it,\n * because it has nothing to pass.\n */\n runnerToken?: string\n /**\n * The Unix socket the runner's forwarder listens on. Inside the deployed\n * runner a Function process is denied internet sockets and reaches the\n * forwarder only here, so every `ctx` call and `ctx.file` read goes over it.\n * Absent (the CLI's dev worker, tests): calls go straight to `serviceUrl`.\n */\n socketPath?: string\n}\n\n/**\n * The path under the forwarder that signed storage reads go to. Owned here and\n * imported by the runner's forwarder, which routes on it, so the two cannot\n * drift apart.\n */\nexport const FORWARDER_STORAGE_PATH = '/storage'\n\n/**\n * A signed storage URL, pointed at the forwarder's storage path under\n * `forwarderUrl`: same path and signature, forwarder host. The forwarder sends\n * it on to real storage. In a Function process the service URL IS the\n * forwarder, and its host is a placeholder; the Unix socket decides where it goes.\n */\nfunction readdressToForwarder(forwarderUrl: string, signedUrl: string): string {\n const signed = new URL(signedUrl)\n return `${forwarderUrl}${FORWARDER_STORAGE_PATH}${signed.pathname}${signed.search}`\n}\n\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\n\nexport class DuplicateStepNameError extends Error {\n constructor(readonly stepName: string) {\n super(duplicateStepMessageText(stepName))\n this.name = 'DuplicateStepNameError'\n }\n}\n\nclass GrantError extends Error {\n constructor(grant: string) {\n super(missingGrantMessageText(grant))\n this.name = 'GrantError'\n }\n}\n\nexport function buildContext(deps: Deps): AutomationContext {\n const serviceUrl = deps.serviceUrl ?? SERVICE_URL\n // Every call to the service goes through here. In the runner it rides the\n // forwarder's Unix socket (the one path left open); elsewhere it is a plain\n // fetch. `unix` is a Bun extension to RequestInit, hence the cast.\n const serviceFetch = (path: string, init?: ServiceCallInit): Promise<Response> =>\n fetch(`${serviceUrl}${path}`, deps.socketPath ? { ...init, unix: deps.socketPath } as RequestInit : init)\n const requireGrant = (grant: string) => {\n if (!deps.grants.includes(grant)) throw new GrantError(grant)\n }\n\n /**\n * Step and finish writes carry BOTH credentials, and the service takes either.\n *\n * The deployed runner has the shared secret; a dev worker on a developer's\n * laptop must never hold it, and has only the run's own token — which is the\n * stronger claim for a row that belongs to one run. Sending both means this\n * module works unchanged in either process, which is the whole reason it can\n * be reused by the CLI rather than forked.\n *\n * An empty runner token is omitted rather than sent blank: the service treats\n * a PRESENT runner header as an assertion to verify, so a blank one would be\n * a 401 instead of a fall-through to the run token.\n */\n const runnerHeaders: ServiceCallHeaders = {\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n ...(deps.runnerToken ? { [RUNNER_TOKEN_HEADER]: deps.runnerToken } : {}),\n }\n\n /**\n * Which author-declared step the code writing a row is running inside.\n *\n * Async-local rather than a plain variable because two steps can be in flight\n * at once — `Promise.all([ctx.step.run('a', …), ctx.step.run('b', …)])` is\n * legal, and a shared mutable \"current step\" would file `a`'s ctx calls under\n * `b` depending on interleaving. This is per-run, not module-global: two runs\n * in one process must never see each other's scope.\n */\n const stepScope = new AsyncLocalStorage<{\n stepId: string\n stepName: string\n /**\n * `action` + `submissionKey` for every submit this step body has made.\n *\n * The write plane CANNOT catch a repeat: two identical submissions derive\n * one key and one semantic fingerprint, so it replays the first request and\n * answers both calls with the same id — no error, one effect, a green run.\n * The check has to be local, and per step EXECUTION so that a genuine\n * resumption or retry (which re-enters the body from scratch) is unaffected.\n */\n submitted: Set<string>\n }>()\n\n /**\n * Append a row to the run's audit trail, returning the id the service gave it.\n *\n * Never throws. A step row is a record OF the work, not part of it — so a\n * service blip while recording must not turn a completed operation into a\n * failed run, and must not replace an in-flight failure with a transport\n * error on the way to reporting it. The same reasoning is why a failure\n * returns an empty id rather than propagating: losing the parent link on one\n * row is strictly better than losing the run.\n */\n const recordStep = async (body: Record<string, unknown>): Promise<string> => {\n const parentStepId = stepScope.getStore()?.stepId\n try {\n const res = await serviceFetch(`/v1/automations/runner/runs/${deps.runId}/steps`, {\n method: 'POST',\n headers: runnerHeaders,\n body: JSON.stringify({\n // Only when there IS a parent. A step whose own row failed to write\n // leaves an empty id in scope, and sending that empty string reaches\n // Postgres as `''::uuid`, which errors — so the child row would be\n // dropped too, quietly, because this whole path is non-fatal. One\n // lost step row must not cost the calls made inside it.\n ...(parentStepId ? { parentStepId } : {}),\n attempt: deps.attempt ?? 0,\n ...body,\n // Last, so it applies to whatever `body` brought — see `jsonbSafe`.\n ...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),\n // `label` and `step_name` are `text`, and a NUL is refused there too —\n // `invalid byte sequence for encoding \"UTF8\": 0x00`. `ctx.log` writes\n // the author's message as the label, so a NUL riding in from a PDF\n // kills the row through the field NEXT to the one being scrubbed. A\n // lone surrogate is not a hazard in a text column (the driver encodes\n // it as U+FFFD), but it is scrubbed with it rather than reasoned about\n // twice.\n ...(typeof body.label === 'string' ? { label: jsonbSafeText(body.label) } : {}),\n ...(typeof body.stepName === 'string'\n ? { stepName: jsonbSafeText(body.stepName) }\n : {}),\n }),\n })\n // `fetch` resolves on a 4xx/5xx, so the status is the only place a\n // rejected step surfaces at all.\n if (!res.ok) {\n console.warn(`[ctx] step record failed (non-fatal): ${res.status}`)\n return ''\n }\n return ((await res.json()) as { data?: { id?: string } }).data?.id ?? ''\n } catch (err) {\n console.warn('[ctx] step record failed (non-fatal):', (err as Error).message)\n return ''\n }\n }\n\n /** Close an author-declared step row. Never throws, for the same reason. */\n const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {\n if (!stepId) return\n // A row refused here is not closed at all — the service guards the update on\n // `status = 'running'` — so the step would read `running` forever.\n const payload = {\n ...body,\n ...(body.detail === undefined ? {} : { detail: jsonbSafe(body.detail) }),\n }\n try {\n const res = await serviceFetch(\n `/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,\n { method: 'POST', headers: runnerHeaders, body: JSON.stringify(payload) },\n )\n if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)\n } catch (err) {\n console.warn('[ctx] step complete failed (non-fatal):', (err as Error).message)\n }\n }\n\n /**\n * The message an author should read when a ctx call is refused.\n *\n * The service answers with an envelope (`{error, message, code}`), so the raw\n * body pasted into an error reads `ctx.http → 400 {\"error\":true,\"message\":...}`\n * — the useful sentence is in there, wrapped in JSON the author did not ask\n * for and cannot act on. This unwraps it and falls back to the raw body when\n * the response is not one of ours (a proxy 502, say), because an empty message\n * would be worse than a noisy one.\n */\n const refusal = async (res: Response): Promise<string> => {\n const body = await res.text()\n try {\n const parsed = JSON.parse(body) as { message?: unknown }\n if (typeof parsed.message === 'string' && parsed.message) return parsed.message\n } catch {\n // Not JSON. Fall through to the body.\n }\n return body\n }\n\n /**\n * Record one ctx call as a row: timed, and with a bounded summary of what it\n * did.\n *\n * The summary is per capability rather than generic. A reader opening\n * `query:LoanApplication` wants the row count and the filter; a reader opening\n * `agent:triage` wants what the agent said — a generic dump of the return\n * value would be both larger and less useful than either. A capability whose\n * result carries a credential (a file's signed URL) summarizes AROUND it:\n * details are rendered verbatim in the Console.\n */\n const step = async <T>(\n kind: string,\n label: string,\n fn: () => Promise<T>,\n detailOf?: (out: T) => Record<string, unknown>,\n ): Promise<T> => {\n const t0 = Date.now()\n try {\n const out = await fn()\n await recordStep({\n kind,\n label,\n status: 'ok',\n durationMs: Date.now() - t0,\n // Omitted, not `{}`: an absent field leaves the service's own default in\n // place rather than writing an empty object the panel would have to\n // treat as detail.\n detail: summarize(detailOf, out),\n })\n return out\n } catch (err) {\n await recordStep({\n kind,\n label,\n status: 'error',\n detail: { message: (err as Error).message },\n durationMs: Date.now() - t0,\n })\n throw err\n }\n }\n\n /**\n * Every ctx call carries the per-run token, never a workspace credential.\n *\n * The fixed headers go LAST so `init.headers` cannot override them — the run\n * token is the entire authority of this call, and a caller that could replace\n * it could replace the run's scope.\n */\n const scoped = (path: string, init?: ServiceCallInit) =>\n // `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,\n // which is prefixed, because they authenticate by run token rather than by\n // session. Addressing them as `/automations/...` reaches the session-guarded\n // router instead and 404s. This is only caught end to end: both sides pass\n // their own tests, and the mismatch is between them.\n serviceFetch(`/v1/automations/runner${path}`, {\n ...init,\n headers: {\n ...(init?.headers ?? {}),\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n 'x-workspace-id': deps.workspaceId,\n },\n })\n\n /**\n * Names used by this EXECUTION, which is what the uniqueness rule is about.\n *\n * A run that uses steps is executed many times — once more after each step\n * completes — and every execution walks the handler from the top, naming the\n * same steps again. That is not a duplicate. A duplicate is the same name\n * twice within one walk, which is what this set sees, because a fresh context\n * is built per execution.\n */\n const namesThisExecution = new Set<string>()\n\n /** Set once `ctx.conversation.transcript()` has returned a transcript in this execution. */\n let transcriptHandedOut = false\n\n /**\n * Run `fn` as a durable step.\n *\n * The row is written from INSIDE the step body, and that placement is the\n * whole design rather than an implementation detail. Code after\n * `await step.run(...)` does not run in the same execution — the platform\n * checkpoints the step and resumes the handler in a fresh execution — so a\n * report written there would land one execution late, time the memoized\n * return instead of the work, and repeat on every later resumption. A body\n * runs exactly once per real execution of the step, so a report inside it is\n * written exactly once and times what actually happened.\n *\n * Open-then-close rather than one write at the end: ctx calls made inside the\n * body need the parent row to exist before they record, and opening first also\n * puts the step ahead of its own children in `seq`.\n */\n const runStep = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n\n // The cast is the honest boundary: the platform hands back the JSON round\n // trip of what the body returned, and nothing here can verify the author's\n // `T` survived it. Rule 2 on `StepApi` is that contract, stated where the\n // author reads it.\n return (await deps.step.run(name, async () => {\n const stepId = await recordStep({\n kind: 'step',\n label: name,\n stepName: name,\n status: 'running',\n })\n const t0 = Date.now()\n try {\n // `stepName` rides alongside `stepId` because `ctx.action.submit` needs\n // the NAME, not the row id: the id is fresh on every execution, and an\n // idempotency key derived from it would differ on each resumption —\n // which is the exact duplicate-submission this scope exists to prevent.\n // The service reads the name off the row rather than trusting this\n // copy; it travels here only so a refusal can name it.\n const out = await stepScope.run(\n { stepId, stepName: name, submitted: new Set<string>() },\n fn,\n )\n if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))\n await completeStep(stepId, {\n status: 'ok',\n durationMs: Date.now() - t0,\n detail: stepResultDetail(out, transcriptHandedOut),\n })\n return out\n } catch (err) {\n await completeStep(stepId, {\n status: 'error',\n durationMs: Date.now() - t0,\n detail: { message: (err as Error).message },\n })\n throw err\n }\n })) as T\n }\n\n /**\n * Durable pause. No step row is written: a row recorded after a memoized\n * sleep would be re-recorded by every later execution (the code after an\n * awaited memoized step re-runs per resumption), and unlike `runStep`\n * there is no body to write it from exactly once.\n */\n const sleepStep = async (name: string, ms: number): Promise<void> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n if (deps.step.sleep) {\n await deps.step.sleep(name, ms)\n return\n }\n // Host without a sleep arm (an old dev worker): wait inline. Correct,\n // just not durable — acceptable for the host that cannot resume anyway.\n await new Promise((resolve) => setTimeout(resolve, ms))\n }\n\n /** What an agent call's row says about it — shared by both transports so a\n * durable call and a synchronous one read the same in the run's trace. */\n const agentDetail = (slug: string, prompt: string, options: AgentRunOptions | undefined) =>\n (out: { text?: unknown } | undefined): Record<string, unknown> => ({\n slug,\n promptChars: prompt.length,\n ...(options?.files?.length ? { files: options.files.length } : {}),\n // The answer itself, capped. A run whose agent step is the expensive\n // one is read to find out WHAT the agent said, and a length alone\n // sends the reader back to re-run the automation to learn it.\n ...(typeof out?.text === 'string' ? { textChars: out.text.length, preview: preview(out.text) } : {}),\n })\n\n /** The body both agent routes accept. */\n const agentBody = (slug: string, prompt: string, options: AgentRunOptions | undefined) => ({\n slug,\n prompt,\n // `files` hands the agent already-uploaded files by canonical id — the\n // same `{ fileId }` a `file`-typed run input carries, so an input can be\n // forwarded as `ctx.agent(s).run(p, { files: [ctx.input.doc] })`. The\n // service authorizes each id against this run's workspace and stages the\n // bytes onto the agent's computer; the agent is told the staged paths.\n ...(options?.files?.length ? { files: options.files.map((f) => ({ fileId: f.fileId })) } : {}),\n ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),\n })\n\n /**\n * `ctx.agent` on one held request — the only shape available inside an\n * author's step body (steps do not nest) and on a host that cannot park a\n * run. Unchanged from before durable waits existed.\n */\n const agentSync = (slug: string, prompt: string, options: AgentRunOptions | undefined) =>\n step(\n 'agent',\n `agent:${slug}`,\n async () => {\n requireGrant(`agent:${slug}:run`)\n const res = await scoped('/ctx/agent-run', {\n method: 'POST',\n headers: { [KEEPALIVE_HEADER]: '1' },\n body: JSON.stringify(agentBody(slug, prompt, options)),\n })\n // A refusal before the turn starts (token, grant, budget, files) is\n // still a plain non-2xx, on either shape.\n if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)\n // The kept-alive body is leading spaces and one JSON value; `JSON.parse`\n // skips the whitespace. The plain body is `{ data }`.\n const body = JSON.parse(await res.text()) as\n | { ok: true; data: { text: string } }\n | { ok: false; status: number; message: string }\n | { ok?: undefined; data: { text: string } }\n // The same words the plain route's non-2xx would have produced.\n if (body.ok === false) throw new Error(`agent ${slug} → ${body.status} ${body.message}`)\n return body.data\n },\n agentDetail(slug, prompt, options),\n ) as Promise<{ text: string }>\n\n /**\n * Durable `ctx.agent` calls made by THIS execution, in call order.\n *\n * The counter names the call's steps, so it must come out the same on every\n * replay — which it does, because a fresh context is built per execution and\n * a deterministic handler makes its calls in the same order each time. The\n * agent's slug is deliberately NOT in the id: two calls to one agent are two\n * turns, and a name built from the slug would memoize the second as the first.\n *\n * \"A deterministic handler\" is the author's side of the bargain, and it is\n * CHECKED rather than assumed: the start step memoizes a fingerprint of the\n * call (agent, prompt, files, timeoutMs), and a replay whose n-th call has a\n * different fingerprint fails with `agentReplayMismatchMessage` instead of\n * reusing the other call's turn.\n */\n let agentCalls = 0\n\n /**\n * `ctx.agent` without a held connection.\n *\n * agent:<n>:start start the turn; the service answers at once\n * agent:<n>:result:<i> read the turn; terminal → done\n * agent:<n>:wait:<i> park until the turn's finished event, or one slice\n *\n * The read is the answer and the event only a wake-up: a wait matches only\n * events sent after it registers, so a turn that settles between a read and\n * the next wait is caught by the read after that slice rather than lost.\n *\n * Every decision is taken from memoized values (`startedAt`, each read's\n * `now`), so a replay walks the same loop and reaches the same step ids. The\n * call's row is written from INSIDE the step that reached the verdict — the\n * one place a body runs exactly once — and times the whole wait.\n *\n * Charged once: the start is the only billable request and it is memoized;\n * reads are free. The idempotency key is the step id, so a start step retried\n * after the service already began the turn gets that same turn back.\n */\n const agentDurable = async (\n slug: string,\n prompt: string,\n options: AgentRunOptions | undefined,\n ): Promise<{ text: string }> => {\n const callNumber = ++agentCalls\n const base = `agent:${callNumber - 1}`\n const label = `agent:${slug}`\n const waitMs = options?.timeoutMs ?? AGENT_DEFAULT_TIMEOUT_MS\n const detailOf = agentDetail(slug, prompt, options)\n const fingerprint = agentFingerprint(slug, prompt, options)\n\n const start = (await deps.step.run(`${base}:start`, async (): Promise<AgentStart> => {\n const startedAt = Date.now()\n const res = await scoped('/ctx/agent-start', {\n method: 'POST',\n body: JSON.stringify({ ...agentBody(slug, prompt, options), idempotencyKey: `${base}:start` }),\n })\n if (!res.ok) {\n // A refusal is an answer, not a transient fault: returned, so the\n // platform does not retry a call the service has already turned down,\n // and thrown below in the words the synchronous path uses.\n const message = `agent ${slug} → ${res.status} ${await refusal(res)}`\n await recordStep({ kind: 'agent', label, status: 'error', detail: { message }, durationMs: Date.now() - startedAt })\n return { ok: false, message, fingerprint, slug }\n }\n const { invocationId } = ((await res.json()) as { data: { invocationId: string } }).data\n return { ok: true, invocationId, startedAt, fingerprint, slug }\n })) as AgentStart\n // Replayed from an earlier execution: is this still the call that started\n // it? Steps are matched by ORDER, so if top-level code before this call\n // read different data this time, the n-th call can be another call — and\n // reusing its turn would hand this call someone else's answer.\n if (start.fingerprint !== undefined && start.fingerprint !== fingerprint) {\n throw new Error(agentReplayMismatchMessage(slug, start.slug ?? slug, callNumber))\n }\n if (!start.ok) throw new Error(start.message)\n\n const giveUpAt = start.startedAt + waitMs + AGENT_SETTLE_GRACE_MS\n for (let i = 0; ; i++) {\n const read = (await deps.step.run(`${base}:result:${i}`, async (): Promise<AgentRead> => {\n const now = Date.now()\n const settle = async (verdict: { text: string } | { error: string }): Promise<AgentRead> => {\n await recordStep(\n 'text' in verdict\n ? { kind: 'agent', label, status: 'ok', durationMs: now - start.startedAt, detail: summarize(detailOf, verdict) }\n : { kind: 'agent', label, status: 'error', durationMs: now - start.startedAt, detail: { message: verdict.error } },\n )\n return { done: true, ...verdict }\n }\n\n let res: Response | null\n try {\n res = await scoped(`/ctx/agent-result/${start.invocationId}`, { method: 'GET' })\n } catch {\n // The service is unreachable for a moment. The turn does not depend\n // on this request, so try again after the next wait.\n res = null\n }\n if (res?.ok) {\n const turn = ((await res.json()) as {\n data: { status: AgentTurnStatus; text: string | null; error: string | null }\n }).data\n if (turn.status === 'succeeded') return settle({ text: turn.text ?? '' })\n if (turn.status !== 'queued' && turn.status !== 'running') {\n // The service stores the message the synchronous route would have\n // thrown; the prefix is the one that route's refusal carries.\n return settle({ error: `agent ${slug} → 400 ${turn.error ?? `the agent turn ended ${turn.status}`}` })\n }\n } else if (res && res.status < 500) {\n // Not transient: the run's token is gone, or the turn is not this\n // run's. Waiting longer cannot change the answer.\n return settle({ error: `agent ${slug} → ${res.status} ${await refusal(res)}` })\n }\n if (now >= giveUpAt) return settle({ error: `agent ${slug} → 400 ${agentTimeoutMessage(slug, waitMs)}` })\n return { done: false, now }\n })) as AgentRead\n\n if (read.done) {\n if ('error' in read) throw new Error(read.error)\n return { text: read.text }\n }\n const sliceMs = Math.max(1_000, Math.min(AGENT_WAIT_SLICE_MS, giveUpAt - read.now))\n await deps.step.waitForEvent!(`${base}:wait:${i}`, {\n event: AGENT_TURN_FINISHED_EVENT,\n timeout: `${Math.ceil(sliceMs / 1000)}s`,\n if: `async.data.invocationId == \"${start.invocationId}\"`,\n })\n }\n }\n\n return {\n runId: deps.runId,\n workspaceId: deps.workspaceId,\n // The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.\n input: deps.input ?? {},\n\n step: { run: runStep, sleep: sleepStep },\n\n async log(message, data) {\n // Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a\n // blip reaching the service must not take down an otherwise-healthy run —\n // the signature promises callers it never rejects.\n await recordStep({ kind: 'log', label: message, detail: data ?? {} })\n },\n\n agent(slug: string) {\n return {\n run: (prompt: string, options?: AgentRunOptions) => {\n // Durable only where it can be: at the top level of the handler (a\n // step cannot contain steps), on a host that can park a run, and\n // with the grant in place — a missing grant is refused by the\n // synchronous path in the same words and with the same row it has\n // always written.\n const durable =\n !stepScope.getStore() && !!deps.step.waitForEvent && deps.grants.includes(`agent:${slug}:run`)\n return durable ? agentDurable(slug, prompt, options) : agentSync(slug, prompt, options)\n },\n }\n },\n\n file: (ref: { fileId: string }) =>\n step('file', `file:${ref.fileId.slice(0, 8)}`, async () => {\n // No grant: this only reads a file the run was GIVEN — the route\n // refuses any fileId that is not among this run's own inputs (a `file`\n // input, or one item of a `files` input). In a dry dev run it returns\n // null, like every other ctx call.\n const res = await scoped('/ctx/file-resolve', {\n method: 'POST',\n body: JSON.stringify({ fileId: ref.fileId }),\n })\n if (!res.ok) throw new Error(`file ${ref.fileId} → ${res.status} ${await refusal(res)}`)\n const file = ((await res.json()) as {\n data: { file: { fileId: string; signedUrl: string; mimeType: string; sizeBytes: number; name: string | null } | null }\n }).data.file\n if (!file) return null\n\n // The signed URL never leaves this closure. Function code cannot open a\n // network socket in the runner, and a URL in a step row would be a live\n // credential — so the SDK does the read, over the same forwarder socket,\n // and hands back the bytes. `readFile` re-resolves each time it is\n // called, so a large file need not sit in memory unless the author asks.\n const readFile = async (): Promise<Response> => {\n const target = deps.socketPath ? readdressToForwarder(serviceUrl, file.signedUrl) : file.signedUrl\n const response = await fetch(target, deps.socketPath ? { unix: deps.socketPath } as RequestInit : undefined)\n if (!response.ok) throw new Error(`file ${ref.fileId} download → ${response.status}`)\n return response\n }\n const handle: import('./types').ResolvedFileHandle = {\n fileId: file.fileId,\n mimeType: file.mimeType,\n sizeBytes: file.sizeBytes,\n name: file.name,\n bytes: async () => new Uint8Array(await (await readFile()).arrayBuffer()),\n text: async () => (await readFile()).text(),\n stream: async () => {\n const body = (await readFile()).body\n if (!body) throw new Error(`file ${ref.fileId} download had no body`)\n return body\n },\n }\n return asFileHandle(handle)\n },\n // The row records the file's identity, never its bytes or a way to read them.\n (out) =>\n out\n ? { fileId: out.fileId, mimeType: out.mimeType, sizeBytes: out.sizeBytes, name: out.name }\n : { resolved: false },\n ) as Promise<import('./types').ResolvedFileHandle | null>,\n\n plugin(install: string) {\n return {\n call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>\n step('plugin', `plugin:${install}:${capability}`, async () => {\n // Pre-flighted locally so an author reads the grant by name, in the\n // same words the service uses. The service checks it again — this\n // copy exists for the message, not for the authority.\n requireGrant(`plugin:${install}:${capability}`)\n const res = await scoped('/ctx/plugin-call', {\n method: 'POST',\n body: JSON.stringify({ install, capability, input: input ?? {} }),\n })\n if (!res.ok) {\n throw new Error(\n `ctx.plugin(\"${install}\").call(\"${capability}\") → ${res.status} ${await refusal(res)}`,\n )\n }\n // Two levels: the service envelope's data, then PluginCallResult's own data.\n return ((await res.json()) as { data: PluginCallResult<T> }).data\n },\n (out) => {\n const chars = jsonSize(out?.data)\n return {\n install,\n capability,\n ...(chars === null ? {} : { resultChars: chars }),\n // The plugin's own shape, while it is small enough to read at a\n // glance. The size above stands for it when it is not.\n ...ifSmall('data', out?.data, 2_000),\n }\n },\n ) as Promise<PluginCallResult<T>>,\n }\n },\n\n http: {\n fetch: (req: HttpRequest) =>\n step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {\n // Pre-flight the HOST grant so an author sees it named locally, in the\n // same wording the service uses. The SECRET grant is deliberately not\n // pre-flighted: the service derives it, and duplicating that derivation\n // here would be a second place to get it wrong.\n let host: string\n try {\n host = new URL(req.url).hostname.toLowerCase()\n } catch {\n throw new Error(`ctx.http: invalid URL ${req.url}`)\n }\n requireGrant(`http:${host}`)\n const res = await scoped('/ctx/http', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)\n return ((await res.json()) as { data: HttpResponse }).data\n },\n // The upstream status, which is the fact this call is read for — an API\n // answering 404 is data here, not a throw, so the row is the only place\n // that outcome appears at all. Not the body: it is capped at 1 MB and\n // may carry whatever the host sent back.\n (out) => ({\n ...(out ? { status: out.status, bodyChars: out.body?.length ?? 0 } : {}),\n }),\n ) as Promise<HttpResponse>,\n },\n\n action: {\n submit: (request: ActionSubmission) =>\n step('action', `action:${request.action}`, async () => {\n // Step scope BEFORE the grant. Both are the author's mistake, but this\n // one is structural: a submit outside a step is wrong even with every\n // grant in place, and the remedy is a code change rather than a\n // manifest change. Naming the manifest first would send them to the\n // wrong file.\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessageText(request.action))\n // A step whose own row was lost cannot be submitted from. `recordStep`\n // is contractually non-fatal and hands back an empty id, which is\n // right for telemetry — one lost row must not cost the calls made\n // inside it — but a submission has nothing to key on without it, and\n // improvising a key is how an effect happens twice. Refused HERE so\n // the cause is named; the service would otherwise see an empty string\n // and answer with a generic invalid-submission.\n if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))\n // NUL-joined because both halves are author strings; `a:b` with no\n // key must not collide with `a` keyed `b`.\n // Refused locally, matching the service's own field-named rejection\n // — folding `''` into the no-key identity would make the two layers\n // disagree about what the author asked for.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessageText(request.action))\n }\n // Ahead of the reservation, and synchronous so check-and-reserve still\n // land in one tick. Below it, a missing grant left the identity\n // reserved and the author's next attempt was told they had duplicated\n // a submission that never left the process — pointing at\n // `submissionKey` when the fix is one line in the manifest. A call\n // that is both ungranted and a duplicate now reports the grant, which\n // is the more actionable of the two.\n requireGrant(`governed:${request.action}`)\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // RESERVE, synchronously. The check and the record must land in one\n // tick: with an await between them, `Promise.all([submit(x),\n // submit(x)])` passes both checks before either records, both reach\n // the plane, and — same key, same fingerprint — the plane replays the\n // first for the second. Two calls, one effect, a green run, which is\n // the exact failure this guard exists to prevent.\n //\n // Released again in the catch below, so a submission that never\n // landed does not burn its identity and the author's retry loop still\n // works. Reserve-then-release is what satisfies both at once.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessageText(request.action))\n }\n scope.submitted.add(submissionIdentity)\n // The step ROW id, which the service issued. The service resolves the\n // row, takes the step NAME off it, and derives the key from that — so\n // what identifies the submission comes from the database rather than\n // from this process. No ordinal: a positional one made an in-body\n // retry mint a fresh key and duplicate the effect, and reordered\n // concurrent submits bind each other's keys. `submissionKey` is how an\n // author says two submissions are genuinely two.\n let res: Response\n try {\n res = await scoped('/ctx/action-submit', {\n method: 'POST',\n body: JSON.stringify({ ...request, stepId: scope.stepId }),\n })\n } catch (err) {\n // Never reached the service. Release, so a retry is a retry rather\n // than a duplicate accusation for a step that submitted zero times.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n if (!res.ok) {\n // Refused, so nothing was bound to this identity. A 5xx is the\n // interesting case: the author catches it and submits again, and\n // that second call must be allowed through to the plane, where the\n // key — unchanged — makes it a replay rather than a second effect.\n scope.submitted.delete(submissionIdentity)\n throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)\n }\n try {\n return ((await res.json()) as { data: ActionSubmitResult }).data\n } catch (err) {\n // The submission LANDED, so keeping the reservation would be\n // defensible — but the reasoning that releases a 503 applies here\n // with more force: the key is unchanged, so a resubmit can only\n // replay, and replaying is the only way the author recovers a\n // request id they never received. Keeping it ends the run accusing\n // them of two submissions when there was one and an unreadable\n // answer.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n },\n // `lifecycle` is the half of the answer a reader most often wants: a\n // submission that stopped at `awaiting_approval` is a normal return, so\n // the row's green status does not say whether anything happened yet.\n (out) => ({\n action: request.action,\n ...(request.submissionKey ? { submissionKey: request.submissionKey } : {}),\n ...(out ? { requestId: out.requestId, lifecycle: out.lifecycle } : {}),\n }),\n ) as Promise<ActionSubmitResult>,\n },\n\n blueprint: {\n query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>\n step('blueprint', `query:${objectType}`, async () => {\n requireGrant('blueprint:read')\n // Spread rather than forwarded field-by-field so adding an option to\n // `BlueprintQueryOptions` does not silently drop it here — the\n // service validates the body, so an unknown key is refused there\n // rather than ignored in transit.\n const res = await scoped('/ctx/blueprint-query', {\n method: 'POST',\n body: JSON.stringify({ objectType, ...(options ?? {}) }),\n })\n if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)\n // `T` is an author-supplied shape for rows the warehouse returns\n // untyped. The cast is the honest boundary: nothing here can verify\n // it, and pretending otherwise would just move the lie deeper.\n return ((await res.json()) as { data: BlueprintQueryResult<T> }).data\n },\n // Count AND filter, because the two questions a reader brings to a\n // query step are \"how many did it match\" and \"what did it ask for\" —\n // and `hasMore` is how they tell an empty result from a truncated one.\n (out) => ({\n objectType,\n ...(out ? { rowCount: out.rows?.length ?? 0, hasMore: out.hasMore ?? false } : {}),\n ...(options?.limit === undefined ? {} : { limit: options.limit }),\n ...ifSmall('where', options?.where, 1_000),\n ...ifSmall('select', options?.select, 500),\n ...ifSmall('orderBy', options?.orderBy, 500),\n }),\n ) as Promise<BlueprintQueryResult<T>>,\n },\n\n conversation: {\n transcript: () =>\n step('conversation', 'transcript', async () => {\n requireGrant('conversation:read')\n // No argument on purpose: the service reads the conversation the\n // PLATFORM recorded as this run's trigger, so there is nothing an\n // author could name to read someone else's.\n const res = await scoped('/ctx/conversation-transcript', { method: 'POST', body: '{}' })\n if (!res.ok) throw new Error(`ctx.conversation.transcript → ${res.status} ${await refusal(res)}`)\n const transcript = brandTranscript(\n ((await res.json()) as { data: { transcript: ConversationTranscript | null } }).data.transcript,\n )\n if (transcript) transcriptHandedOut = true\n return transcript\n },\n // The turn count only. The text is what the person wrote, and step\n // details are rendered verbatim and kept as long as the run is. A step\n // that RETURNS the transcript is summarized the same way — see\n // `stepResultDetail`.\n (out) => (out ? { turnCount: out.turns.length } : { transcript: false }),\n ) as Promise<ConversationTranscript | null>,\n },\n }\n}\n",
|
|
30
30
|
"frontera/automation/inputs.ts": "/**\n * Run-input validation — the value-side twin of `validateManifest`.\n *\n * Three callers must agree on the verdict and the wording: the run route\n * (fast 400 before anything queues), `startRun` (authoritative — the runner\n * posts whatever rode the event), and the Console form (client courtesy).\n * Living in the SDK is what keeps them one implementation.\n */\nimport type { InputFieldSpec, InputsSchema } from './types'\n\nexport type InputValidation =\n | { ok: true; value: Record<string, unknown> }\n | { ok: false; errors: string[] }\n\n/**\n * The input types' runtime validators, keyed by `InputFieldSpec['type']`.\n *\n * Single source of truth for \"does this value have this type\" — `validateInputValue`'s\n * value check and `checkInputFieldSpec`'s default/enum checks all call this instead\n * of re-deriving it, so a tightening here (the `Number.isFinite` guard that excludes\n * `Infinity`/`NaN` from `number`) or a future widening can never drift between\n * deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`\n * used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why\n * this is exported rather than kept module-private.\n *\n * `file` is shape-only here: it confirms the value is a `FileRef` (a plain object\n * naming a non-empty string `fileId`). It deliberately does NOT authorize the id\n * or check mime/size — those need the DB and the caller's workspace scope, so the\n * SERVER (`run-service` via `resolveFile`) authorizes and resolves the reference\n * after this passes. `files` is a list of the same reference; its count is\n * checked in `validateInputValue`, which knows the field's `maxFiles`.\n */\nconst isFileRef = (v: unknown): boolean =>\n typeof v === 'object' &&\n v !== null &&\n !Array.isArray(v) &&\n typeof (v as { fileId?: unknown }).fileId === 'string' &&\n (v as { fileId: string }).fileId.length > 0\n\nexport const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {\n string: (v) => typeof v === 'string',\n number: (v) => typeof v === 'number' && Number.isFinite(v),\n boolean: (v) => typeof v === 'boolean',\n object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),\n array: Array.isArray,\n file: isFileRef,\n files: (v) => Array.isArray(v) && v.every(isFileRef),\n}\n\n/** The most files a `files` input holds when its spec names no `maxFiles`. */\nexport const DEFAULT_MAX_FILES = 20\n/** The highest `maxFiles` a `files` input may declare. */\nexport const MAX_FILES_CAP = 50\n\n/** Whether an input of this type holds file references: one, or a list. */\nexport function isFileInputType(type: unknown): type is 'file' | 'files' {\n return type === 'file' || type === 'files'\n}\n\n/** The most files a `files` input holds. */\nexport function maxFilesOf(spec: Pick<InputFieldSpec, 'maxFiles'>): number {\n return spec.maxFiles ?? DEFAULT_MAX_FILES\n}\n\n/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */\nconst INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/\n/** Lowercase snake — the one allowance kebab doesn't cover. */\nconst INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/\n\nconst INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array', 'file', 'files'])\n\n/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).\n * Anything else there is a warning, same philosophy as `manifest.ts`'s\n * top-level unknown-key warning: a typo like `requred` should be visible,\n * but a field a newer SDK added must not fail an older validator's deploy. */\nconst INPUT_SPEC_KEYS = new Set([\n 'type',\n 'required',\n 'default',\n 'description',\n 'enum',\n 'redact',\n 'accept',\n 'maxBytes',\n 'maxFiles',\n])\n\n/**\n * Serialized cap on a run's input object — the same 64KB the service enforces\n * when a run starts. Exported so `validateManifest` can refuse a manifest\n * whose *defaults alone* exceed it at deploy time: past that point a cron\n * fire would fail inside run-open with no run row, which is invisible.\n */\nexport const MAX_INPUT_BYTES = 64 * 1024\n\n/** Input names that smell like credentials — warned at deploy, never blocked.\n * Tails are anchored so `max_tokens` (a count) and `secretary` stay quiet\n * while `api_key`, `auth-token`, `client_secret`, `secret_key` still warn. */\nconst CREDENTIAL_NAME_RE = /([_-]token$|[_-]key$|password|(^|[_-])secret([_-]|$))/i\n\nexport interface InputFieldCheck {\n errors: string[]\n warnings: string[]\n}\n\n/**\n * Structural rules for ONE `{ name: spec }` entry in an inputs schema — name\n * shape, declared type, required/default shape, enum.\n *\n * The shared source for both `validateManifest` (deploy-time; also surfaces\n * the non-fatal warnings) and `sanitizeInputsSchema` (runtime; pass/fail\n * only) so the two can never quietly diverge on what \"a well-formed input\n * field\" means — which is exactly how `manifest.ts`'s default-type check once\n * drifted from `TYPE_CHECK` and admitted `default: Infinity`.\n */\nexport function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck {\n const errors: string[] = []\n const warnings: string[] = []\n\n if (!INPUT_NAME_KEBAB_RE.test(key) && !INPUT_NAME_SNAKE_RE.test(key)) {\n errors.push(`input \"${key}\" — names are lowercase snake or kebab`)\n return { errors, warnings }\n }\n\n // Inputs land on the run row and in traces permanently; there is no way to\n // detect a secret in a value, but a name that says \"credential\" is an honest\n // mistake we can flag while the author is still looking at the file.\n if (CREDENTIAL_NAME_RE.test(key)) {\n warnings.push(\n `input \"${key}\" looks like a credential — inputs are stored on the run row `\n + 'and visible in traces. Use a `secret:` grant instead.',\n )\n }\n\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n for (const specKey of Object.keys(raw as Record<string, unknown>)) {\n if (!INPUT_SPEC_KEYS.has(specKey)) {\n warnings.push(`unknown key \"${specKey}\" on input \"${key}\" — ignored`)\n }\n }\n }\n\n const spec = (raw ?? {}) as {\n type?: unknown\n required?: unknown\n default?: unknown\n enum?: unknown\n description?: unknown\n redact?: unknown\n accept?: unknown\n maxBytes?: unknown\n maxFiles?: unknown\n }\n\n if (spec.description !== undefined && typeof spec.description !== 'string') {\n warnings.push(`input \"${key}\": description is not a string — ignored`)\n }\n\n if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {\n errors.push(`input \"${key}\": type must be one of string, number, boolean, object, array, file, files`)\n return { errors, warnings }\n }\n const t = spec.type as InputFieldSpec['type']\n\n // Deploy-side and runtime must share this exact predicate (`=== true`), not\n // a truthy check — `inputs.ts`'s own `validateInputValue` only treats\n // `required` as active when it is literally `true`. Without this, a plain\n // `required: 1` would deploy clean and then never actually be enforced.\n if (spec.required !== undefined && typeof spec.required !== 'boolean') {\n errors.push(`input \"${key}\": required must be a boolean`)\n }\n\n if (spec.required === true && spec.default !== undefined) {\n errors.push(`input \"${key}\": required and default are mutually exclusive — a default always satisfies required`)\n }\n\n // Same `=== true` predicate as `required`, for the same reason: the runtime\n // treats only a literal `true` as active, so a truthy check here would let\n // `redact: 1` deploy clean and then mask nothing.\n if (spec.redact !== undefined && typeof spec.redact !== 'boolean') {\n errors.push(`input \"${key}\": redact must be a boolean`)\n }\n\n // Both of these publish the value the flag claims to hide, so they are\n // refused rather than warned about: a manifest that declares them is a\n // masking guarantee that was never going to hold.\n if (spec.redact === true && spec.default !== undefined) {\n errors.push(\n `input \"${key}\": redact and default are mutually exclusive — a default is published in the `\n + 'version manifest, so the value would be readable there',\n )\n }\n if (spec.redact === true && spec.enum !== undefined) {\n errors.push(\n `input \"${key}\": redact and enum are mutually exclusive — an enum publishes every allowed `\n + 'value in the version manifest',\n )\n }\n\n let enumOk = true\n if (spec.enum !== undefined) {\n if (t !== 'string' && t !== 'number') {\n errors.push(`input \"${key}\": enum is only valid for string and number types`)\n enumOk = false\n } else if (!Array.isArray(spec.enum) || spec.enum.length === 0) {\n errors.push(`input \"${key}\": enum must not be empty`)\n enumOk = false\n } else if (spec.enum.some((e) => typeof e !== t)) {\n errors.push(`input \"${key}\": enum values must match the declared type`)\n enumOk = false\n } else if (t === 'number' && spec.enum.some((e) => !Number.isFinite(e as number))) {\n // TYPE_CHECK's own `number` check already excludes NaN/Infinity from\n // values — a member of `enum` that no value could ever equal is\n // unreachable and can only be an authoring mistake.\n errors.push(`input \"${key}\": enum values must be finite numbers`)\n enumOk = false\n }\n }\n\n // `accept`/`maxBytes` are the file-only constraints, for one file or a list\n // of them — refused on any other type so a typo like `accept` on a string\n // field is loud, not silently ignored. They constrain the UPLOAD, not the value on the run row (which is\n // only a reference), so the server enforces them; here we check well-formedness.\n if (spec.accept !== undefined) {\n if (!isFileInputType(t)) {\n errors.push(`input \"${key}\": accept is only valid for file inputs`)\n } else if (\n !Array.isArray(spec.accept)\n || spec.accept.length === 0\n || spec.accept.some((a) => typeof a !== 'string' || a.length === 0)\n ) {\n errors.push(`input \"${key}\": accept must be a non-empty array of MIME patterns`)\n }\n }\n if (spec.maxBytes !== undefined) {\n if (!isFileInputType(t)) {\n errors.push(`input \"${key}\": maxBytes is only valid for file inputs`)\n } else if (typeof spec.maxBytes !== 'number' || !Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {\n errors.push(`input \"${key}\": maxBytes must be a positive number`)\n }\n }\n if (spec.maxFiles !== undefined) {\n if (t !== 'files') {\n errors.push(`input \"${key}\": maxFiles is only valid for files inputs`)\n } else if (\n typeof spec.maxFiles !== 'number'\n || !Number.isInteger(spec.maxFiles)\n || spec.maxFiles < 1\n || spec.maxFiles > MAX_FILES_CAP\n ) {\n errors.push(`input \"${key}\": maxFiles must be a whole number from 1 to ${MAX_FILES_CAP}`)\n }\n }\n\n let defaultOk = true\n if (spec.default !== undefined) {\n // A `file` or `files` input carries references to uploaded items, so a\n // fixed literal default is meaningless — refused rather than type-checked.\n if (isFileInputType(t)) {\n errors.push(`input \"${key}\": a file input cannot have a default`)\n defaultOk = false\n } else if (!TYPE_CHECK[t](spec.default)) {\n errors.push(`input \"${key}\": default must match the declared type`)\n defaultOk = false\n }\n }\n\n if (spec.enum !== undefined && spec.default !== undefined && enumOk && defaultOk) {\n if (!(spec.enum as unknown[]).includes(spec.default)) {\n errors.push(`input \"${key}\": default must be one of the enum values`)\n }\n }\n\n return { errors, warnings }\n}\n\nexport function validateInputValue(\n schema: InputsSchema | undefined,\n value: Record<string, unknown> | undefined | null,\n): InputValidation {\n const given = value ?? {}\n if (!schema || Object.keys(schema).length === 0) {\n return Object.keys(given).length === 0\n ? { ok: true, value: {} }\n : { ok: false, errors: ['this automation declares no inputs — remove the input and run again'] }\n }\n const errors: string[] = []\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(given)) {\n // `Object.hasOwn`, not `key in schema`: the `in` operator also sees\n // inherited members — every plain object \"has\" `toString` via\n // `Object.prototype` — so a value keyed `toString` would slip past an\n // undeclared-field check that used `in`.\n if (!Object.hasOwn(schema, key)) errors.push(`\"${key}\" is not a declared input`)\n }\n for (const [key, spec] of Object.entries(schema)) {\n // Same reasoning in reverse: plain `given[key]` for key `constructor`\n // resolves to `Object.prototype.constructor` (a function) rather than\n // `undefined` when the caller never supplied one, which would run type\n // checks against Object's own constructor instead of treating the field\n // as absent.\n const v = Object.hasOwn(given, key) ? given[key] : undefined\n if (v === undefined) {\n if (spec.default !== undefined) out[key] = structuredClone(spec.default)\n // `=== true`, not truthy: a legacy/malformed `required: 1` must not be\n // silently enforced here when `validateManifest` already refuses it as\n // \"not a boolean\" — the two sides share one predicate on purpose.\n else if (spec.required === true) errors.push(`\"${key}\" is required`)\n continue\n }\n // `Object.hasOwn`, not a plain lookup: `TYPE_CHECK` is an object literal,\n // so `TYPE_CHECK['toString']` resolves to `Object.prototype.toString` —\n // truthy, and callable — rather than `undefined`. A spec of `{ type:\n // 'toString' }` would then pass `check(v)` for ANY `v` instead of being\n // refused as the unknown type it is.\n const check = Object.hasOwn(TYPE_CHECK, spec.type) ? TYPE_CHECK[spec.type as InputFieldSpec['type']] : undefined\n if (!check) {\n // A stored manifest can predate this SDK version and carry a `type`\n // this build has never heard of (pre-input-validation, `inputs` was an\n // unknown key with no shape checking at all). Fail the field, don't\n // crash the run route.\n errors.push(`\"${key}\" has an unknown declared type \"${String(spec.type)}\"`)\n continue\n }\n if (!check(v)) {\n errors.push(\n spec.type === 'files' ? `\"${key}\" must be a list of files` : `\"${key}\" must be of type ${spec.type}`,\n )\n continue\n }\n if (spec.type === 'files') {\n const count = (v as unknown[]).length\n // An empty list says \"no files\", which a required input does not accept.\n if (count === 0 && spec.required === true) {\n errors.push(`\"${key}\" is required — add at least one file`)\n continue\n }\n if (count > maxFilesOf(spec)) {\n errors.push(`\"${key}\" holds ${count} files — the most it takes is ${maxFilesOf(spec)}`)\n continue\n }\n }\n if (spec.enum && !spec.enum.includes(v as string | number)) {\n errors.push(`\"${key}\" must be one of ${spec.enum.join(', ')}`)\n continue\n }\n out[key] = v\n }\n return errors.length > 0 ? { ok: false, errors } : { ok: true, value: out }\n}\n\n/**\n * A stored manifest's `inputs` key, admitted only when structurally valid.\n *\n * Versions deployed before inputs existed could carry ANY value under this\n * key (it was warn-and-store), and `startRun` must not let a stray legacy\n * blob retroactively break a working schedule — an invalid schema is treated\n * as \"declares no inputs\", never as a refusal.\n */\nexport function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n if (checkInputFieldSpec(key, raw).errors.length > 0) return undefined\n }\n return inputs as InputsSchema\n}\n\n\n/**\n * The input names a version declared `redact: true` on.\n *\n * One implementation, so the service, the Console and anything else answer\n * \"which fields are masked\" the same way — the same argument that keeps\n * `checkInputFieldSpec` shared between deploy and runtime.\n *\n * FAIL CLOSED IS THE CALLER'S JOB, and it matters: `sanitizeInputsSchema`\n * returns `undefined` for the WHOLE schema when any single field is malformed,\n * which a caller deriving this list from its result would read as \"nothing is\n * redacted\". A caller holding a non-empty raw `inputs` whose sanitized form is\n * `undefined` must mask everything rather than nothing.\n */\nexport function redactedInputKeys(schema: InputsSchema | undefined): string[] {\n if (!schema) return []\n return Object.entries(schema)\n .filter(([, spec]) => spec?.redact === true)\n .map(([key]) => key)\n}\n",
|
|
31
|
-
"frontera/automation/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { NearestRequest, WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron, manual, and agent for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n *\n * `agent` is deliberately NOT part of that exclusion. Cron and manual answer\n * \"what fires this on its own\"; `agent: true` answers \"may a bound agent call\n * this\", which is an orthogonal question — a nightly reconciliation that an\n * analyst can also ask an agent to run on demand is one automation, not two.\n * So `agent` rides alongside either, and the third arm exists for the\n * agent-only automation, which has no self-starting trigger at all.\n *\n * Declaring it is only the AUTHOR's half of the permission. A workspace\n * operator must still bind the automation to one named agent before any tool\n * is projected; see the design in\n * docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never; agent?: true }\n | { cron?: never; manual: true; agent?: true }\n | { cron?: never; manual?: never; agent: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n /** Read the conversation that started the run — see `ctx.conversation`. */\n | 'conversation:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these.\n * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.\n * On a `files` field it applies to each file.\n * A declaration aid: the value on the run row is only a reference, so this is\n * enforced server-side at upload and at run start, never against the value here. */\n accept?: readonly string[]\n /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.\n * On a `files` field it applies to each file. */\n maxBytes?: number\n /** `files` type only. The most files the field holds: a whole number from 1\n * to 50, 20 when absent. */\n maxFiles?: number\n /**\n * Mask this field's VALUE wherever a person reads the run.\n *\n * What it changes: the run list, the run page and `frontera function runs`\n * show a placeholder instead of the value. What it does NOT change: the\n * handler, every resumption and every retried attempt receive the real value,\n * because the run row still holds it — this is a display control, not\n * storage encryption and not an access control.\n *\n * What it CANNOT cover, stated here so the flag never reads as a promise it\n * does not keep:\n *\n * - `ctx.log('…', { key: ctx.input.token })` — an author writing a value\n * into a step detail publishes it, and nothing here can intercept that.\n * - a redacted value the author TRANSFORMS before using it. The agent\n * transcript on the run page is masked by exact occurrence, so a value\n * interpolated into a prompt — or quoted back in the reply — is replaced.\n * A value upper-cased, truncated or reformatted first no longer matches\n * and is not found. Exact match is what can be done without guessing at\n * substrings; the alternative, withholding transcripts entirely for any\n * run with a redacted input, would take the review surface away from\n * exactly the runs that most need reviewing.\n * - `default` and `enum`, which are published in the version manifest and in\n * any tool schema built from it. Declaring either alongside `redact` is\n * refused at deploy for exactly that reason.\n * - the run's own `result` and error message. A handler that returns the\n * value — `return { note: ctx.input.customer_note }` — or throws an error\n * quoting it publishes it on the same run page, unmasked, next to the\n * masked input it came from. Only the INPUT is masked; what the handler\n * chooses to emit is the handler's decision.\n * - anything already recorded. Versions are append-only and the mask is\n * frozen onto each run when it starts, so adding `redact` masks future\n * runs and never rewrites history.\n *\n * A credential still belongs in a `secret:` grant, whose value never enters\n * this process at all. `redact` is for the ordinary personal or commercial\n * detail a run legitimately takes and a bystander has no reason to read.\n */\n redact?: boolean\n}\n\n\n/**\n * The value of a `file`-typed input.\n *\n * A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the\n * canonical handle from the platform's unified file registry (see\n * docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in\n * storage; only this small id rides on the run row, so the 64KB input cap is\n * untouched.\n *\n * One reference, two entry points: the run-form uploader gets a `fileId` for a\n * file a person drops in, and an agent passes the `fileId` of a chat attachment\n * it is already holding — both resolve identically downstream. The client never\n * supplies a trusted path or URL; the SERVER authorizes the `fileId` against the\n * caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.\n */\nexport interface FileRef {\n fileId: string\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n /**\n * One headless agent turn. `options.files` hands the agent already-uploaded\n * files by canonical id — the same `{ fileId }` a `file`-typed run input\n * carries, so an input forwards directly: `run(p, { files: [ctx.input.doc] })`.\n * Each id is authorized against this run's workspace and the bytes are staged\n * onto the agent's computer; the agent is told the staged paths.\n *\n * `options.timeoutMs` waits longer than the default 120 s for work known to\n * be long (a long scanned document); the service caps it at 480 s, under\n * the run's own 10-minute ceiling.\n *\n * Call it at the TOP LEVEL of the handler, not inside `ctx.step.run`. There\n * the wait is durable: the turn is started once, the run is parked until it\n * finishes, and a resumption replays the answer instead of asking again.\n * Inside a step body (steps cannot nest) the call waits on one request, and\n * a step that is re-run asks the agent again.\n */\n run(prompt: string, options?: { files?: readonly FileRef[]; timeoutMs?: number }): Promise<{ text: string }>\n}\n\n/**\n * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch\n * its bytes. `null` only in a dry dev run.\n *\n * There is no URL. In the deployed runner a Function cannot open a network\n * socket, and a signed URL would be a live credential; the readers download\n * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a\n * large file need not stay in memory.\n */\nexport interface ResolvedFileHandle {\n fileId: string\n mimeType: string\n sizeBytes: number\n name: string | null\n /**\n * The whole file as bytes.\n *\n * Each reader downloads the file again, through a link that expires about an\n * hour after `ctx.file` returned the handle. Read it soon after, or call\n * `ctx.file` again rather than keeping a handle across a long wait.\n */\n bytes(): Promise<Uint8Array>\n /** The whole file decoded as UTF-8 text. */\n text(): Promise<string>\n /** The file as a stream, for reading large files without holding them in memory. */\n stream(): Promise<ReadableStream<Uint8Array>>\n}\n\n/**\n * The conversation that started a run, as the person saw it: what they wrote\n * and what the agent wrote back, as text.\n *\n * Only the stretch that belongs to this run is included — earlier requests in\n * the same conversation, already handled by earlier runs that read a\n * transcript and finished successfully or are still running, and anything\n * before a quiet gap of more than six hours are left out.\n *\n * Replaced with `[removed]`: card numbers of the major card brands (Visa,\n * Mastercard, American Express, Discover, UnionPay), with the digit groups\n * separated by a single space or dash, or written together (an expiry or\n * security code after the number stays); Singapore NRIC/FIN numbers; and —\n * within the six words after \"password\", \"passcode\", \"PIN\", \"OTP\", \"one-time\n * code\" or \"verification code\" (or the Indonesian and Malay \"kata sandi\",\n * \"sandi\", \"kode verifikasi\", \"kode OTP\", \"kata laluan\", \"kod pengesahan\"), or\n * the first six words of the person's reply right after the agent asks for one\n * of these — any word containing a digit.\n * Words without a digit stay. This removes obvious secrets; it is not a guarantee: keep the\n * transcript where only the people who need it can read it.\n */\nexport interface ConversationTranscript {\n /** The agent the person was talking to. */\n agentName: string\n /** ISO 8601 time of the first included turn. */\n startedAt: string\n /** ISO 8601 time of the last included turn. */\n endedAt: string\n /** Oldest first. A card the agent showed reads as one line of text. */\n turns: Array<{ role: 'user' | 'agent'; text: string; at: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour. It is also recorded on\n * the step's row — capped, and replaced by its size when it is too large —\n * so the run trace can show what the step produced. Never return a secret\n * from a step: details are rendered verbatim in the Console.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\n | 'received'\n | 'awaiting_approval'\n | 'ready'\n | 'executing'\n | 'finalizing'\n | 'succeeded'\n | 'rejected'\n | 'expired'\n | 'cancelled'\n | 'failed'\n | 'outcome_unknown'\n | 'awaiting_resolution'\n | 'closed_unknown'\n\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * `notify` still arrives with a later slice; `governed` is here.\n */\nexport interface AutomationContext {\n runId: string\n workspaceId: string\n /**\n * The values this run was started with — validated against the manifest's\n * `inputs` schema and fixed on the run row at start, so every resumption\n * and retried attempt sees the same object. `{}` when the manifest declares\n * no inputs. Visible in the run trace by design: never put a secret here —\n * `secret:` grants are the credential path.\n */\n input: Record<string, unknown>\n /** Never rejects — telemetry must not be able to fail a run. */\n log(message: string, data?: Record<string, unknown>): Promise<void>\n agent(slug: string): AgentHandle\n /**\n * Resolve one of THIS run's files to a readable form (signed URL +\n * authoritative mime/size): the value of a `file` input, or one item of a\n * `files` input. No grant — it only reads files the run was given; any other\n * fileId is refused. `null` in a dry dev run.\n */\n file(ref: FileRef): Promise<ResolvedFileHandle | null>\n /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */\n plugin(install: string): PluginHandle\n http: {\n /**\n * Call an allowlisted host, optionally with a workspace secret injected\n * server-side.\n *\n * Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`\n * is used. The secret's VALUE never enters this process — that is deliberate:\n * a credential this process never held cannot be leaked by a stray\n * `ctx.log`, an exception serialiser, or a dependency, and step details are\n * rendered verbatim in the Console.\n *\n * An upstream 4xx/5xx comes back as `status`, not as a throw. An API\n * answering 404 is data; only failures of the mechanism reject.\n */\n fetch(req: HttpRequest): Promise<HttpResponse>\n }\n blueprint: {\n query<T = Record<string, unknown>>(\n objectType: string,\n options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>>\n }\n conversation: {\n /**\n * The conversation that started THIS run, as the person saw it.\n *\n * `null` when the run was not started from a conversation (a schedule, a\n * manual run, an App), when that conversation was deleted, and in dry (dev)\n * runs.\n *\n * Call it inside a `ctx.step.run` step — required, not a style choice.\n * Outside a step it reads the conversation again on every resumption, and\n * what it returns can change between reads: an earlier run from the same\n * conversation that finishes, or reads, in the meantime moves where this\n * run's part begins. Act on it in that same step and return only a\n * summary, because step results are kept to resume the run.\n *\n * When a step returns the transcript,\n * its `turns` or any of its turns — alone or inside objects and arrays up\n * to four levels deep — its trace row records only the turn count; a\n * result too large or too deep to check completely is not recorded at all.\n * Everything else is recorded as written: text copied out of it, including\n * new objects built from its turns (`turns.map(t => ({ role: t.role, text:\n * t.text }))`), a copy restored after the run resumes, an error message\n * built from it, and the Function's final return value (recorded as the\n * run's result). Requires the\n * `conversation:read` grant. Reads only the run's own conversation — there\n * is no way to name another one.\n */\n transcript(): Promise<ConversationTranscript | null>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */\n auth?: { header: string; secret: string; prefix?: string }\n}\n\nexport interface HttpResponse {\n status: number\n headers: Record<string, string>\n /** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a\n * silently shortened response is a wrong answer that looks right. */\n body: string\n}\n\nexport interface BlueprintQueryOptions {\n /** The real Blueprint filter DSL, not a shorthand — `and`/`or`/`not`, ranges\n * and date presets all work. A convenience subset with no escape hatch is the\n * thing the first \"overdue OR flagged\" automation would have to work around. */\n where?: WhereNode\n select?: string[]\n /** `dir`, not `direction` — matches `QueryRequest` exactly. */\n orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>\n /** Default 100, clamped to 1000. Exceeding the cap sets `hasMore`; it never\n * truncates silently. */\n limit?: number\n /** Opaque. Pass back the previous result's `nextPageToken`; absent means the\n * first page. Cursor-based, so a scan stays correct while the table moves\n * underneath it — which a cron-driven automation's table always does. */\n pageToken?: string\n /** Closest first from `nearest.from`, with `_distanceMeters` on each row.\n * `where` must also hold a `nearby` or `withinBbox` on the same location,\n * and `orderBy` must be omitted. */\n nearest?: NearestRequest\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n rows: T[]\n /** True when the query matched more rows than were returned. */\n hasMore: boolean\n /** Present iff `hasMore`. Feed to the next call's `pageToken`.\n *\n * Forwarded rather than narrowed away on purpose: `hasMore` on its own is a\n * fact the author can do nothing about, which is how a digest over the first\n * 500 of 5,000 rows reports success. */\n nextPageToken?: string\n}\n\nexport type AutomationHandler = (ctx: AutomationContext) => Promise<unknown>\n\nexport interface AutomationDescriptor {\n readonly manifest: ResolvedAutomationManifest\n readonly handler: AutomationHandler\n}\n\n/**\n * How a run came to exist, named once so the two sides of the invoke event\n * cannot drift.\n *\n * This is not decoration. The service publishes `source` on the invoke event,\n * the RUNNER resolves it back and posts it to the open-run call, and the\n * service then refuses an invocation ticket arriving under a source that does\n * not expect one. So a source the service knows and the runner does not is not\n * a mis-filed run — it is no run at all: the ticket rides along, the open-run\n * call 400s, and the caller waits forever on a request that never became\n * anything. That is precisely how the App lane shipped broken.\n *\n * Both packages depend on this one, so the list lives here rather than being\n * spelled out in each. Adding a source means adding it here, and the two\n * consumers pick it up by construction.\n *\n * `cron` is deliberately absent: it is what the runner INFERS when the event\n * names no source at all, so it is never carried on an event.\n */\nexport const EVENT_TRIGGER_SOURCES = ['manual', 'rehearsal', 'agent', 'app', 'workflow', 'channel'] as const\nexport type EventTriggerSource = (typeof EVENT_TRIGGER_SOURCES)[number]\n\n/**\n * The sources whose runs MUST arrive with an invocation ticket.\n *\n * A run under one of these has a caller whose identity exists only on the\n * ticket, so a missing one is refused rather than opened unattributed. A ticket\n * under any other source is refused too — it means the event was tampered with\n * or two payloads got mixed.\n */\nexport const TICKETED_TRIGGER_SOURCES = ['agent', 'app'] as const\nexport type TicketedTriggerSource = (typeof TICKETED_TRIGGER_SOURCES)[number]\n\nexport function isEventTriggerSource(value: unknown): value is EventTriggerSource {\n return typeof value === 'string'\n && (EVENT_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n\nexport function isTicketedTriggerSource(value: unknown): value is TicketedTriggerSource {\n return typeof value === 'string'\n && (TICKETED_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n",
|
|
31
|
+
"frontera/automation/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { NearestRequest, WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron, manual, and agent for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n *\n * `agent` is deliberately NOT part of that exclusion. Cron and manual answer\n * \"what fires this on its own\"; `agent: true` answers \"may a bound agent call\n * this\", which is an orthogonal question — a nightly reconciliation that an\n * analyst can also ask an agent to run on demand is one automation, not two.\n * So `agent` rides alongside either, and the third arm exists for the\n * agent-only automation, which has no self-starting trigger at all.\n *\n * Declaring it is only the AUTHOR's half of the permission. A workspace\n * operator must still bind the automation to one named agent before any tool\n * is projected; see the design in\n * docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never; agent?: true }\n | { cron?: never; manual: true; agent?: true }\n | { cron?: never; manual?: never; agent: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n /** Read the conversation that started the run — see `ctx.conversation`. */\n | 'conversation:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these.\n * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.\n * On a `files` field it applies to each file.\n * A declaration aid: the value on the run row is only a reference, so this is\n * enforced server-side at upload and at run start, never against the value here. */\n accept?: readonly string[]\n /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.\n * On a `files` field it applies to each file. */\n maxBytes?: number\n /** `files` type only. The most files the field holds: a whole number from 1\n * to 50, 20 when absent. */\n maxFiles?: number\n /**\n * Mask this field's VALUE wherever a person reads the run.\n *\n * What it changes: the run list, the run page and `frontera function runs`\n * show a placeholder instead of the value. What it does NOT change: the\n * handler, every resumption and every retried attempt receive the real value,\n * because the run row still holds it — this is a display control, not\n * storage encryption and not an access control.\n *\n * What it CANNOT cover, stated here so the flag never reads as a promise it\n * does not keep:\n *\n * - `ctx.log('…', { key: ctx.input.token })` — an author writing a value\n * into a step detail publishes it, and nothing here can intercept that.\n * - a redacted value the author TRANSFORMS before using it. The agent\n * transcript on the run page is masked by exact occurrence, so a value\n * interpolated into a prompt — or quoted back in the reply — is replaced.\n * A value upper-cased, truncated or reformatted first no longer matches\n * and is not found. Exact match is what can be done without guessing at\n * substrings; the alternative, withholding transcripts entirely for any\n * run with a redacted input, would take the review surface away from\n * exactly the runs that most need reviewing.\n * - `default` and `enum`, which are published in the version manifest and in\n * any tool schema built from it. Declaring either alongside `redact` is\n * refused at deploy for exactly that reason.\n * - the run's own `result` and error message. A handler that returns the\n * value — `return { note: ctx.input.customer_note }` — or throws an error\n * quoting it publishes it on the same run page, unmasked, next to the\n * masked input it came from. Only the INPUT is masked; what the handler\n * chooses to emit is the handler's decision.\n * - anything already recorded. Versions are append-only and the mask is\n * frozen onto each run when it starts, so adding `redact` masks future\n * runs and never rewrites history.\n *\n * A credential still belongs in a `secret:` grant, whose value never enters\n * this process at all. `redact` is for the ordinary personal or commercial\n * detail a run legitimately takes and a bystander has no reason to read.\n */\n redact?: boolean\n}\n\n\n/**\n * The value of a `file`-typed input.\n *\n * A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the\n * canonical handle from the platform's unified file registry (see\n * docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in\n * storage; only this small id rides on the run row, so the 64KB input cap is\n * untouched.\n *\n * One reference, two entry points: the run-form uploader gets a `fileId` for a\n * file a person drops in, and an agent passes the `fileId` of a chat attachment\n * it is already holding — both resolve identically downstream. The client never\n * supplies a trusted path or URL; the SERVER authorizes the `fileId` against the\n * caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.\n */\nexport interface FileRef {\n fileId: string\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n /**\n * One headless agent turn. `options.files` hands the agent already-uploaded\n * files by canonical id — the same `{ fileId }` a `file`-typed run input\n * carries, so an input forwards directly: `run(p, { files: [ctx.input.doc] })`.\n * Each id is authorized against this run's workspace and the bytes are staged\n * onto the agent's computer; the agent is told the staged paths.\n *\n * `options.timeoutMs` waits longer than the default 120 s for work known to\n * be long (a long scanned document); the service caps it at 480 s, under\n * the run's own 10-minute ceiling.\n *\n * Call it at the TOP LEVEL of the handler, not inside `ctx.step.run`. There\n * the wait is durable: the turn is started once, the run is parked until it\n * finishes, and a resumption replays the answer instead of asking again.\n * Inside a step body (steps cannot nest) the call waits on one request, and\n * a step that is re-run asks the agent again.\n */\n run(prompt: string, options?: { files?: readonly FileRef[]; timeoutMs?: number }): Promise<{ text: string }>\n}\n\n/**\n * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch\n * its bytes. `null` only in a dry dev run.\n *\n * There is no URL. In the deployed runner a Function cannot open a network\n * socket, and a signed URL would be a live credential; the readers download\n * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a\n * large file need not stay in memory.\n */\nexport interface ResolvedFileHandle {\n fileId: string\n mimeType: string\n sizeBytes: number\n name: string | null\n /**\n * The whole file as bytes.\n *\n * Each reader downloads the file again, through a link that expires about an\n * hour after `ctx.file` returned the handle. Read it soon after, or call\n * `ctx.file` again rather than keeping a handle across a long wait.\n */\n bytes(): Promise<Uint8Array>\n /** The whole file decoded as UTF-8 text. */\n text(): Promise<string>\n /** The file as a stream, for reading large files without holding them in memory. */\n stream(): Promise<ReadableStream<Uint8Array>>\n}\n\n/**\n * The conversation that started a run, as the person saw it: what they wrote\n * and what the agent wrote back, as text.\n *\n * Only the stretch that belongs to this run is included — earlier requests in\n * the same conversation, already handled by earlier runs that read a\n * transcript and finished successfully or are still running, and anything\n * before a quiet gap of more than six hours are left out.\n *\n * Replaced with `[removed]`: card numbers of the major card brands (Visa,\n * Mastercard, American Express, Discover, UnionPay), with the digit groups\n * separated by a single space or dash, or written together (an expiry or\n * security code after the number stays); Singapore NRIC/FIN numbers; and —\n * within the six words after \"password\", \"passcode\", \"PIN\", \"OTP\", \"one-time\n * code\" or \"verification code\" (or the Indonesian and Malay \"kata sandi\",\n * \"sandi\", \"kode verifikasi\", \"kode OTP\", \"kata laluan\", \"kod pengesahan\"), or\n * the first six words of the person's reply right after the agent asks for one\n * of these — any word containing a digit.\n * Words without a digit stay. This removes obvious secrets; it is not a guarantee: keep the\n * transcript where only the people who need it can read it.\n */\nexport interface ConversationTranscript {\n /** The agent the person was talking to. */\n agentName: string\n /** ISO 8601 time of the first included turn. */\n startedAt: string\n /** ISO 8601 time of the last included turn. */\n endedAt: string\n /** Oldest first. A card the agent showed reads as one line of text. */\n turns: Array<{ role: 'user' | 'agent'; text: string; at: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour. It is also recorded on\n * the step's row — capped, and replaced by its size when it is too large —\n * so the run trace can show what the step produced. Never return a secret\n * from a step: details are rendered verbatim in the Console.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\n | 'received'\n | 'awaiting_approval'\n | 'ready'\n | 'executing'\n | 'finalizing'\n | 'succeeded'\n | 'rejected'\n | 'expired'\n | 'cancelled'\n | 'failed'\n | 'outcome_unknown'\n | 'awaiting_resolution'\n | 'closed_unknown'\n\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n /**\n * The Action's inputs by name. An input that attaches a file accepts one of\n * this run's file inputs as passed in — `{ fileId }`, e.g. `ctx.input.photo`.\n */\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * `notify` still arrives with a later slice; `governed` is here.\n */\nexport interface AutomationContext {\n runId: string\n workspaceId: string\n /**\n * The values this run was started with — validated against the manifest's\n * `inputs` schema and fixed on the run row at start, so every resumption\n * and retried attempt sees the same object. `{}` when the manifest declares\n * no inputs. Visible in the run trace by design: never put a secret here —\n * `secret:` grants are the credential path.\n */\n input: Record<string, unknown>\n /** Never rejects — telemetry must not be able to fail a run. */\n log(message: string, data?: Record<string, unknown>): Promise<void>\n agent(slug: string): AgentHandle\n /**\n * Resolve one of THIS run's files to a readable form (signed URL +\n * authoritative mime/size): the value of a `file` input, or one item of a\n * `files` input. No grant — it only reads files the run was given; any other\n * fileId is refused. `null` in a dry dev run.\n */\n file(ref: FileRef): Promise<ResolvedFileHandle | null>\n /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */\n plugin(install: string): PluginHandle\n http: {\n /**\n * Call an allowlisted host, optionally with a workspace secret injected\n * server-side.\n *\n * Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`\n * is used. The secret's VALUE never enters this process — that is deliberate:\n * a credential this process never held cannot be leaked by a stray\n * `ctx.log`, an exception serialiser, or a dependency, and step details are\n * rendered verbatim in the Console.\n *\n * An upstream 4xx/5xx comes back as `status`, not as a throw. An API\n * answering 404 is data; only failures of the mechanism reject.\n */\n fetch(req: HttpRequest): Promise<HttpResponse>\n }\n blueprint: {\n query<T = Record<string, unknown>>(\n objectType: string,\n options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>>\n }\n conversation: {\n /**\n * The conversation that started THIS run, as the person saw it.\n *\n * `null` when the run was not started from a conversation (a schedule, a\n * manual run, an App), when that conversation was deleted, and in dry (dev)\n * runs.\n *\n * Call it inside a `ctx.step.run` step — required, not a style choice.\n * Outside a step it reads the conversation again on every resumption, and\n * what it returns can change between reads: an earlier run from the same\n * conversation that finishes, or reads, in the meantime moves where this\n * run's part begins. Act on it in that same step and return only a\n * summary, because step results are kept to resume the run.\n *\n * When a step returns the transcript,\n * its `turns` or any of its turns — alone or inside objects and arrays up\n * to four levels deep — its trace row records only the turn count; a\n * result too large or too deep to check completely is not recorded at all.\n * Everything else is recorded as written: text copied out of it, including\n * new objects built from its turns (`turns.map(t => ({ role: t.role, text:\n * t.text }))`), a copy restored after the run resumes, an error message\n * built from it, and the Function's final return value (recorded as the\n * run's result). Requires the\n * `conversation:read` grant. Reads only the run's own conversation — there\n * is no way to name another one.\n */\n transcript(): Promise<ConversationTranscript | null>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n *\n * An Action input that attaches a file accepts `{ fileId }` for one of this\n * run's own file inputs; the platform stores it in the Action's media set.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */\n auth?: { header: string; secret: string; prefix?: string }\n}\n\nexport interface HttpResponse {\n status: number\n headers: Record<string, string>\n /** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a\n * silently shortened response is a wrong answer that looks right. */\n body: string\n}\n\nexport interface BlueprintQueryOptions {\n /** The real Blueprint filter DSL, not a shorthand — `and`/`or`/`not`, ranges\n * and date presets all work. A convenience subset with no escape hatch is the\n * thing the first \"overdue OR flagged\" automation would have to work around. */\n where?: WhereNode\n select?: string[]\n /** `dir`, not `direction` — matches `QueryRequest` exactly. */\n orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>\n /** Default 100, clamped to 1000. Exceeding the cap sets `hasMore`; it never\n * truncates silently. */\n limit?: number\n /** Opaque. Pass back the previous result's `nextPageToken`; absent means the\n * first page. Cursor-based, so a scan stays correct while the table moves\n * underneath it — which a cron-driven automation's table always does. */\n pageToken?: string\n /** Closest first from `nearest.from`, with `_distanceMeters` on each row.\n * `where` must also hold a `nearby` or `withinBbox` on the same location,\n * and `orderBy` must be omitted. */\n nearest?: NearestRequest\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n rows: T[]\n /** True when the query matched more rows than were returned. */\n hasMore: boolean\n /** Present iff `hasMore`. Feed to the next call's `pageToken`.\n *\n * Forwarded rather than narrowed away on purpose: `hasMore` on its own is a\n * fact the author can do nothing about, which is how a digest over the first\n * 500 of 5,000 rows reports success. */\n nextPageToken?: string\n}\n\nexport type AutomationHandler = (ctx: AutomationContext) => Promise<unknown>\n\nexport interface AutomationDescriptor {\n readonly manifest: ResolvedAutomationManifest\n readonly handler: AutomationHandler\n}\n\n/**\n * How a run came to exist, named once so the two sides of the invoke event\n * cannot drift.\n *\n * This is not decoration. The service publishes `source` on the invoke event,\n * the RUNNER resolves it back and posts it to the open-run call, and the\n * service then refuses an invocation ticket arriving under a source that does\n * not expect one. So a source the service knows and the runner does not is not\n * a mis-filed run — it is no run at all: the ticket rides along, the open-run\n * call 400s, and the caller waits forever on a request that never became\n * anything. That is precisely how the App lane shipped broken.\n *\n * Both packages depend on this one, so the list lives here rather than being\n * spelled out in each. Adding a source means adding it here, and the two\n * consumers pick it up by construction.\n *\n * `cron` is deliberately absent: it is what the runner INFERS when the event\n * names no source at all, so it is never carried on an event.\n */\nexport const EVENT_TRIGGER_SOURCES = ['manual', 'rehearsal', 'agent', 'app', 'workflow', 'channel'] as const\nexport type EventTriggerSource = (typeof EVENT_TRIGGER_SOURCES)[number]\n\n/**\n * The sources whose runs MUST arrive with an invocation ticket.\n *\n * A run under one of these has a caller whose identity exists only on the\n * ticket, so a missing one is refused rather than opened unattributed. A ticket\n * under any other source is refused too — it means the event was tampered with\n * or two payloads got mixed.\n */\nexport const TICKETED_TRIGGER_SOURCES = ['agent', 'app'] as const\nexport type TicketedTriggerSource = (typeof TICKETED_TRIGGER_SOURCES)[number]\n\nexport function isEventTriggerSource(value: unknown): value is EventTriggerSource {\n return typeof value === 'string'\n && (EVENT_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n\nexport function isTicketedTriggerSource(value: unknown): value is TicketedTriggerSource {\n return typeof value === 'string'\n && (TICKETED_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n",
|
|
32
32
|
"frontera/automation/index.ts": "export { automation, defineFunction } from './define'\nexport { validateManifest } from './manifest'\nexport type { ValidationResult } from './manifest'\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nexport { createTestContext } from './testing'\nexport type { TestCall, TestContext, TestContextOptions } from './testing'\nexport {\n DEFAULT_MAX_FILES,\n MAX_FILES_CAP,\n MAX_INPUT_BYTES,\n checkInputFieldSpec,\n isFileInputType,\n maxFilesOf,\n redactedInputKeys,\n sanitizeInputsSchema,\n validateInputValue,\n} from './inputs'\nexport type { InputFieldCheck, InputValidation } from './inputs'\nexport {\n EVENT_TRIGGER_SOURCES,\n TICKETED_TRIGGER_SOURCES,\n isEventTriggerSource,\n isTicketedTriggerSource,\n} from './types'\nexport type * from './types'\n",
|
|
33
33
|
"frontera/automation/define.ts": "import type {\n AutomationDescriptor,\n AutomationHandler,\n AutomationManifest,\n AutomationTrigger,\n InputsSchema,\n} from './types'\n\n/**\n * Declare a Function: a manifest and the handler the runner executes.\n *\n * Named `automation` until the rename; that name is still exported below as a\n * deprecated alias, because a project scaffolded before the rename imports it\n * and `frontera function pull` replays a stored archive verbatim.\n */\nexport function defineFunction(\n manifest: AutomationManifest,\n handler: AutomationHandler,\n): AutomationDescriptor {\n if (!manifest?.name) throw new Error('Automation name is required')\n if (typeof handler !== 'function') throw new Error('Automation handler must be a function')\n\n // Copied, not aliased. `{ ...manifest }` is shallow, so without this the\n // frozen manifest would still hold a live reference to the caller's trigger\n // object — and a later mutation of it would silently change the schedule the\n // runner registers.\n const trigger: AutomationTrigger = { ...manifest.trigger }\n\n let inputs: Readonly<InputsSchema> | undefined\n if (manifest.inputs) {\n try {\n inputs = Object.freeze(structuredClone(manifest.inputs))\n } catch {\n // `structuredClone` throws a raw `DataCloneError` DOMException on a\n // function/symbol/etc default, which names neither the automation nor\n // the field — useless in a deploy log. Rethrow with both.\n throw new Error(`Automation \"${manifest.name}\": input defaults must be JSON-serializable values`)\n }\n }\n\n return Object.freeze({\n manifest: Object.freeze({\n ...manifest,\n trigger: Object.freeze(trigger),\n grants: Object.freeze([...(manifest.grants ?? [])]),\n ...(inputs ? { inputs } : {}),\n concurrency: manifest.concurrency ?? 1,\n retries: manifest.retries ?? 0,\n }),\n handler,\n })\n}\n\n/**\n * @deprecated Use `defineFunction`. Kept so a project written against\n * `@frontera-sdk/functions` still compiles after `frontera function pull`\n * hydrates it, and so a bundle built from one still parses — `step-graph.ts`\n * matches both names for exactly this reason.\n */\nexport const automation = defineFunction\n",
|
|
34
34
|
"theme.css": "/* GENERATED from packages/platform-kit/src/styles/theme.css — do not edit.\n * Refresh with `frontera app add theme`.\n *\n * Gives an app the same Tailwind utilities and design tokens the platform\n * uses, so copied components look native rather than unstyled. Import this\n * once from your entry (`import './theme.css'`).\n */\n@import \"tailwindcss\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n --color-dot: var(--dot-foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-inline-code: var(--inline-code);\n --color-inline-code-bg: var(--inline-code-bg);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n --color-info: var(--info);\n --color-info-foreground: var(--info-foreground);\n --color-border: var(--border);\n --color-border-secondary: var(--border-secondary);\n --color-border-strong: var(--border-strong);\n --color-input: var(--input);\n --color-ring: var(--ring);\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n --color-chart-6: var(--chart-6);\n --color-chart-7: var(--chart-7);\n --color-chart-8: var(--chart-8);\n --color-chart-9: var(--chart-9);\n --color-chart-10: var(--chart-10);\n --color-chart-11: var(--chart-11);\n --color-chart-12: var(--chart-12);\n /* Stable application identities, independent of the selected accent/theme. */\n --color-application-chat: var(--application-chat);\n --color-application-agents: var(--application-agents);\n --color-application-workflows: var(--application-workflows);\n --color-application-functions: var(--application-functions);\n --color-application-blueprint: var(--application-blueprint);\n --color-application-apps: var(--application-apps);\n --color-application-console: var(--application-console);\n --color-application-forge: var(--application-forge);\n --color-application-home: var(--application-home);\n --color-application-foreground: var(--application-foreground);\n --color-application-highlight: var(--application-highlight);\n --color-application-shade: var(--application-shade);\n /* Capability tints — UI chrome, kept out of the chart ramp on purpose. */\n --color-capability-data: var(--capability-data);\n --color-capability-run: var(--capability-run);\n --color-capability-reach: var(--capability-reach);\n --color-capability-act: var(--capability-act);\n --color-capability-compute: var(--capability-compute);\n --color-capability-flow: var(--capability-flow);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n --radius-2xl: calc(var(--radius) + 8px);\n\n /* next/font variables live on <html>. Inline so `font-mono` and preflight\n `code`/`pre` use Geist (then a real mono stack) rather than Tailwind's\n default, and never a proportional face. */\n --font-mono: var(--font-brand-mono, var(--font-geist-mono)), ui-monospace,\n SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\",\n monospace;\n\n /* Shadow elevation tokens — Attio-inspired multi-layer */\n /* --shadow-xs: var(--elevation-xs);\n --shadow-sm: var(--elevation-sm);\n --shadow-md: var(--elevation-md);\n --shadow-lg: var(--elevation-lg);\n --shadow-xl: var(--elevation-xl); */\n\n /* Surface elevation tokens */\n --color-surface-inset-deep: var(--surface-inset-deep);\n --color-surface-inset-deep-hover: var(--surface-inset-deep-hover);\n --color-surface-inset-deep-active: var(--surface-inset-deep-active);\n --color-surface-inset: var(--surface-inset);\n --color-surface-inset-hover: var(--surface-inset-hover);\n --color-surface-inset-active: var(--surface-inset-active);\n --color-surface: var(--surface);\n --color-surface-chat: var(--surface-chat);\n --color-surface-hover: var(--surface-hover);\n --color-surface-active: var(--surface-active);\n --color-surface-raised: var(--surface-raised);\n --color-surface-raised-hover: var(--surface-raised-hover);\n --color-surface-raised-active: var(--surface-raised-active);\n /* --color-surface-overlay: var(--surface-raised);\n --color-surface-overlay-hover: var(--surface-raised-hover);\n --color-surface-overlay-active: var(--surface-raised-active); */\n --color-surface-overlay: var(--surface-overlay);\n --color-surface-overlay-hover: var(--surface-overlay-hover);\n --color-surface-overlay-active: var(--surface-overlay-active);\n /* The modal scrim, shared by every Dialog and Sheet. */\n --color-scrim: var(--scrim);\n /* Quint-out. For transitions long enough (250ms+) that Tailwind's `ease-out`\n — cubic-bezier(0, 0, 0.2, 1) — reads as near-constant motion: this covers\n 58% of the distance in the first 15% of the time against ease-out's 37%,\n so the element commits immediately and then settles. Use it for a surface\n opening or expanding; keep `ease-out` for short state changes, where the\n difference isn't perceptible. */\n --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);\n /* Gentler than quint: still decelerates, but spreads the travel over the\n duration instead of spending it in the first frames. */\n --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);\n\n /* Image lightbox chrome sits over arbitrary pixels while still matching the\n active light/dark theme. */\n --color-image-overlay-scrim: var(--image-overlay-scrim);\n --color-image-overlay-control: var(--image-overlay-control);\n --color-image-overlay-control-hover: var(--image-overlay-control-hover);\n --color-image-overlay-border: var(--image-overlay-border);\n --color-image-overlay-foreground: var(--image-overlay-foreground);\n --color-image-overlay-muted: var(--image-overlay-muted);\n --color-image-overlay-thumb: var(--image-overlay-thumb);\n --color-image-checker-a: var(--image-checker-a);\n --color-image-checker-b: var(--image-checker-b);\n /* Range sliders: the handle stays white in both themes, so the dark fill is\n a mid grey rather than the near-white primary it would vanish against. */\n --color-slider-fill: var(--slider-fill);\n --color-slider-thumb: var(--slider-thumb);\n}\n\n:root {\n --radius: 0.625rem;\n\n /* Widest a config-row chip (skill / pack / app / context) may grow before\n its label truncates. Roughly 28 characters at text-xs — long enough to\n read a name, short enough that one chip can't monopolise the row. */\n --chip-max-width: 13rem;\n\n /* Bottom padding every `/console` page ends with, so a scrolled list never\n stops flush against the panel edge and the last row clears the home\n indicator on a notched phone. ONE value at every breakpoint — \"the same\n bottom padding on every console page\" is the whole point of it, and\n `env()` resolves to 0 off a notched device.\n\n Reach for the `console-page` utility below, which already applies this.\n Use `pb-(--console-page-tail)` directly only where the container sets no\n padding shorthand of its own — a bare `pb-*` is emitted with the\n unprefixed utilities and so LOSES to a later `sm:p-*`/`md:py-*` on the\n same element. That is measured, not assumed: in the built stylesheet the\n unprefixed padding block sits around 250kB and `sm:p-4` at 402kB. */\n --console-page-tail: calc(2rem + env(safe-area-inset-bottom));\n\n --background: 0 0% 97%;\n\n /* React flow canvas tokens */\n --dot-foreground: #c9c9c9;\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0 0% 93%);\n --surface-inset-deep-hover: hsl(0 0% 89%);\n --surface-inset-deep-active: hsl(0 0% 86%);\n --surface-inset: hsl(0 0% 96%);\n --surface-inset-hover: hsl(0 0% 91%);\n --surface-inset-active: hsl(0 0% 88%);\n --surface: hsl(0 0% 98%);\n /* Chat-view backdrop — slightly warmer off-white than --surface, light only. */\n --surface-chat: #f9f9f9;\n --surface-hover: hsl(0 0% 96%);\n --surface-active: hsl(0 0% 94%);\n --surface-raised: hsl(0 0% 100%);\n --surface-raised-hover: hsl(0 0% 96%);\n --surface-raised-active: hsl(0 0% 94%);\n --surface-overlay: hsl(0 0% 100%);\n --surface-overlay-hover: hsl(0 0% 95%);\n --surface-overlay-active: hsl(0 0% 91%);\n /* ── Modal scrim ────────────────────────────────────────────────\n ONE value for every Dialog and Sheet. Was `bg-surface-inset-deep/50`\n written inline in both primitives, which in light mode is a 93% grey at\n half alpha — a wash, not a scrim, and it left surfaces that had opted into\n something darker looking like a different app. Black in both themes: what\n a scrim has to do is push the page back, and only black does that at a\n usable alpha in light. */\n --scrim: hsl(0 0% 0% / 60%);\n\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: var(--surface-overlay);\n --image-overlay-control-hover: var(--surface-overlay-hover);\n --image-overlay-border: var(--border);\n --image-overlay-foreground: hsl(var(--foreground));\n --image-overlay-muted: var(--muted-foreground);\n --image-overlay-thumb: var(--surface-inset);\n --slider-fill: var(--primary);\n --slider-thumb: hsl(0 0% 100%);\n /* Transparency checkerboard, following the theme (.dark overrides below).\n The scrim is 70% black, which composites over the page rather than\n replacing it, so the lightbox backdrop lands near 30% lightness in light\n theme and near black in dark — a light checker reads against one and a\n dark checker against the other.\n\n Known tradeoff, chosen deliberately: a theme-following check sits close in\n lightness to same-polarity artwork, so a white-ink transparent logo is\n weak on the light check (and a black-ink one on the dark check). A neutral\n mid-grey avoids that but reads as theme-agnostic. If the washout ever\n matters more than the theme character, pull both pairs toward mid —\n 62/82 and 34/54 keep both polarities legible.\n\n Light is the Photoshop/Figma convention (#ccc on #fff). It matters that\n the lighter square is pure white: the chat surface is #f9f9f9 (97.6%), so\n a checker whose mean sits far below that reads as a dark patch inset into\n the page rather than as transparency. Keep the two squares ~20 lightness\n points apart in light and ~17 in dark. */\n --image-checker-a: hsl(0 0% 80%);\n --image-checker-b: hsl(0 0% 100%);\n\n /* ── Shadow elevations: (light) ───── */\n --elevation-xs: 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-sm:\n 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-md:\n 0px 8px 12px 0px rgba(25, 25, 25, 0.027),\n 0px 2px 6px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-lg:\n 0px 20px 24px 0px rgba(25, 25, 25, 0.05),\n 0px 5px 8px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-xl:\n 0px 24px 48px 0px rgba(25, 25, 25, 0.24),\n 0px 4px 12px 0px rgba(25, 25, 25, 0.14),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --foreground: 47 13% 14%;\n --card: var(--surface-raised);\n --card-foreground: 47 13% 14%;\n --popover: oklch(1 0 0);\n --popover-foreground: 47 13% 14%;\n --primary: oklch(0.21 0.006 285.885);\n --primary-foreground: oklch(0.985 0 0);\n --secondary: oklch(0.967 0.001 286.375);\n --secondary-foreground: oklch(0.21 0.006 285.885);\n --muted: oklch(0.967 0.001 286.375);\n --muted-foreground: oklch(0.5 0.016 285.938);\n --accent: oklch(0.967 0.001 286.375);\n --accent-foreground: 47 13% 14%;\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.627 0.194 149.214);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.735 0.166 70.67);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.6 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n /* Inline code chips in rendered markdown (chat, previews): warm code accent\n on a fill one step off the bubble so the chip reads on raised surfaces. */\n --inline-code: oklch(0.55 0.17 25);\n --inline-code-bg: hsl(0 0% 95.5%);\n --border: hsl(240 100 6 / 0.05);\n --border-secondary: hsl(214 32% 96%);\n /* The edge of a top-level surface against the page ground, rather than a\n divider drawn ON a raised surface. `--border` is tuned for the latter and\n is too faint to read as an outer edge at 0.05/0.08. */\n --border-strong: hsl(240 100 6 / 0.11);\n --input: oklch(0.92 0.004 286.32 / 0.75);\n --ring: oklch(0.705 0.015 286.067);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n /* Dashboard analytics series — extends the fixed chart palette. */\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Application glyph palette: darker accents on softly tinted light tiles. */\n --application-chat: oklch(0.55 0.15 155);\n --application-agents: oklch(0.54 0.20 292);\n --application-workflows: oklch(0.60 0.17 48);\n --application-functions: oklch(0.49 0.19 268);\n --application-blueprint: oklch(0.55 0.20 255);\n --application-apps: oklch(0.52 0.12 165);\n --application-console: oklch(0.40 0.025 260);\n --application-forge: oklch(0.53 0.19 25);\n --application-home: oklch(0.50 0.025 75);\n --application-foreground: oklch(0.99 0 0);\n --application-highlight: oklch(1 0 0);\n --application-shade: oklch(0 0 0);\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.985 0 0);\n --sidebar-foreground: oklch(0.141 0.005 285.823);\n --sidebar-primary: oklch(0.21 0.006 285.885);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.967 0.001 286.375);\n --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n --sidebar-border: oklch(0.92 0.004 286.32);\n --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n.dark {\n --background: 0 0% 6%;\n\n /* React flow canvas tokens */\n --dot-foreground: #363636;\n\n /* ── Shadow elevations (dark) ───────────────────── */\n --elevation-xs: 0 0 0 1px #383836;\n\n --elevation-sm: 0 0 0 1px #383836, 0 2px 4px 0 rgba(0, 0, 0, 0.18);\n\n --elevation-md:\n 0 0 0 1px #383836, 0 8px 16px -4px rgba(0, 0, 0, 0.36),\n 0 2px 6px -2px rgba(0, 0, 0, 0.28);\n\n --elevation-lg:\n 0 0 0 1px #383836, 0 14px 28px -6px rgba(0, 0, 0, 0.44),\n 0 2px 4px -1px rgba(0, 0, 0, 0.28);\n\n --elevation-xl:\n 0 0 0 1px #383836, 0 24px 48px 0 rgba(0, 0, 0, 0.56),\n 0 4px 12px 0 rgba(0, 0, 0, 0.4);\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0, 0%, 6%);\n --surface-inset-deep-hover: hsl(0 0% 8%);\n --surface-inset-deep-active: hsl(0 0% 11%);\n --surface-inset: hsl(0 0% 7.5%);\n --surface-inset-hover: hsl(0 0% 10%);\n --surface-inset-active: hsl(0 0% 12%);\n --surface: hsl(0 0% 9%);\n /* Dark mirrors --surface — chat backdrop is only retuned in light. */\n --surface-chat: hsl(0 0% 9%);\n --surface-hover: hsl(0 0% 14%);\n --surface-active: hsl(0 0% 17%);\n --surface-raised: hsl(0 0% 13%);\n --surface-raised-hover: hsl(0 0% 15%);\n --surface-raised-active: hsl(0 0% 17%);\n --surface-overlay: hsl(0 0% 9%);\n --surface-overlay-hover: hsl(0 0% 15%);\n --surface-overlay-active: hsl(0 0% 19%);\n --scrim: hsl(0 0% 0% / 60%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: hsl(0 0% 12% / 96%);\n --image-overlay-control-hover: hsl(0 0% 18% / 98%);\n --image-overlay-border: hsl(0 0% 100% / 12%);\n --image-overlay-foreground: hsl(0 0% 100%);\n --image-overlay-muted: hsl(0 0% 100% / 65%);\n --image-overlay-thumb: hsl(0 0% 0% / 35%);\n --slider-fill: hsl(0 0% 50%);\n --image-checker-a: hsl(0 0% 22%);\n --image-checker-b: hsl(0 0% 39%);\n\n --foreground: 0 0% 90%;\n --card: var(--surface-raised);\n --card-foreground: hsl(0 0% 98%);\n --popover: hsl(0 0% 7%);\n --popover-foreground: hsl(0 0% 98%);\n --primary: hsl(0 0% 90%);\n --primary-foreground: hsl(0 0% 5%);\n --secondary: hsl(0 0% 18%);\n --secondary-foreground: hsl(0 0% 98%);\n --muted: hsl(0 0% 9%);\n --muted-foreground: hsl(0 0% 52%);\n --accent: hsl(0 0% 9%);\n --accent-foreground: hsl(0 0% 98%);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.723 0.191 142.542);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.815 0.152 78.2);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.7 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --inline-code: oklch(0.75 0.12 25);\n --inline-code-bg: hsl(0 0% 17.5%);\n --border: oklch(0.9296 0.007 106.53 / 0.08);\n --border-secondary: oklch(0.9296 0.007 106.53 / 0.03);\n --border-strong: oklch(0.9296 0.007 106.53 / 0.16);\n --input: oklch(1 0 0 / 10%);\n --ring: oklch(0.552 0.016 285.938);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Same application hues, lifted for legible glyphs on dark surfaces. */\n --application-chat: oklch(0.74 0.14 155);\n --application-agents: oklch(0.72 0.16 292);\n --application-workflows: oklch(0.76 0.14 48);\n --application-functions: oklch(0.73 0.14 268);\n --application-blueprint: oklch(0.73 0.15 255);\n --application-apps: oklch(0.74 0.14 165);\n --application-console: oklch(0.73 0.025 260);\n --application-forge: oklch(0.73 0.16 25);\n --application-home: oklch(0.73 0.025 75);\n --application-foreground: oklch(0.99 0 0);\n --application-highlight: oklch(1 0 0);\n --application-shade: oklch(0 0 0);\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.21 0.006 285.885);\n --sidebar-foreground: oklch(0.985 0 0);\n --sidebar-primary: oklch(0.488 0.243 264.376);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.274 0.006 286.033);\n --sidebar-accent-foreground: oklch(0.985 0 0);\n --sidebar-border: oklch(1 0 0 / 10%);\n --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n * {\n border-color: var(--border);\n }\n body {\n background: var(--app-canvas, var(--surface));\n color: var(--foreground);\n font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);\n -webkit-font-smoothing: antialiased;\n }\n}\n\n/* ── Make it the CUSTOMER'S app, not ours ────────────────────────────────\n *\n * Everything above is a DEFAULT, not a house style. A Frontera app should\n * look like the product it belongs to, so override any token below in your\n * own stylesheet, imported after this file:\n *\n * :root {\n * --primary: #0b5fff; [your brand]\n * --radius: 0.25rem; [your shape]\n * --font-sans: \"Inter\", sans-serif;\n * --app-canvas: #f7f8fa; [page background]\n * --app-gutter: 2rem; [page padding]\n * --app-radius: 0.25rem; [card and tile corners]\n * --app-section-gap: 2rem; [vertical rhythm]\n * }\n *\n * The components read tokens, never literals, so redefining these restyles\n * the whole app without forking a single component. The app-* tokens are the\n * layout knobs the app-kit exposes; the rest are the shared semantic set.\n */\n"
|