@frontera-sdk/cli 1.51.0 → 1.51.1

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.51.0",
3
+ "version": "1.51.1",
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.51.0",
43
- "@frontera-sdk/core": "1.51.0",
42
+ "@frontera-sdk/functions": "1.51.1",
43
+ "@frontera-sdk/core": "1.51.1",
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.51.0",
49
+ "@frontera-sdk/forge-contracts": "1.51.1",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -25,11 +25,11 @@
25
25
  "frontera/automation/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
26
26
  "frontera/automation/testing.ts": "import { AsyncLocalStorage } from 'node:async_hooks'\nimport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n fileHandleInStepMessage,\n missingGrantMessage,\n oldFileStubMessage,\n submitOutsideStepMessage,\n} from './messages'\nimport { asFileHandle, carriesFileHandle } from './runtime-context'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n ConversationTranscript,\n Grant,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\n/**\n * A `ctx` you can hand your handler in a unit test.\n *\n * Until this existed the only way to find out whether an automation worked was\n * to deploy it and run it — a loop measured in tens of seconds, against real\n * data, for a question as small as \"does the empty branch return the right\n * shape\".\n *\n * It enforces what the platform enforces, in the platform's own words: a\n * missing grant and a repeated step name fail here exactly as they fail in\n * production, so a green test means something.\n *\n * What it does NOT simulate is resumption. In production a handler is re-entered\n * after every step, so code outside a step runs many times; here the handler is\n * called once, straight through. Steps still memoize by name within the run, and\n * everything the handler did is recorded on `calls`.\n */\n\n/**\n * A call REFUSED before it happened is not recorded.\n *\n * A missing grant, a missing stub, a submit outside a step, a duplicate\n * submission — none of these appear in `calls`, because none of them did\n * anything. A real run differs here in one direction worth knowing: it writes\n * an errored ctx-call row for a refused `ctx.action.submit`, so the Console\n * trace shows the attempt where this list does not. Assert on the thrown error\n * for a refusal, and on `calls` for what ran.\n */\nexport interface TestCall {\n kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file' | 'conversation'\n /** Step name, log message, agent slug, `install:capability`, URL, object type, Action apiName, or `transcript`. */\n label: string\n /** Present on a step: how it ended. */\n status?: 'ok' | 'error'\n}\n\n\nexport interface TestContextOptions {\n runId?: string\n workspaceId?: string\n /** What the run was started with. Passed through verbatim — a unit test\n * states exactly what the handler sees; defaults are `startRun`'s job. */\n input?: Record<string, unknown>\n /**\n * The grants the manifest declares.\n *\n * Given, they are enforced — which is the point: a missing grant is one of\n * the few automation bugs that only shows up in a deployed run, and it is\n * exactly the kind a unit test should catch.\n *\n * Omitted, nothing is refused, so an existing test does not have to enumerate\n * grants to keep passing.\n */\n grants?: readonly Grant[]\n /** Per-slug agent answers. An unstubbed agent throws rather than answering. */\n agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>\n /** Per-fileId answers for `ctx.file`: what the resolved handle should carry.\n * An unstubbed fileId throws — a fabricated handle is a false pass. */\n files?: Record<string, { content: string | Uint8Array; mimeType: string; name: string | null }>\n /**\n * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.\n * An unstubbed capability throws rather than answering — a fabricated\n * `{ data: {} }` is a test that passes while asserting nothing.\n */\n plugins?: Record<\n string,\n Record<string, (input: Record<string, unknown>) => Promise<PluginCallResult> | PluginCallResult>\n >\n /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */\n http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse\n /**\n * What `ctx.conversation.transcript()` returns. Omitted, it returns `null` —\n * a run that was not started from a conversation, which is a real answer and\n * a branch worth testing.\n */\n conversation?: ConversationTranscript | null\n /** Rows per object type. An unstubbed type returns no rows, which is a real\n * answer and usually the branch worth testing. */\n blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>\n /**\n * Per-apiName Action outcomes. An unstubbed Action throws rather than\n * answering.\n *\n * Throws for the same reason the agent stub does, and the reason is sharper\n * here: the returned `lifecycle` is a branch an author writes code against —\n * `awaiting_approval` means a human still has to decide — so inventing\n * `ready` would silently pick one arm and pass.\n */\n actions?: Record<\n string,\n (request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult\n >\n}\n\nexport interface TestContext {\n ctx: AutomationContext\n /** Everything the handler did, in order. */\n calls: TestCall[]\n /** Step names, in the order they ran. */\n steps: string[]\n logs: Array<{ message: string; data?: Record<string, unknown> }>\n}\n\nexport function createTestContext(options: TestContextOptions = {}): TestContext {\n const calls: TestCall[] = []\n const steps: string[] = []\n const logs: TestContext['logs'] = []\n const seenNames = new Set<string>()\n /**\n * Which step the running code is inside.\n *\n * `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.\n * A stack gets the concurrent case wrong in the direction that matters:\n * `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and\n * with a shared mutable stack the bare submit sees `a` open and is allowed —\n * so the test double passes what production refuses, which is the one failure\n * mode a test double must not have.\n *\n * Enforced here for the same reason grants and duplicate names are: a rule\n * the unit test does not apply is a rule the author meets for the first time\n * in a deployed run.\n */\n const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()\n\n const requireGrant = (grant: string): void => {\n // No grant list means the test is not about grants. Enforcing an empty list\n // would fail every existing test for a reason its author never chose.\n if (!options.grants) return\n if (!options.grants.includes(grant as Grant)) throw new Error(missingGrantMessage(grant))\n }\n\n const ctx: AutomationContext = {\n runId: options.runId ?? 'test-run',\n workspaceId: options.workspaceId ?? 'test-workspace',\n input: options.input ?? {},\n\n step: {\n async run<T>(name: string, fn: () => Promise<T>): Promise<T> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {\n try {\n const out = await fn()\n // Nothing here replays a step, so the handle would keep working in\n // a test and break in a deployed run.\n if (carriesFileHandle(out)) throw new Error(fileHandleInStepMessage(name))\n calls.push({ kind: 'step', label: name, status: 'ok' })\n return out\n } catch (err) {\n calls.push({ kind: 'step', label: name, status: 'error' })\n throw err\n }\n })\n },\n\n async sleep(name: string): Promise<void> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n // Recorded, never waited: a test suite that really slept out its\n // backoffs would take minutes to say nothing.\n calls.push({ kind: 'step', label: name, status: 'ok' })\n },\n },\n\n async log(message, data) {\n logs.push({ message, ...(data ? { data } : {}) })\n calls.push({ kind: 'log', label: message })\n },\n\n async file(ref: { fileId: string }) {\n calls.push({ kind: 'file', label: ref.fileId })\n const stub = options.files?.[ref.fileId]\n // Throwing beats a fabricated handle: a test that reads a file it never\n // stubbed would otherwise pass on made-up bytes.\n if (!stub) {\n throw new Error(\n `No file stub for \"${ref.fileId}\". Pass files: { '${ref.fileId}': { content: '…', ` +\n \"mimeType: '…', name: null } } to createTestContext.\",\n )\n }\n // A stub written for the handle's old shape ({ signedUrl, sizeBytes })\n // has no content; without this, reading it fails as a bare TypeError.\n if (typeof stub.content !== 'string' && !(stub.content instanceof Uint8Array)) {\n throw new Error(oldFileStubMessage(ref.fileId))\n }\n const bytes = typeof stub.content === 'string' ? new TextEncoder().encode(stub.content) : stub.content\n return asFileHandle({\n fileId: ref.fileId,\n mimeType: stub.mimeType,\n name: stub.name,\n sizeBytes: bytes.byteLength,\n bytes: async () => bytes,\n text: async () => new TextDecoder().decode(bytes),\n stream: async () => new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(bytes); controller.close() } }),\n })\n },\n\n agent(slug: string) {\n return {\n async run(prompt: string) {\n requireGrant(`agent:${slug}:run`)\n calls.push({ kind: 'agent', label: slug })\n const stub = options.agents?.[slug]\n // Throwing beats answering with an empty string: a test whose agent\n // silently returns '' passes while asserting nothing about the step\n // that matters most.\n if (!stub) {\n throw new Error(\n `No agent stub for \"${slug}\". Pass agents: { '${slug}': () => ({ text: '…' }) } ` +\n 'to createTestContext.',\n )\n }\n return await stub(prompt)\n },\n }\n },\n\n plugin(install: string) {\n return {\n async call<T = unknown>(capability: string, input?: Record<string, unknown>) {\n requireGrant(`plugin:${install}:${capability}`)\n const stub = options.plugins?.[install]?.[capability]\n // Before the record, matching the contract on `TestCall` and the\n // `action` arm. (`agent` and `http` record first — a pre-existing\n // divergence.)\n if (!stub) {\n throw new Error(\n `No plugin stub for \"${install}\".${capability}. Pass ` +\n // Quoted, unlike a bare identifier: an install name defaults to\n // the catalog kind (kebab, e.g. \"github-prod\") and a capability\n // can be dotted (\"run.query\") — neither survives as an object\n // key without quotes, so the unquoted form the author would\n // paste back in does not parse.\n `plugins: { '${install}': { '${capability}': () => ({ data: … }) } } to createTestContext.`,\n )\n }\n calls.push({ kind: 'plugin', label: `${install}:${capability}` })\n return (await stub(input ?? {})) as PluginCallResult<T>\n },\n }\n },\n\n http: {\n async fetch(req: HttpRequest) {\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 calls.push({ kind: 'http', label: req.url })\n // Same reasoning as the agent: a fabricated 200 is a false pass.\n if (!options.http) {\n throw new Error(\n `No http stub. Pass http: (req) => ({ status: 200, headers: {}, body: '' }) ` +\n 'to createTestContext.',\n )\n }\n return await options.http(req)\n },\n },\n\n action: {\n async submit(request: ActionSubmission): Promise<ActionSubmitResult> {\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessage(request.action))\n // The batch loop is the shape this catches, and a one-row fixture never\n // reaches it — so the double has to enforce it or an author meets it\n // for the first time on their second production row, after the first\n // has already been applied.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessage(request.action))\n }\n requireGrant(`governed:${request.action}`)\n const stub = options.actions?.[request.action]\n // Both refusals that mean \"this never happened\" come BEFORE the\n // reservation, matching the runtime's grant check: reserving first left\n // an author who fixed the missing stub and re-ran a loop facing a\n // duplicate accusation for a call that never answered.\n if (!stub) {\n throw new Error(\n `No action stub for \"${request.action}\". Pass actions: { '${request.action}': ` +\n \"() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.\",\n )\n }\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // Reserved synchronously and released on failure, matching the runtime\n // exactly. A double that checked and recorded across an await would let\n // `Promise.all([submit(x), submit(x)])` through — and a double that\n // permits what production refuses is the one failure mode a double must\n // not have.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessage(request.action))\n }\n scope.submitted.add(submissionIdentity)\n calls.push({ kind: 'action', label: request.action })\n try {\n return await stub(request)\n } catch (err) {\n // A throwing stub stands in for a submission that never landed.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n },\n },\n\n blueprint: {\n async query<T = Record<string, unknown>>(\n objectType: string,\n _options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>> {\n requireGrant('blueprint:read')\n calls.push({ kind: 'blueprint', label: objectType })\n const stub = options.blueprint?.[objectType]\n // Empty is a real answer, and the branch an author most often forgets\n // to test — so this one defaults rather than throwing.\n return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>\n },\n },\n\n conversation: {\n async transcript(): Promise<ConversationTranscript | null> {\n requireGrant('conversation:read')\n calls.push({ kind: 'conversation', label: 'transcript' })\n return options.conversation ?? null\n },\n },\n }\n\n return { ctx, calls, steps, logs }\n}\n",
27
27
  "frontera/automation/messages.ts": "/**\n * Everything an author reads when the platform turns their code away.\n *\n * They live in the SDK, not in the runner, because three surfaces have to say\n * exactly the same sentence: the runner refusing a call before it makes it, the\n * service refusing it after, and `createTestContext` refusing it on the author's\n * own machine. Three copies of a message drift, and a test that fails with\n * different words than production is a test that teaches the wrong lesson.\n */\n\n/**\n * A `ctx` call the manifest does not permit.\n *\n * Names the fix, not the verdict: the author is looking at CLI output, and\n * \"missing grant\" without the remedy costs them a round trip through the docs.\n */\nexport function missingGrantMessage(grant: string): string {\n return `Automation is missing the \"${grant}\" grant. Add it to the manifest and redeploy.`\n}\n\n/**\n * One step name used twice in a run.\n *\n * The platform memoizes by name, so the second call would return the FIRST\n * step's result — no error, no warning, a wrong value flowing on. The remedy\n * names THIS step rather than a placeholder, because a hint that reads as code\n * to paste gets pasted.\n */\nexport function duplicateStepMessage(name: string): string {\n return (\n `Duplicate automation step name \"${name}\". Step names must be unique within a run — ` +\n \"the platform memoizes by name, so this call would return the first step's result \" +\n 'instead of running again. If this is a loop, add the index: ' +\n `ctx.step.run(\\`${name}:\\${i}\\`, ...)`\n )\n}\n\n/**\n * A submit inside a step whose own row never recorded.\n *\n * Recording a step is contractually non-fatal — telemetry must not fail a run —\n * so the id comes back empty and everything else carries on. A submission\n * cannot: the row is what the idempotency key is derived from, and improvising\n * one is how the same effect happens twice. Named here rather than left to the\n * service's generic invalid-submission, which would blame the payload.\n */\nexport function lostStepRowMessage(stepName: string): string {\n return (\n `ctx.action.submit cannot run in step \"${stepName}\": the step's own record failed to write, ` +\n 'so there is nothing stable to key the submission on and it is refused rather than sent ' +\n 'twice. This is a transient service failure — retry the run.'\n )\n}\n\n/**\n * The same Action submitted twice from one step with no way to tell them apart.\n *\n * Refused HERE, locally, rather than left to the write plane, because the plane\n * cannot refuse it: two identical submissions derive one idempotency key AND\n * one semantic fingerprint, so it replays the first request and answers both\n * calls with the same request id. Nothing errors, one effect happens, and the\n * run reports success — the failure mode a batch loop hits on its second row\n * and not on a one-row fixture.\n *\n * Naming the remedy matters more than usual: `submissionKey` is the one field\n * an author has to reach for to fix this, and it is not guessable from a 409.\n */\nexport function duplicateSubmissionMessage(apiName: string): string {\n return (\n `Step already submitted \"${apiName}\" with the same submissionKey. Two submissions the ` +\n 'platform cannot tell apart become ONE request — the plane replays the first and the second ' +\n 'effect never happens. If this is a batch, give each submission a distinct submissionKey ' +\n \"keyed on what it acts on: ctx.action.submit({ action: '\" + apiName + \"', submissionKey: \" +\n 'row.id, ... }). If it is a retry, it is already idempotent — drop the loop.'\n )\n}\n\n/**\n * An empty `submissionKey`.\n *\n * Refused rather than treated as absent, and refused in both layers: the\n * service rejects it by name, so folding it into the no-key identity here\n * would make the SDK and the service disagree about what the author asked for.\n */\nexport function emptySubmissionKeyMessage(apiName: string): string {\n return (\n `ctx.action.submit(\"${apiName}\") was given an empty submissionKey. Omit it entirely to mean ` +\n '\"this step submits once\", or pass a value identifying what this submission acts on.'\n )\n}\n\n/**\n * `ctx.action.submit` called outside a step.\n *\n * States the consequence rather than the rule, because the rule on its own\n * reads as ceremony: code outside a step runs again after EVERY step the\n * handler completes, so a submit there is not one submission with a retry\n * risk — it is one submission per step boundary, every time the run resumes.\n * The step row is also what the idempotency key is derived from, so there is\n * nothing to derive one from out here.\n */\nexport function submitOutsideStepMessage(apiName: string): string {\n return (\n `ctx.action.submit(\"${apiName}\") must be called inside ctx.step.run. Code outside a step ` +\n 're-executes every time the run resumes, so this would submit once per step boundary. ' +\n `Wrap it: ctx.step.run('submit-${apiName}', () => ctx.action.submit({ ... }))`\n )\n}\n\n/**\n * A `ctx.agent` turn that outlived the wait the author asked for.\n *\n * The same sentence the service writes when it gives up on the turn, so the\n * synchronous wait, the durable wait and the durable loop's own last-resort\n * deadline all read identically. The turn is NOT cancelled; its output still\n * lands in the conversation linked from the run.\n */\nexport function agentTimeoutMessage(agentSlug: string, timeoutMs: number): string {\n return (\n `Agent \"${agentSlug}\" did not finish within ${Math.round(timeoutMs / 1000)}s. ` +\n 'The run is still executing server-side; shorten the prompt or split the work ' +\n 'across steps.'\n )\n}\n\n/**\n * A top-level `ctx.agent` call that does not match the one this run made at\n * the same point before it last waited.\n *\n * Top-level code re-runs every time a durable wait resumes, and a call is\n * matched to its earlier self by ORDER. When a read above it returned\n * something different on the re-run (a query, an HTTP call, the clock), the\n * n-th call is now a different call — and silently reusing the turn started\n * for the old one would attribute its answer to the wrong record.\n */\nexport function agentReplayMismatchMessage(agentSlug: string, originalSlug: string, callNumber: number): string {\n return (\n `ctx.agent(\"${agentSlug}\") is not the call this run made at this point before it resumed ` +\n `(call #${callNumber} was ctx.agent(\"${originalSlug}\") with a different prompt, files or timeoutMs). ` +\n 'Code outside ctx.step.run runs again every time the run resumes, so it must make the same ' +\n 'ctx.agent calls in the same order each time. Move reads whose results can change ' +\n '(ctx.blueprint.query, ctx.http, the current time, random values) into ctx.step.run so ' +\n 'they are recorded once and replayed.'\n )\n}\n\n/**\n * A step that returned a `ctx.file` handle.\n *\n * A step's result is kept as JSON and replayed, and JSON cannot hold the\n * handle's readers, so the run would fail one execution later with \"text is\n * not a function\", far from the step that caused it. Both fixes are named,\n * because which one fits depends on whether the bytes are needed later.\n */\nexport function fileHandleInStepMessage(stepName: string): string {\n return (\n `Step \"${stepName}\" returned a ctx.file handle. A step's result is saved as JSON, which keeps the ` +\n \"file's details but drops text(), bytes() and stream(). Read the file inside the step and return \" +\n 'what you need from it, or call ctx.file outside the step.'\n )\n}\n\n/**\n * Code reading `ctx.file(...).signedUrl`, which a handle no longer has.\n *\n * A bundle deployed before the change still reads it. Without this it gets\n * `undefined`, and fails later in `fetch(undefined)` with nothing pointing\n * back here; the readers are named so the fix is plain.\n */\nexport function signedUrlRemovedMessage(): string {\n return (\n 'ctx.file() no longer returns signedUrl: Function code cannot open network connections of its own. ' +\n 'Read the file with the handle instead: await file.text(), await file.bytes() or await file.stream().'\n )\n}\n\n/**\n * A `createTestContext` file stub written for the handle's old shape.\n *\n * An existing test passing `{ signedUrl, sizeBytes }` has no `content`, and\n * would otherwise fail inside `ctx.file` as a bare TypeError on `undefined`.\n */\nexport function oldFileStubMessage(fileId: string): string {\n return (\n `The file stub for \"${fileId}\" has no content. ctx.file now returns the file's bytes rather than a signedUrl, ` +\n `so stub it with its content: files: { '${fileId}': { content: '…', mimeType: '…', name: null } }. ` +\n 'sizeBytes is computed from the content.'\n )\n}\n",
28
- "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.\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: (v) =>\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}\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'])\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])\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 }\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`)\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 — refused on any other\n // type so a typo like `accept` on a string field is loud, not silently\n // 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 (t !== 'file') {\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 (t !== 'file') {\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\n let defaultOk = true\n if (spec.default !== undefined) {\n // A `file` carries a reference to an uploaded item, so a fixed literal\n // default is meaningless — refused rather than type-checked.\n if (t === 'file') {\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(`\"${key}\" must be of type ${spec.type}`)\n continue\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",
29
- "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'\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` 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` type only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.\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` type only. Maximum upload size in bytes, enforced server-side. */\n maxBytes?: 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 `file` inputs to a readable form (signed URL +\n * authoritative mime/size). No grant — it only reads files the run was given;\n * any other 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",
30
- "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. In a dry\n // dev run it returns 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",
31
- "frontera/automation/manifest.ts": "import { CronExpressionParser } from 'cron-parser'\nimport { MAX_INPUT_BYTES, checkInputFieldSpec } 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` input can never carry a default (refused above), so the\n // \"add a default\" remedy would send the author straight into the\n // next validation error — name the two remedies that actually work.\n const remedy = spec.type === 'file'\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",
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 MAX_INPUT_BYTES,\n checkInputFieldSpec,\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",
28
+ "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",
29
+ "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",
30
+ "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",
31
+ "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",
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/web/src/app/globals.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"
35
35
  }