@frontera-sdk/cli 1.53.0 → 1.53.2

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.53.0",
3
+ "version": "1.53.2",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.0",
44
- "@frontera-sdk/functions": "1.53.0",
43
+ "@frontera-sdk/core": "1.53.2",
44
+ "@frontera-sdk/functions": "1.53.2",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.0",
52
+ "@frontera-sdk/forge-contracts": "1.53.2",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -280,6 +280,22 @@ export class PlatformApi {
280
280
  return this.get<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/editor`)
281
281
  }
282
282
 
283
+ // ── Workspace MCP servers ─────────────────────────────────────────────────
284
+
285
+ mcpServers(): Promise<{ servers: unknown[] }> {
286
+ return this.get<{ servers: unknown[] }>('/v1/mcp-servers')
287
+ }
288
+
289
+ /** One server, with the tools the caller cannot use now and why (`unavailable`). */
290
+ mcpServer(slug: string): Promise<{ server: unknown }> {
291
+ return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
292
+ }
293
+
294
+ /** Archived, not deleted: the Function runs its tools started still name it. */
295
+ archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
296
+ return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
297
+ }
298
+
283
299
  /** Drafts are per principal: a key's draft is its own, not a person's. */
284
300
  saveChatAppDraft(appId: string, configuration: unknown) {
285
301
  return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/draft`, {
@@ -35,6 +35,7 @@ import { applyTheme, themeCalls } from '../../local/local-themes'
35
35
  import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
36
36
  import { fillPlaceholderMedia } from '../../local/local-media-placeholders'
37
37
  import { applyChatApp, keyCalls } from '../../local/local-chat-apps'
38
+ import { applyMcpServer } from '../../local/local-mcp-servers'
38
39
  import { applyLessons } from '../../local/local-lessons'
39
40
  import { applyStandIn, missingGrantInstalls, platformOnlyNames, readStandIn, standInNames, standInPath, writeGrantStandIn } from '../../local/local-stand-ins'
40
41
  import { projectProvides } from '../../local/local-adapt'
@@ -215,6 +216,11 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
215
216
  // The workspace key's draft too, so `frontera chat-app get` (which reads the key's own draft) shows what was applied.
216
217
  return withNotes(await applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId), creds.workspaceKey ? keyCalls(opts.serviceUrl, creds) : undefined))
217
218
  }
219
+ if (target.kind === 'mcp-server') {
220
+ // As the runtime's workspace key: the service checks each tool against what the runtime holds.
221
+ if (!creds?.workspaceKey) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
222
+ return withNotes(await applyMcpServer(project.root, target.name, target.file, keyCalls(opts.serviceUrl, creds)))
223
+ }
218
224
  if (target.kind === 'app') {
219
225
  if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
220
226
  return applyApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), projectProvides(project, creds.organizationId))
@@ -1454,7 +1460,7 @@ async function dev(ctx: CommandContext) {
1454
1460
  return { data: { applied: done, unchanged, ...alreadyField, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}${alreadyText}` }
1455
1461
  }
1456
1462
 
1457
- ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
1463
+ ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, mcp-servers/, brand/ and library.json (Ctrl+C to stop)')
1458
1464
  if (pending.size) schedule()
1459
1465
  // What failed while the runtime was down is applied once it answers again, not when its file next changes.
1460
1466
  const poller = setInterval(() => {
@@ -0,0 +1,96 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { UsageError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import type { Command } from '../types'
5
+
6
+ /**
7
+ * A workspace's own MCP servers: what is on the workspace, and taking one out
8
+ * of service.
9
+ *
10
+ * A server is authored as `mcp-servers/<slug>.yaml` in a project and saved by
11
+ * `frontera local dev`, which also archives one whose file was removed. These
12
+ * commands cover the rest: seeing what a workspace serves and where a client
13
+ * connects, why a tool is left out for you, and archiving a server the project
14
+ * never had a file for.
15
+ */
16
+
17
+ interface McpServerRow {
18
+ slug?: string
19
+ name?: string
20
+ url?: string
21
+ revision?: number
22
+ tools?: Array<{ name?: string; kind?: string; target?: string }>
23
+ unavailable?: Array<{ name?: string; reason?: string; temporary?: boolean }>
24
+ }
25
+
26
+ function serverRef(ctx: { positional: string[] }, verb: string): string {
27
+ const slug = ctx.positional[0]
28
+ if (!slug) throw new UsageError('missing <slug>', `frontera mcp-server list — then frontera mcp-server ${verb} <slug>`)
29
+ return slug
30
+ }
31
+
32
+ const list: Command = {
33
+ meta: {
34
+ noun: 'mcp-server',
35
+ verb: 'list',
36
+ args: [],
37
+ flags: {},
38
+ summary: 'List the MCP servers this workspace serves, and where a client connects to each',
39
+ examples: ['frontera mcp-server list', 'frontera mcp-server list --json'],
40
+ },
41
+ async run(ctx) {
42
+ const rows = ((await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).mcpServers()).servers ?? []) as McpServerRow[]
43
+ return {
44
+ data: rows,
45
+ text:
46
+ rows.length === 0
47
+ ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and run `frontera local dev` (`frontera local schema mcp-server` prints the format).'
48
+ : table(
49
+ ['slug', 'name', 'tools', 'url'],
50
+ rows.map((s) => [s.slug ?? '?', s.name ?? '', String(s.tools?.length ?? 0), s.url ?? '']),
51
+ ),
52
+ }
53
+ },
54
+ }
55
+
56
+ const get: Command = {
57
+ meta: {
58
+ noun: 'mcp-server',
59
+ verb: 'get',
60
+ args: [{ name: 'slug', required: true, description: 'MCP server slug' }],
61
+ flags: {},
62
+ summary: 'Show an MCP server: its tools, and any you cannot use now with the reason',
63
+ examples: ['frontera mcp-server get tracker'],
64
+ },
65
+ async run(ctx) {
66
+ const slug = serverRef(ctx, 'get')
67
+ const server = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).mcpServer(slug)).server as McpServerRow
68
+ const lines = [`${server.name ?? slug} (${slug}), revision ${server.revision ?? '?'}`, ` connect at ${server.url ?? '?'}`, '']
69
+ lines.push(table(['tool', 'kind', 'runs'], (server.tools ?? []).map((t) => [t.name ?? '?', t.kind ?? '', t.target ?? ''])))
70
+ for (const tool of server.unavailable ?? []) {
71
+ lines.push(` ${tool.temporary ? 'for now, ' : ''}${tool.name ?? '?'} is left out for you: ${tool.reason ?? 'unavailable'}`)
72
+ }
73
+ return { data: server, text: lines.join('\n') }
74
+ },
75
+ }
76
+
77
+ const archive: Command = {
78
+ meta: {
79
+ noun: 'mcp-server',
80
+ verb: 'archive',
81
+ args: [{ name: 'slug', required: true, description: 'MCP server slug' }],
82
+ flags: {},
83
+ summary: 'Archive an MCP server: its URL stops answering at once, and its slug can be taken again',
84
+ examples: ['frontera mcp-server archive tracker'],
85
+ },
86
+ async run(ctx) {
87
+ const slug = serverRef(ctx, 'archive')
88
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).archiveMcpServer(slug)
89
+ return {
90
+ data: result,
91
+ text: `Archived MCP server ${slug}: clients connected to it get 404 from now on. Saving mcp-servers/${slug}.yaml again creates it anew.`,
92
+ }
93
+ },
94
+ }
95
+
96
+ export const mcpServerCommands: Command[] = [list, get, archive]
@@ -41,6 +41,7 @@ import { mediaCommands } from './media/index-commands'
41
41
  import { packCommands } from './pack/index-commands'
42
42
  import { secretCommands } from './secret/index-commands'
43
43
  import { chatAppCommands } from './chat-app/index-commands'
44
+ import { mcpServerCommands } from './mcp-server/index-commands'
44
45
  import { markingCommands } from './marking/index-commands'
45
46
  import { automationCommands } from './automation/index-commands'
46
47
  import { workflowCommands } from './workflow/index-commands'
@@ -133,6 +134,7 @@ export const COMMANDS: readonly Command[] = [
133
134
  ...packCommands,
134
135
  ...secretCommands,
135
136
  ...chatAppCommands,
137
+ ...mcpServerCommands,
136
138
  ...automationCommands,
137
139
  ...workflowCommands,
138
140
 
@@ -210,6 +212,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
210
212
  pack: 'Reusable skill bundles — author once, install per workspace',
211
213
  secret: 'Workspace secrets — named here, never printed back',
212
214
  'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
215
+ 'mcp-server': 'Workspace MCP servers — list, inspect and archive',
213
216
  function: 'Functions — TypeScript deployed here, run on a schedule',
214
217
  workflow: 'Workflows — declared steps, versioned, promoted, then run',
215
218
  blueprint: 'The shared model of the organization — what an app can read',
@@ -399,6 +399,8 @@ export function refOfTarget(target: SyncTarget): string {
399
399
  return `chat-app/${target.name}`
400
400
  case 'app':
401
401
  return `app/${target.name}`
402
+ case 'mcp-server':
403
+ return `mcp-server/${target.name}`
402
404
  case 'brand':
403
405
  return 'brand'
404
406
  case 'library':
@@ -419,6 +421,7 @@ const KIND_WORDS: Record<string, string> = {
419
421
  plugin: 'Plugin',
420
422
  model: 'model',
421
423
  dataset: 'dataset',
424
+ 'mcp-server': 'MCP server',
422
425
  }
423
426
 
424
427
  /** `workflow/telco-fault-triage` → "workflow telco-fault-triage". */
@@ -167,7 +167,7 @@ export async function applyApp(root: string, slug: string, call: Call, provides:
167
167
  // ── A Custom App's code, in the project ─────────────────────────────────────
168
168
 
169
169
  /** Folders an App's code is never in: dependencies, build output, the project's state and its other kinds. */
170
- const NOT_APP_DIRS = new Set(['node_modules', '.git', '.frontera', '.next', 'dist', 'out', 'build', 'vendor', 'fixtures', 'datasets', 'blueprint', 'agents', 'workflows', 'functions', 'knowledge', 'skills', 'themes', 'mcp', 'chat-apps'])
170
+ const NOT_APP_DIRS = new Set(['node_modules', '.git', '.frontera', '.next', 'dist', 'out', 'build', 'vendor', 'fixtures', 'datasets', 'blueprint', 'agents', 'workflows', 'functions', 'knowledge', 'skills', 'themes', 'mcp', 'mcp-servers', 'chat-apps'])
171
171
 
172
172
  export function readPackage(dir: string): { scripts?: Record<string, string>; frontera?: unknown } | null {
173
173
  try {
@@ -473,7 +473,7 @@ export function itemKindsText(rows: BuildRow[]): string {
473
473
  objectType: ['object type', 'object types'], linkType: ['link type', 'link types'], metric: ['metric', 'metrics'], semanticType: ['semantic type', 'semantic types'],
474
474
  sharedField: ['shared field', 'shared fields'], objectSet: ['saved object set', 'saved object sets'], action: ['governed Action', 'governed Actions'],
475
475
  knowledge: ['knowledge base', 'knowledge bases'], skill: ['skill', 'skills'], plugin: ['Plugin', 'Plugins'], theme: ['slide theme', 'slide themes'],
476
- 'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'],
476
+ 'chat-app': ['Chat App', 'Chat Apps'], app: ['Custom App', 'Custom Apps'], 'mcp-server': ['MCP server', 'MCP servers'],
477
477
  }
478
478
  const counts = new Map<string, number>()
479
479
  for (const r of rows) {
@@ -27,6 +27,7 @@ import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
27
27
  * themes/<name>/theme.json (+ images) → the slide theme saved on the runtime (replacing its copy)
28
28
  * chat-apps/<slug>.json → the Chat App saved on the runtime (the owner's draft; published when it can be)
29
29
  * apps/<slug>.json → the Custom App made on the runtime, with the Functions it may start (not its code)
30
+ * mcp-servers/<slug>.yaml|.yml|.json → the workspace's MCP server of that slug, saved on the runtime (PUT /v1/mcp-servers/<slug>)
30
31
  * brand/brand.json (+ logo) → the organization's colors and logo on the runtime
31
32
  * library.json → the library installations, checked against the runtime's library (not installed)
32
33
  *
@@ -53,9 +54,13 @@ export type SyncTarget =
53
54
  | { kind: 'theme'; key: string; name: string; file: string }
54
55
  | { kind: 'chat-app'; key: string; name: string; file: string }
55
56
  | { kind: 'app'; key: string; name: string; file: string }
57
+ | { kind: 'mcp-server'; key: string; name: string; file: string }
56
58
  | { kind: 'brand'; key: string; file: string }
57
59
  | { kind: 'library'; key: string; file: string }
58
60
 
61
+ /** `mcp-servers/<slug>.yaml`: a slug as the service takes it (lowercase, digits, dashes; a letter first). */
62
+ const MCP_SERVER_FILE = /^[a-z][a-z0-9-]{0,62}\.(ya?ml|json)$/
63
+
59
64
  const IGNORED_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'fixtures'])
60
65
 
61
66
  /** Hidden files and editor leftovers: `.#x.yaml` locks, `#x#` autosaves, `x~` backups, `.swp`. */
@@ -122,6 +127,11 @@ export function targetFor(root: string, absolute: string): SyncTarget | null {
122
127
  const name = second.slice(0, -'.json'.length)
123
128
  return { kind: 'app', key: `app:${name}`, name, file: rel }
124
129
  }
130
+ // A workspace MCP server: its file's name is its slug (the last segment of its URL).
131
+ if (top === 'mcp-servers' && parts.length === 2 && second && MCP_SERVER_FILE.test(second)) {
132
+ const name = second.replace(/\.(ya?ml|json)$/, '')
133
+ return { kind: 'mcp-server', key: `mcp-server:${name}`, name, file: rel }
134
+ }
125
135
  // The brand is its file and the logo beside it.
126
136
  if (top === 'brand' && parts.length === 2 && existsSync(join(root, 'brand', 'brand.json'))) return { kind: 'brand', key: 'brand', file: join('brand', 'brand.json') }
127
137
  if (rel === 'library.json') return { kind: 'library', key: 'library', file: 'library.json' }
@@ -219,7 +229,7 @@ export function allTargets(root: string): SyncTarget[] {
219
229
  }
220
230
  }
221
231
  }
222
- for (const top of ['datasets', 'blueprint', 'knowledge', 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'brand']) walk(join(root, top), 1)
232
+ for (const top of ['datasets', 'blueprint', 'knowledge', 'skills', 'mcp', 'themes', 'functions', 'workflows', 'agents', 'chat-apps', 'apps', 'mcp-servers', 'brand']) walk(join(root, top), 1)
223
233
  if (existsSync(join(root, 'library.json'))) out.set('library', { kind: 'library', key: 'library', file: 'library.json' })
224
234
  // Datasets first (object types read them), then the Blueprint (Agents and
225
235
  // workflows read its objects), then what Agents use by name (knowledge
@@ -227,11 +237,12 @@ export function allTargets(root: string): SyncTarget[] {
227
237
  // Agents call them), then Agents (a workflow's agent step names one), then workflows, then what
228
238
  // each Agent may start, then the Agents' behavior settings (a procedure runs
229
239
  // only what its Agent may start), then the Chat Apps their Agents are used in and the Apps that
230
- // start Functions. The brand first of all (it is how everything looks), the library record last.
240
+ // start Functions, and the MCP servers whose tools name Functions, workflows, Actions and object types.
241
+ // The brand first of all (it is how everything looks), the library record last.
231
242
  return [...out.values()].sort(byApplyOrder)
232
243
  }
233
244
 
234
- const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, theme: 2, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, library: 11 }
245
+ const ORDER = { brand: -1, dataset: 0, blueprint: 1, knowledge: 2, theme: 2, skill: 3, plugin: 4, function: 5, agent: 6, workflow: 7, bindings: 8, operating: 9, lessons: 9, 'chat-app': 10, app: 10, 'mcp-server': 10, library: 11 }
235
246
 
236
247
  /** Dependencies first: what a later kind reads or calls is applied before it. */
237
248
  export function byApplyOrder(a: SyncTarget, b: SyncTarget): number {
@@ -291,6 +302,7 @@ export function commandFor(target: SyncTarget, profiles: SyncProfiles): string[]
291
302
  case 'theme':
292
303
  case 'chat-app':
293
304
  case 'app':
305
+ case 'mcp-server':
294
306
  case 'brand':
295
307
  case 'library':
296
308
  case 'lessons':
@@ -435,6 +447,8 @@ export function describeTarget(target: SyncTarget): string {
435
447
  return `Chat App ${target.name}`
436
448
  case 'app':
437
449
  return `App ${target.name}`
450
+ case 'mcp-server':
451
+ return `MCP server ${target.name}`
438
452
  case 'brand':
439
453
  return 'the organization’s brand'
440
454
  case 'library':
@@ -496,6 +510,10 @@ export function unappliedReason(root: string, rel: string): string | null {
496
510
  return `skills/${second}/ has no SKILL.md, so it is not pushed`
497
511
  case 'chat-apps':
498
512
  return 'a Chat App is chat-apps/<slug>.json, directly in chat-apps/'
513
+ case 'mcp-servers':
514
+ if (parts.length > 2) return 'an MCP server is mcp-servers/<slug>.yaml, directly in mcp-servers/ (`frontera local schema mcp-server`)'
515
+ if (!/\.(ya?ml|json)$/.test(name)) return 'an MCP server is mcp-servers/<slug>.yaml (or .yml, .json)'
516
+ return `an MCP server's file is named by its slug: lowercase letters, digits and dashes, starting with a letter (mcp-servers/${name.replace(/\.(ya?ml|json)$/, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^[^a-z]+/, '') || 'my-server'}.yaml)`
499
517
  case 'mcp':
500
518
  return 'a Plugin is mcp/<name>.plugin.json (a stand-in mcp/<name>.stand-in.json)'
501
519
  default:
@@ -544,6 +562,6 @@ export function unappliedFiles(root: string): string[] {
544
562
  }
545
563
  }
546
564
  }
547
- for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
565
+ for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'mcp-servers', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
548
566
  return out
549
567
  }
@@ -14,7 +14,7 @@ import { join, posix } from 'node:path'
14
14
  */
15
15
 
16
16
  /** Top folders whose files are items of their own (or tooling), never a Function's shared file. */
17
- const ITEM_FOLDERS = new Set(['agents', 'workflows', 'skills', 'knowledge', 'blueprint', 'mcp', 'chat-apps', 'themes', 'datasets', 'apps', 'brand', 'functions', 'vendor', 'node_modules', 'dist'])
17
+ const ITEM_FOLDERS = new Set(['agents', 'workflows', 'skills', 'knowledge', 'blueprint', 'mcp', 'mcp-servers', 'chat-apps', 'themes', 'datasets', 'apps', 'brand', 'functions', 'vendor', 'node_modules', 'dist'])
18
18
 
19
19
  /** Project files that are the project's own, never a Function's. */
20
20
  const PROJECT_FILES = new Set(['frontera.yaml', 'frontera.lock', 'package.json', 'tsconfig.json', 'bun.lock', 'bun.lockb', 'library.json'])
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+
5
+ import { errorOf } from './local-chat-apps'
6
+
7
+ /**
8
+ * A workspace's own MCP servers: `mcp-servers/<slug>.yaml` (or .yml, .json),
9
+ * one server per file, named by its slug. `local dev` saves each on the local
10
+ * runtime (`PUT /v1/mcp-servers/<slug>`, as the runtime's workspace key), and
11
+ * the service checks every tool against what the runtime holds: the object
12
+ * types the workspace is granted, its Functions, workflows and governed
13
+ * Actions. A server whose file is deleted is archived on the runtime.
14
+ *
15
+ * Its format is the service's (`frontera local schema mcp-server`).
16
+ */
17
+
18
+ type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
19
+
20
+ export const mcpServerPath = (slug: string, ext = 'yaml') => `mcp-servers/${slug}.${ext}`
21
+
22
+ export interface McpServerApplied {
23
+ ok: boolean
24
+ output: string
25
+ }
26
+
27
+ interface SavedServer {
28
+ server?: { slug?: string; name?: string; url?: string; tools?: unknown[] }
29
+ created?: boolean
30
+ warnings?: Array<{ name?: string; reason?: string }>
31
+ }
32
+
33
+ /** The file as the service takes it: YAML or JSON, a mapping at the top. */
34
+ export function readMcpServerFile(root: string, file: string): { ok: true; doc: Record<string, unknown> } | { ok: false; output: string } {
35
+ const path = join(root, file)
36
+ if (!existsSync(path)) return { ok: false, output: `error [MCP_SERVER]: ${file} is missing` }
37
+ let doc: unknown
38
+ try {
39
+ const text = readFileSync(path, 'utf8')
40
+ doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)
41
+ } catch (err) {
42
+ return { ok: false, output: `error [MCP_SERVER]: ${file} can't be read: ${(err as Error).message.split('\n')[0]}` }
43
+ }
44
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
45
+ return { ok: false, output: `error [MCP_SERVER]: ${file} must be a mapping with name, instructions and tools (\`frontera local schema mcp-server\`)` }
46
+ }
47
+ return { ok: true, doc: doc as Record<string, unknown> }
48
+ }
49
+
50
+ /** Save one server on the runtime; the line says where a client connects, or what the service refused. */
51
+ export async function applyMcpServer(root: string, slug: string, file: string, call: Call): Promise<McpServerApplied> {
52
+ const read = readMcpServerFile(root, file)
53
+ if (!read.ok) return read
54
+ const res = await call('PUT', `/v1/mcp-servers/${encodeURIComponent(slug)}`, read.doc)
55
+ if (res.status !== 200 && res.status !== 201) {
56
+ return { ok: false, output: `error [MCP_SERVER]: MCP server ${slug} was refused: ${errorOf(res)}` }
57
+ }
58
+ const saved = ((res.body as { data?: unknown } | null)?.data ?? res.body) as SavedServer | null
59
+ const tools = saved?.server?.tools?.length ?? 0
60
+ const url = saved?.server?.url
61
+ const lines = [` ${saved?.created ? 'created' : 'applied'} MCP server ${slug} (${tools} tool${tools === 1 ? '' : 's'})${url ? ` — connect at ${url}` : ''}`]
62
+ for (const warning of saved?.warnings ?? []) {
63
+ lines.push(` warning: MCP server ${slug}: tool ${warning.name ?? '?'} is offered to nobody for now: ${warning.reason ?? 'unavailable'}`)
64
+ }
65
+ return { ok: true, output: lines.join('\n') }
66
+ }
@@ -88,7 +88,7 @@ export function writeNewFiles(root: string, files: Record<string, string>): stri
88
88
  // ── fmt ─────────────────────────────────────────────────────────────────────
89
89
 
90
90
  /** The folders whose JSON and YAML are project files; an App's own code and the tool state are left alone. */
91
- const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp']
91
+ const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp', 'mcp-servers']
92
92
  const FORMATTED_ROOT_FILES = ['frontera.yaml']
93
93
  const FORMATTED = /\.(json|ya?ml)$/
94
94
 
@@ -28,13 +28,15 @@ import type { LocalCredentials } from './local-state'
28
28
  * switched on again when its file is deployed again
29
29
  * knowledge base left as it is: deleting one takes its documents with it and
30
30
  * cannot be undone, so `status` only says it
31
+ * MCP server archived (its Function runs keep naming it) — saved again,
32
+ * as a new server of the same slug, when its file returns
31
33
  *
32
34
  * An orphan the project never had (made in Studio, say) is only said: it may
33
35
  * be work someone means to pull into the files. A Function switched off with
34
36
  * no file is not one: it doesn't run, and there is nothing left to do.
35
37
  */
36
38
 
37
- export type OrphanKind = 'agent' | 'workflow' | 'function' | 'knowledge'
39
+ export type OrphanKind = 'agent' | 'workflow' | 'function' | 'knowledge' | 'mcp-server'
38
40
 
39
41
  export interface Orphan {
40
42
  /** `agent/assistant`, `workflow/hello`, `function/word-count`, `knowledge/Field desk policies`. */
@@ -57,6 +59,7 @@ export interface RuntimeItems {
57
59
  workflows?: Array<{ slug: string }>
58
60
  functions?: Array<{ slug: string; enabled: boolean | null }>
59
61
  knowledge?: Array<{ id: string; name: string }>
62
+ mcpServers?: Array<{ slug: string }>
60
63
  }
61
64
 
62
65
  function rowsOf<T>(body: unknown): T[] {
@@ -95,13 +98,15 @@ export function orphanCalls(serviceUrl: string, creds: Pick<LocalCredentials, 'w
95
98
 
96
99
  /** The Agents, workflows, Functions and knowledge bases on the runtime; each kind that didn't answer is left out. */
97
100
  export async function runtimeItems(call: Call, workspaceId: string): Promise<RuntimeItems> {
98
- const [agents, workflows, functions, knowledge] = await Promise.all([
101
+ const [agents, workflows, functions, knowledge, mcpServers] = await Promise.all([
99
102
  // The sync listing: drafts never published included, archived and platform Agents left out.
100
103
  call('/v1/sync/agents'),
101
104
  // Archived workflows are left out of the listing.
102
105
  call('/v1/workflows'),
103
106
  call('/v1/functions'),
104
107
  call(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`),
108
+ // Archived servers are left out of the listing.
109
+ call('/v1/mcp-servers'),
105
110
  ])
106
111
  const out: RuntimeItems = {}
107
112
  if (agents.ok) {
@@ -113,15 +118,20 @@ export async function runtimeItems(call: Call, workspaceId: string): Promise<Run
113
118
  if (workflows.ok) out.workflows = rowsOf<{ slug?: string; archivedAt?: string | null }>(workflows.body).filter((w) => w.slug && !w.archivedAt).map((w) => ({ slug: w.slug! }))
114
119
  if (functions.ok) out.functions = rowsOf<{ slug?: string; enabled?: boolean | null }>(functions.body).filter((f) => f.slug).map((f) => ({ slug: f.slug!, enabled: f.enabled ?? null }))
115
120
  if (knowledge.ok) out.knowledge = rowsOf<{ id?: string; name?: string }>(knowledge.body).filter((k) => k.id && k.name).map((k) => ({ id: k.id!, name: k.name! }))
121
+ if (mcpServers.ok) {
122
+ const data = (mcpServers.body as { data?: { servers?: unknown } } | null)?.data?.servers
123
+ out.mcpServers = (Array.isArray(data) ? data as Array<{ slug?: string }> : []).filter((m) => m.slug).map((m) => ({ slug: m.slug! }))
124
+ }
116
125
  return out
117
126
  }
118
127
 
119
128
  /** The names the project's files give each kind: an Agent's folder, a workflow's file, a Function's file or folder, a knowledge base's `name`. */
120
129
  export function projectNames(root: string): Record<OrphanKind, Set<string>> {
121
- const out: Record<OrphanKind, Set<string>> = { agent: new Set(), workflow: new Set(), function: new Set(), knowledge: new Set() }
130
+ const out: Record<OrphanKind, Set<string>> = { agent: new Set(), workflow: new Set(), function: new Set(), knowledge: new Set(), 'mcp-server': new Set() }
122
131
  for (const t of allTargets(root)) {
123
132
  if (t.kind === 'agent') out.agent.add(t.slug)
124
133
  else if (t.kind === 'workflow') out.workflow.add(t.slug)
134
+ else if (t.kind === 'mcp-server') out['mcp-server'].add(t.name)
125
135
  else if (t.kind === 'function') {
126
136
  const slug = functionSlugOf(t.key)
127
137
  if (slug) out.function.add(slug)
@@ -143,6 +153,7 @@ export function appliedKeysOf(kind: OrphanKind, name: string): string[] {
143
153
  if (kind === 'agent') return [`agent:${name}`, `operating:${name}`, `bindings:${name}`, `lessons:${name}`]
144
154
  if (kind === 'workflow') return [`workflow:${name}`]
145
155
  if (kind === 'function') return [`function:${name}.ts`, `function:${name}/`]
156
+ if (kind === 'mcp-server') return [`mcp-server:${name}`]
146
157
  return []
147
158
  }
148
159
 
@@ -153,13 +164,14 @@ function wasApplied(kind: OrphanKind, name: string, applied: ReadonlySet<string>
153
164
  return appliedKeysOf(kind, name).some((k) => applied.has(k))
154
165
  }
155
166
 
156
- const WORDS: Record<OrphanKind, string> = { agent: 'Agent', workflow: 'workflow', function: 'Function', knowledge: 'knowledge base' }
167
+ const WORDS: Record<OrphanKind, string> = { agent: 'Agent', workflow: 'workflow', function: 'Function', knowledge: 'knowledge base', 'mcp-server': 'MCP server' }
157
168
 
158
169
  /** The file an item of a kind would have in the project. */
159
170
  function fileOf(kind: OrphanKind, name: string): string {
160
171
  if (kind === 'agent') return `agents/${name}/agent.json`
161
172
  if (kind === 'workflow') return `workflows/${name}.yaml`
162
173
  if (kind === 'function') return `functions/${name}.ts`
174
+ if (kind === 'mcp-server') return `mcp-servers/${name}.yaml`
163
175
  return 'knowledge/<folder>/knowledge.json'
164
176
  }
165
177
 
@@ -183,6 +195,10 @@ export function findOrphans(root: string, items: RuntimeItems, applied: Readonly
183
195
  fix = deleted
184
196
  ? `\`frontera local dev\` switches it off (the runtime can’t remove a Function), or \`frontera function disable ${name}\` now`
185
197
  : `add its file to the project, or \`frontera function disable ${name}\` to switch it off (the runtime can’t remove a Function)`
198
+ } else if (kind === 'mcp-server') {
199
+ fix = deleted
200
+ ? '`frontera local dev` archives it (or `frontera mcp-server archive ' + name + '` now)'
201
+ : `add its file to the project (mcp-servers/${name}.yaml), or \`frontera mcp-server archive ${name}\` if it isn’t wanted`
186
202
  } else fix = 'add its folder to the project, or delete it from its page in Studio if it isn’t wanted: deleting a knowledge base takes its documents with it, so `frontera local dev` never does'
187
203
  out.push({ ref: `${kind}/${name}`, kind, name, ...(id ? { id } : {}), deleted, problem, fix })
188
204
  }
@@ -191,6 +207,7 @@ export function findOrphans(root: string, items: RuntimeItems, applied: Readonly
191
207
  // A Function switched off and with no file: it doesn't run, and the runtime can't remove it, so there is nothing to fix.
192
208
  for (const f of items.functions ?? []) if (f.enabled !== false) add('function', f.slug)
193
209
  for (const k of items.knowledge ?? []) add('knowledge', k.name, k.id)
210
+ for (const m of items.mcpServers ?? []) add('mcp-server', m.slug)
194
211
  return out
195
212
  }
196
213
 
@@ -274,6 +291,14 @@ export async function retireDeleted(project: Pick<LocalProject, 'stateDir'>, org
274
291
  }
275
292
  retired[o.ref] = { how: 'switched-off', at }
276
293
  lines.push(` switched off Function ${o.name}: its file is gone from the project, and the runtime can’t remove a Function (it is switched on again if the file comes back)`)
294
+ } else if (o.kind === 'mcp-server') {
295
+ const res = await call(`/v1/mcp-servers/${encodeURIComponent(o.name)}`, { method: 'DELETE' })
296
+ if (!res.ok && res.status !== 404) {
297
+ lines.push(` warning: MCP server ${o.name}’s file is gone, but it couldn’t be archived here: ${said(res)}`)
298
+ continue
299
+ }
300
+ retired[o.ref] = { how: 'archived', at }
301
+ lines.push(` archived MCP server ${o.name}: its file is gone from the project, so clients can no longer connect to it (it is saved again if the file comes back)`)
277
302
  } else continue
278
303
  forget.push(...appliedKeysOf(o.kind, o.name))
279
304
  }
@@ -13,7 +13,7 @@ import { REF_KINDS, refPattern, type RefKind } from './local-sync-refs'
13
13
  * The formats of a project's files, as JSON Schema, for the coding agents and
14
14
  * editors working in it (`frontera local schema <kind>`).
15
15
  *
16
- * The service checks three of them, so their schemas come from the service
16
+ * The service checks four of them (an Agent, its behavior, a workflow, an MCP server), so their schemas come from the service
17
17
  * the runtime runs (`GET /v1/config/file-schemas/<kind>`), made from the same
18
18
  * zod schemas that check the files, then widened where a project file names a
19
19
  * resource by reference instead of id (`localizeServiceSchema`). The others
@@ -35,6 +35,7 @@ export const SCHEMA_KINDS = [
35
35
  'theme',
36
36
  'chat-app',
37
37
  'app',
38
+ 'mcp-server',
38
39
  'knowledge',
39
40
  'skill',
40
41
  'blueprint-object-type',
@@ -61,6 +62,7 @@ export const SCHEMA_FILES: Record<SchemaKind, string> = {
61
62
  theme: 'themes/<name>/theme.json',
62
63
  'chat-app': 'chat-apps/<slug>.json',
63
64
  app: 'apps/<slug>.json',
65
+ 'mcp-server': 'mcp-servers/<slug>.yaml',
64
66
  knowledge: 'knowledge/<name>/knowledge.json',
65
67
  skill: 'skills/<name>/SKILL.md (its front matter)',
66
68
  secrets: 'secrets.json',
@@ -12,7 +12,7 @@ const DOCS_TOOLS = [
12
12
  { name: 'search_documentation', description: "Search Frontera's documentation for the release the local runtime runs. Returns guides with the best matching heading and a snippet; read one with read_documentation." },
13
13
  { name: 'read_documentation', description: 'Read a guide by its slug from a search result, whole or one heading of it.' },
14
14
  { name: 'list_documentation', description: 'List every guide: slug, title and description. Use it to browse an area, e.g. everything under "workflows/".' },
15
- { name: 'read_file_schema', description: 'The exact format of a kind of project file (agent.json, operating.json, a workflow, the Blueprint), as JSON Schema, for the runtime\'s release. Read it before writing such a file.' },
15
+ { name: 'read_file_schema', description: 'The exact format of a kind of project file (agent.json, operating.json, a workflow, an MCP server, the Blueprint), as JSON Schema, for the runtime\'s release. Read it before writing such a file.' },
16
16
  ] as const
17
17
 
18
18
  export function docsTools(project: LocalProject, schemaFor: SchemaLookup): McpTool[] {
@@ -27,7 +27,7 @@
27
27
  "frontera/automation/errors.ts": "/**\n * A `ctx` call the platform refused.\n *\n * The message is the one a refused call has always thrown\n * (`ctx.action.submit → 409 Record \"SRV-1\" already has an open … request`), so\n * a log line or a trace reads the same. What it adds is what a handler branches\n * on: the platform's error `code` (`ACTION_REQUEST_ALREADY_OPEN`), the HTTP\n * `status` and the refusal's `details`, the same code a workflow step reads as\n * `$steps.<id>.error.code`. Matching on the wording breaks the day it changes;\n * the code does not.\n *\n * Codes are not enumerated: the platform adds new ones, and an SDK that refused\n * unknown codes would need a release each time.\n */\nexport class FronteraCallError extends Error {\n /** The platform's error code, e.g. `ACTION_REQUEST_ALREADY_OPEN`. */\n readonly code: string\n /** The HTTP status the platform answered with. */\n readonly status: number\n /** What the platform said beside the message, e.g. `{ openRequest: { requestId, lifecycle } }`; absent when it said nothing more. */\n readonly details?: unknown\n /** The call that was refused, e.g. `ctx.action.submit`. */\n readonly call: string\n\n constructor(call: string, status: number, message: string, options: { code?: string; details?: unknown } = {}) {\n super(`${call} → ${status} ${message}`)\n this.name = 'FronteraCallError'\n this.call = call\n this.status = status\n this.code = options.code ?? codeForStatus(status)\n if (options.details !== undefined) this.details = options.details\n }\n}\n\n/** The code of a refusal whose body names none: a proxy's 502, say. */\nfunction codeForStatus(status: number): string {\n switch (status) {\n case 400: return 'BAD_REQUEST'\n case 401: return 'UNAUTHORIZED'\n case 403: return 'FORBIDDEN'\n case 404: return 'NOT_FOUND'\n case 409: return 'CONFLICT'\n case 410: return 'GONE'\n case 429: return 'RATE_LIMITED'\n case 503: return 'SERVICE_UNAVAILABLE'\n default: return status >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_FAILED'\n }\n}\n\n/**\n * Whether `err` is a refused `ctx` call, optionally with this code.\n *\n * Reads the fields rather than `instanceof`, so a refusal thrown by another\n * copy of the SDK (a test helper's, a bundled one) still matches.\n */\nexport function isFronteraCallError(err: unknown, code?: string): err is FronteraCallError {\n if (!err || typeof err !== 'object') return false\n const e = err as { name?: unknown; code?: unknown; status?: unknown }\n if (e.name !== 'FronteraCallError' || typeof e.code !== 'string' || typeof e.status !== 'number') return false\n return code === undefined || e.code === code\n}\n\n/**\n * The refusal in a response body: the platform answers `{ error, message, code, details }`.\n * A body that is not one of ours gives its text as the message, because an\n * empty message would be worse than a noisy one.\n */\nexport function refusalOf(body: string): { message: string; code?: string; details?: unknown } {\n try {\n const parsed = JSON.parse(body) as { message?: unknown; code?: unknown; details?: unknown }\n if (parsed && typeof parsed === 'object' && typeof parsed.message === 'string' && parsed.message) {\n return {\n message: parsed.message,\n ...(typeof parsed.code === 'string' && parsed.code ? { code: parsed.code } : {}),\n ...(parsed.details !== undefined ? { details: parsed.details } : {}),\n }\n }\n } catch {\n // Not JSON. Fall through to the body.\n }\n return { message: body }\n}\n",
28
28
  "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\nimport { PERSON_CONFIRM_MAX_MINUTES } from './types'\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\n/**\n * A `ctx.person.confirm` asking to wait longer than a Function run can.\n *\n * Refused before anyone is asked: the run ends at ten minutes, waits included,\n * so a longer question would still be open after its Function stopped. `null`\n * when the wait is one the run can hold.\n */\nexport function personConfirmTimeoutRefusal(timeoutMinutes: number | undefined): string | null {\n if (timeoutMinutes === undefined) return null\n if (Number.isInteger(timeoutMinutes) && timeoutMinutes >= 1 && timeoutMinutes <= PERSON_CONFIRM_MAX_MINUTES) return null\n return (\n `ctx.person.confirm: timeoutMinutes must be a whole number from 1 to ${PERSON_CONFIRM_MAX_MINUTES}, got ${String(timeoutMinutes)}. ` +\n 'A Function run ends at ten minutes, waits included. To wait longer for the person, ask in a workflow\\'s \"Confirm with the person\" node.'\n )\n}\n",
29
29
  "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",
30
- "frontera/automation/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { NearestRequest, WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron, manual, and agent for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n *\n * `agent` is deliberately NOT part of that exclusion. Cron and manual answer\n * \"what fires this on its own\"; `agent: true` answers \"may a bound agent call\n * this\", which is an orthogonal question — a nightly reconciliation that an\n * analyst can also ask an agent to run on demand is one automation, not two.\n * So `agent` rides alongside either, and the third arm exists for the\n * agent-only automation, which has no self-starting trigger at all.\n *\n * Declaring it is only the AUTHOR's half of the permission. A workspace\n * operator must still bind the automation to one named agent before any tool\n * is projected; see the design in\n * docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never; agent?: true }\n | { cron?: never; manual: true; agent?: true }\n | { cron?: never; manual?: never; agent: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n /** Read the conversation that started the run — see `ctx.conversation`. */\n | 'conversation:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these.\n * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.\n * On a `files` field it applies to each file.\n * A declaration aid: the value on the run row is only a reference, so this is\n * enforced server-side at upload and at run start, never against the value here. */\n accept?: readonly string[]\n /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.\n * On a `files` field it applies to each file. */\n maxBytes?: number\n /** `files` type only. The most files the field holds: a whole number from 1\n * to 50, 20 when absent. */\n maxFiles?: number\n /**\n * Mask this field's VALUE wherever a person reads the run.\n *\n * What it changes: the run list, the run page and `frontera function runs`\n * show a placeholder instead of the value. What it does NOT change: the\n * handler, every resumption and every retried attempt receive the real value,\n * because the run row still holds it — this is a display control, not\n * storage encryption and not an access control.\n *\n * What it CANNOT cover, stated here so the flag never reads as a promise it\n * does not keep:\n *\n * - `ctx.log('…', { key: ctx.input.token })` — an author writing a value\n * into a step detail publishes it, and nothing here can intercept that.\n * - a redacted value the author TRANSFORMS before using it. The agent\n * transcript on the run page is masked by exact occurrence, so a value\n * interpolated into a prompt — or quoted back in the reply — is replaced.\n * A value upper-cased, truncated or reformatted first no longer matches\n * and is not found. Exact match is what can be done without guessing at\n * substrings; the alternative, withholding transcripts entirely for any\n * run with a redacted input, would take the review surface away from\n * exactly the runs that most need reviewing.\n * - `default` and `enum`, which are published in the version manifest and in\n * any tool schema built from it. Declaring either alongside `redact` is\n * refused at deploy for exactly that reason.\n * - the run's own `result` and error message. A handler that returns the\n * value — `return { note: ctx.input.customer_note }` — or throws an error\n * quoting it publishes it on the same run page, unmasked, next to the\n * masked input it came from. Only the INPUT is masked; what the handler\n * chooses to emit is the handler's decision.\n * - anything already recorded. Versions are append-only and the mask is\n * frozen onto each run when it starts, so adding `redact` masks future\n * runs and never rewrites history.\n *\n * A credential still belongs in a `secret:` grant, whose value never enters\n * this process at all. `redact` is for the ordinary personal or commercial\n * detail a run legitimately takes and a bystander has no reason to read.\n */\n redact?: boolean\n}\n\n\n/**\n * The value of a `file`-typed input.\n *\n * A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the\n * canonical handle from the platform's unified file registry (see\n * docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in\n * storage; only this small id rides on the run row, so the 64KB input cap is\n * untouched.\n *\n * One reference, two entry points: the run-form uploader gets a `fileId` for a\n * file a person drops in, and an agent passes the `fileId` of a chat attachment\n * it is already holding — both resolve identically downstream. The client never\n * supplies a trusted path or URL; the SERVER authorizes the `fileId` against the\n * caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.\n */\nexport interface FileRef {\n fileId: string\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n /**\n * One headless agent turn. `options.files` hands the agent already-uploaded\n * files by canonical id — the same `{ fileId }` a `file`-typed run input\n * carries, so an input forwards directly: `run(p, { files: [ctx.input.doc] })`.\n * Each id is authorized against this run's workspace and the bytes are staged\n * onto the agent's computer; the agent is told the staged paths.\n *\n * `options.timeoutMs` waits longer than the default 120 s for work known to\n * be long (a long scanned document); the service caps it at 480 s, under\n * the run's own 10-minute ceiling.\n *\n * Call it at the TOP LEVEL of the handler, not inside `ctx.step.run`. There\n * the wait is durable: the turn is started once, the run is parked until it\n * finishes, and a resumption replays the answer instead of asking again.\n * Inside a step body (steps cannot nest) the call waits on one request, and\n * a step that is re-run asks the agent again.\n */\n run(prompt: string, options?: { files?: readonly FileRef[]; timeoutMs?: number }): Promise<{ text: string }>\n}\n\n/**\n * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch\n * its bytes. `null` only in a dry dev run.\n *\n * There is no URL. In the deployed runner a Function cannot open a network\n * socket, and a signed URL would be a live credential; the readers download\n * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a\n * large file need not stay in memory.\n */\nexport interface ResolvedFileHandle {\n fileId: string\n mimeType: string\n sizeBytes: number\n name: string | null\n /**\n * The whole file as bytes.\n *\n * Each reader downloads the file again, through a link that expires about an\n * hour after `ctx.file` returned the handle. Read it soon after, or call\n * `ctx.file` again rather than keeping a handle across a long wait.\n */\n bytes(): Promise<Uint8Array>\n /** The whole file decoded as UTF-8 text. */\n text(): Promise<string>\n /** The file as a stream, for reading large files without holding them in memory. */\n stream(): Promise<ReadableStream<Uint8Array>>\n}\n\n/**\n * The conversation that started a run, as the person saw it: what they wrote\n * and what the agent wrote back, as text.\n *\n * Only the stretch that belongs to this run is included — earlier requests in\n * the same conversation, already handled by earlier runs that read a\n * transcript and finished successfully or are still running, and anything\n * before a quiet gap of more than six hours are left out.\n *\n * Replaced with `[removed]`: card numbers of the major card brands (Visa,\n * Mastercard, American Express, Discover, UnionPay), with the digit groups\n * separated by a single space or dash, or written together (an expiry or\n * security code after the number stays); Singapore NRIC/FIN numbers; and —\n * within the six words after \"password\", \"passcode\", \"PIN\", \"OTP\", \"one-time\n * code\" or \"verification code\" (or the Indonesian and Malay \"kata sandi\",\n * \"sandi\", \"kode verifikasi\", \"kode OTP\", \"kata laluan\", \"kod pengesahan\"), or\n * the first six words of the person's reply right after the agent asks for one\n * of these — any word containing a digit.\n * Words without a digit stay. This removes obvious secrets; it is not a guarantee: keep the\n * transcript where only the people who need it can read it.\n */\nexport interface ConversationTranscript {\n /** The agent the person was talking to. */\n agentName: string\n /** ISO 8601 time of the first included turn. */\n startedAt: string\n /** ISO 8601 time of the last included turn. */\n endedAt: string\n /** Oldest first. A card the agent showed reads as one line of text. */\n turns: Array<{ role: 'user' | 'agent'; text: string; at: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour. It is also recorded on\n * the step's row — capped, and replaced by its size when it is too large —\n * so the run trace can show what the step produced. Never return a secret\n * from a step: details are rendered verbatim in the Console.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\n | 'received'\n | 'awaiting_approval'\n | 'ready'\n | 'executing'\n | 'finalizing'\n | 'succeeded'\n | 'rejected'\n | 'expired'\n | 'cancelled'\n | 'failed'\n | 'outcome_unknown'\n | 'awaiting_resolution'\n | 'closed_unknown'\n\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n /**\n * The Action's inputs by name. An input that attaches a file accepts one of\n * this run's file inputs as passed in — `{ fileId }`, e.g. `ctx.input.photo`.\n *\n * For an Action that changes an existing record, leave out the input named\n * like the object type's primary key and the `expectedVersion` input: they\n * are filled from `subjectRef` and `expectedSubjectVersion` (`subjectOf`\n * gives both). Sent anyway, they must name the same record and version, or\n * the submission is refused naming the input.\n *\n * An input that references another record takes that record's primary key,\n * as a query row carries it (`'TCH-00026'`, or a UUID).\n */\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * Who asked an Agent for this run, as the platform knows them. Set by the\n * platform beside the run's input, never by the model, so a Function can\n * trust it for identity and authorization.\n */\nexport type FunctionCaller = (\n /** A signed-in member of the workspace. */\n | { kind: 'person'; userId: string }\n /** A system calling with one of the Agent's API keys. */\n | { kind: 'api-key'; apiKeyId: string }\n /** An end user signed in with a chat token your backend minted (`externalUserId` is your id for them). */\n | { kind: 'external-user'; externalUserId: string }\n /**\n * A customer on a messaging channel. `application` is the channel\n * application's name, or the workspace channel's slug; `customerId` is the\n * channel's id for the customer, such as a phone number.\n */\n | { kind: 'channel'; application: string; customerId: string }\n) & {\n /** The Agent whose tool call started this run. */\n agentId: string\n /** The conversation it was in. */\n conversationId: string\n}\n\n/**\n * The longest `ctx.person.confirm` waits, in minutes. A Function run ends at\n * ten minutes, waits included, so a question holds at most eight and leaves\n * two for the Function's own work, the same ceiling as `ctx.agent`'s\n * `timeoutMs`. A longer wait for a person belongs in a workflow's confirm node.\n */\nexport const PERSON_CONFIRM_MAX_MINUTES = 8\n/** How long `ctx.person.confirm` waits when the Function names no `timeoutMinutes`. */\nexport const PERSON_CONFIRM_DEFAULT_MINUTES = 5\n\n/** What `ctx.person.confirm` asks the person. */\nexport interface PersonConfirmRequest {\n /** What will be done, in one sentence the person can say yes to (\"Refund 49.00 to the card ending 4242\"). */\n summary: string\n /** Anything else they should see first, as plain text. */\n details?: string\n /** The confirm button's label; \"Confirm\" by default. */\n confirmLabel?: string\n /** The cancel button's label; \"Cancel\" by default. */\n cancelLabel?: string\n /**\n * How long the question stays open, in whole minutes from 1 to 8; 5 by\n * default. The run still ends at ten minutes, waits included.\n */\n timeoutMinutes?: number\n}\n\n/**\n * The person's answer. `unavailable`: no Agent started this run from a\n * conversation (a schedule, a manual run, a workflow, a dry run), or the Agent\n * was talking to nobody the platform can name, so there is nobody to ask; or\n * the conversation is on a messaging channel, which cannot show the question.\n */\nexport type PersonConfirmResult = 'confirmed' | 'cancelled' | 'expired' | 'unavailable'\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 * Who asked for this run, when an Agent's tool call started it: the person,\n * API key, chat-token user or channel customer in that conversation. Set by\n * the platform, not the model, and the same on every resumption. `null`\n * when no Agent started the run (a schedule, a manual run, an App, a\n * workflow), when the Agent was talking to nobody the platform can name\n * (an Agent another Function ran with no person behind it), and in dry\n * (dev) runs.\n */\n caller: FunctionCaller | null\n /**\n * The conversation's variables, by name: the values its entry point\n * supplied when it started (the SDK or API, a channel). Set by the\n * platform, not the model. `{}` when there are none or no Agent started\n * the run.\n */\n variables: Record<string, unknown>\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 person: {\n /**\n * Ask the person in the conversation that started this run to confirm\n * before the run acts. They answer on a card in web chat; in an API\n * conversation, the system holding the Agent's key answers. The Agent\n * waits for the answer, and so does this run, durably, without holding a\n * connection. A conversation on a messaging channel (WhatsApp, Telegram)\n * cannot show the question yet and answers `unavailable` at once.\n *\n * Call it at the top level of the handler, not inside `ctx.step.run`, and\n * act on the answer after it: do the work in a step only when it is\n * `confirmed`. The question is yours, not the Agent's model's: write\n * everything the person needs to decide in `summary` and `details`.\n */\n confirm(request: PersonConfirmRequest): Promise<PersonConfirmResult>\n }\n conversation: {\n /**\n * The conversation that started THIS run, as the person saw it.\n *\n * `null` when the run was not started from a conversation (a schedule, a\n * manual run, an App), when that conversation was deleted, and in dry (dev)\n * runs.\n *\n * Call it inside a `ctx.step.run` step — required, not a style choice.\n * Outside a step it reads the conversation again on every resumption, and\n * what it returns can change between reads: an earlier run from the same\n * conversation that finishes, or reads, in the meantime moves where this\n * run's part begins. Act on it in that same step and return only a\n * summary, because step results are kept to resume the run.\n *\n * When a step returns the transcript,\n * its `turns` or any of its turns — alone or inside objects and arrays up\n * to four levels deep — its trace row records only the turn count; a\n * result too large or too deep to check completely is not recorded at all.\n * Everything else is recorded as written: text copied out of it, including\n * new objects built from its turns (`turns.map(t => ({ role: t.role, text:\n * t.text }))`), a copy restored after the run resumes, an error message\n * built from it, and the Function's final return value (recorded as the\n * run's result). Requires the\n * `conversation:read` grant. Reads only the run's own conversation — there\n * is no way to name another one.\n */\n transcript(): Promise<ConversationTranscript | null>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n *\n * An Action input that attaches a file accepts `{ fileId }` for one of this\n * run's own file inputs; the platform stores it in the Action's media set.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /**\n * A credential the platform adds, which this process never sees. Needs a\n * `secret:<name>` grant either way.\n *\n * - `{ header, secret, prefix? }` puts the secret's value in one header.\n * - `{ githubApp: { secret, installationId } }` calls GitHub as a GitHub App\n * installation: the platform signs a JWT with the App's private key from\n * the secret (JSON `{ \"appId\": 123, \"privateKey\": \"-----BEGIN …\" }`), trades\n * it for the installation's one-hour token, reuses that until five minutes\n * before it expires, and sends `Authorization: Bearer <token>`. An event\n * GitHub sends to the App carries the id as `payload.installation.id`.\n */\n auth?: { header: string; secret: string; prefix?: string } | { githubApp: { secret: string; installationId: number | 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\n/**\n * What the platform attaches to a row it can version: `_meta` on each row of\n * an editable object type (one whose properties a governed Action may change).\n */\nexport interface BlueprintRecordMeta {\n /**\n * The record's edit version — `0` for a record nobody has edited. Send it,\n * as a string, as an Action's `expectedSubjectVersion`: a submission built\n * from a stale read then loses instead of overwriting. `subjectOf` does both\n * halves for you.\n *\n * Absent when the row changed while it was being read; read it again.\n */\n recordVersion: number\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n /**\n * Each row of an editable type carries `_meta: { recordVersion }`. A type\n * that defines its own `_meta` column keeps its value, and has no version.\n *\n * A timestamp property is ISO 8601 text in UTC (`'2026-10-17T09:17:00.000Z'`,\n * which `new Date()` reads), a date property `'YYYY-MM-DD'`.\n */\n rows: Array<T & { _meta?: BlueprintRecordMeta }>\n /**\n * The object type's stable id — what `subjectRef.objectTypeId` takes. Present\n * whenever rows came back.\n */\n objectTypeId?: string\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/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { NearestRequest, WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron, manual, and agent for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n *\n * `agent` is deliberately NOT part of that exclusion. Cron and manual answer\n * \"what fires this on its own\"; `agent: true` answers \"may a bound agent call\n * this\", which is an orthogonal question — a nightly reconciliation that an\n * analyst can also ask an agent to run on demand is one automation, not two.\n * So `agent` rides alongside either, and the third arm exists for the\n * agent-only automation, which has no self-starting trigger at all.\n *\n * Declaring it is only the AUTHOR's half of the permission. A workspace\n * operator must still bind the automation to one named agent before any tool\n * is projected; see the design in\n * docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never; agent?: true }\n | { cron?: never; manual: true; agent?: true }\n | { cron?: never; manual?: never; agent: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n /** Read the conversation that started the run — see `ctx.conversation`. */\n | 'conversation:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file' | 'files'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these.\n * Not permitted on a `file` or `files` field — a file has no meaningful literal default. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n /** `file` and `files` types only. Allowed MIME patterns, e.g. `['image/*', 'application/pdf']`.\n * On a `files` field it applies to each file.\n * A declaration aid: the value on the run row is only a reference, so this is\n * enforced server-side at upload and at run start, never against the value here. */\n accept?: readonly string[]\n /** `file` and `files` types only. Maximum upload size in bytes, enforced server-side.\n * On a `files` field it applies to each file. */\n maxBytes?: number\n /** `files` type only. The most files the field holds: a whole number from 1\n * to 50, 20 when absent. */\n maxFiles?: number\n /**\n * Mask this field's VALUE wherever a person reads the run.\n *\n * What it changes: the run list, the run page and `frontera function runs`\n * show a placeholder instead of the value. What it does NOT change: the\n * handler, every resumption and every retried attempt receive the real value,\n * because the run row still holds it — this is a display control, not\n * storage encryption and not an access control.\n *\n * What it CANNOT cover, stated here so the flag never reads as a promise it\n * does not keep:\n *\n * - `ctx.log('…', { key: ctx.input.token })` — an author writing a value\n * into a step detail publishes it, and nothing here can intercept that.\n * - a redacted value the author TRANSFORMS before using it. The agent\n * transcript on the run page is masked by exact occurrence, so a value\n * interpolated into a prompt — or quoted back in the reply — is replaced.\n * A value upper-cased, truncated or reformatted first no longer matches\n * and is not found. Exact match is what can be done without guessing at\n * substrings; the alternative, withholding transcripts entirely for any\n * run with a redacted input, would take the review surface away from\n * exactly the runs that most need reviewing.\n * - `default` and `enum`, which are published in the version manifest and in\n * any tool schema built from it. Declaring either alongside `redact` is\n * refused at deploy for exactly that reason.\n * - the run's own `result` and error message. A handler that returns the\n * value — `return { note: ctx.input.customer_note }` — or throws an error\n * quoting it publishes it on the same run page, unmasked, next to the\n * masked input it came from. Only the INPUT is masked; what the handler\n * chooses to emit is the handler's decision.\n * - anything already recorded. Versions are append-only and the mask is\n * frozen onto each run when it starts, so adding `redact` masks future\n * runs and never rewrites history.\n *\n * A credential still belongs in a `secret:` grant, whose value never enters\n * this process at all. `redact` is for the ordinary personal or commercial\n * detail a run legitimately takes and a bystander has no reason to read.\n */\n redact?: boolean\n}\n\n\n/**\n * The value of a `file`-typed input.\n *\n * A REFERENCE to an already-uploaded file, never its bytes: `fileId` is the\n * canonical handle from the platform's unified file registry (see\n * docs/superpowers/plans/2026-08-27-unified-file-layer.md). Bytes live in\n * storage; only this small id rides on the run row, so the 64KB input cap is\n * untouched.\n *\n * One reference, two entry points: the run-form uploader gets a `fileId` for a\n * file a person drops in, and an agent passes the `fileId` of a chat attachment\n * it is already holding — both resolve identically downstream. The client never\n * supplies a trusted path or URL; the SERVER authorizes the `fileId` against the\n * caller's workspace (`resolveFile`) and resolves it to bytes/URL when read.\n */\nexport interface FileRef {\n fileId: string\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n /**\n * One headless agent turn. `options.files` hands the agent already-uploaded\n * files by canonical id — the same `{ fileId }` a `file`-typed run input\n * carries, so an input forwards directly: `run(p, { files: [ctx.input.doc] })`.\n * Each id is authorized against this run's workspace and the bytes are staged\n * onto the agent's computer; the agent is told the staged paths.\n *\n * `options.timeoutMs` waits longer than the default 120 s for work known to\n * be long (a long scanned document); the service caps it at 480 s, under\n * the run's own 10-minute ceiling.\n *\n * Call it at the TOP LEVEL of the handler, not inside `ctx.step.run`. There\n * the wait is durable: the turn is started once, the run is parked until it\n * finishes, and a resumption replays the answer instead of asking again.\n * Inside a step body (steps cannot nest) the call waits on one request, and\n * a step that is re-run asks the agent again.\n */\n run(prompt: string, options?: { files?: readonly FileRef[]; timeoutMs?: number }): Promise<{ text: string }>\n}\n\n/**\n * What `ctx.file(ref)` resolves to: the file's identity plus readers that fetch\n * its bytes. `null` only in a dry dev run.\n *\n * There is no URL. In the deployed runner a Function cannot open a network\n * socket, and a signed URL would be a live credential; the readers download\n * over the runner's forwarder inside the SDK. Each reader fetches afresh, so a\n * large file need not stay in memory.\n */\nexport interface ResolvedFileHandle {\n fileId: string\n mimeType: string\n sizeBytes: number\n name: string | null\n /**\n * The whole file as bytes.\n *\n * Each reader downloads the file again, through a link that expires about an\n * hour after `ctx.file` returned the handle. Read it soon after, or call\n * `ctx.file` again rather than keeping a handle across a long wait.\n */\n bytes(): Promise<Uint8Array>\n /** The whole file decoded as UTF-8 text. */\n text(): Promise<string>\n /** The file as a stream, for reading large files without holding them in memory. */\n stream(): Promise<ReadableStream<Uint8Array>>\n}\n\n/**\n * The conversation that started a run, as the person saw it: what they wrote\n * and what the agent wrote back, as text.\n *\n * Only the stretch that belongs to this run is included — earlier requests in\n * the same conversation, already handled by earlier runs that read a\n * transcript and finished successfully or are still running, and anything\n * before a quiet gap of more than six hours are left out.\n *\n * Replaced with `[removed]`: card numbers of the major card brands (Visa,\n * Mastercard, American Express, Discover, UnionPay), with the digit groups\n * separated by a single space or dash, or written together (an expiry or\n * security code after the number stays); Singapore NRIC/FIN numbers; and —\n * within the six words after \"password\", \"passcode\", \"PIN\", \"OTP\", \"one-time\n * code\" or \"verification code\" (or the Indonesian and Malay \"kata sandi\",\n * \"sandi\", \"kode verifikasi\", \"kode OTP\", \"kata laluan\", \"kod pengesahan\"), or\n * the first six words of the person's reply right after the agent asks for one\n * of these — any word containing a digit.\n * Words without a digit stay. This removes obvious secrets; it is not a guarantee: keep the\n * transcript where only the people who need it can read it.\n */\nexport interface ConversationTranscript {\n /** The agent the person was talking to. */\n agentName: string\n /** ISO 8601 time of the first included turn. */\n startedAt: string\n /** ISO 8601 time of the last included turn. */\n endedAt: string\n /** Oldest first. A card the agent showed reads as one line of text. */\n turns: Array<{ role: 'user' | 'agent'; text: string; at: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour. It is also recorded on\n * the step's row — capped, and replaced by its size when it is too large —\n * so the run trace can show what the step produced. Never return a secret\n * from a step: details are rendered verbatim in the Console.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\n | 'received'\n | 'awaiting_approval'\n | 'ready'\n | 'executing'\n | 'finalizing'\n | 'succeeded'\n | 'rejected'\n | 'expired'\n | 'cancelled'\n | 'failed'\n | 'outcome_unknown'\n | 'awaiting_resolution'\n | 'closed_unknown'\n\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n /**\n * The Action's inputs by name. An input that attaches a file accepts one of\n * this run's file inputs as passed in — `{ fileId }`, e.g. `ctx.input.photo`.\n *\n * For an Action that changes an existing record, leave out the input named\n * like the object type's primary key and the `expectedVersion` input: they\n * are filled from `subjectRef` and `expectedSubjectVersion` (`subjectOf`\n * gives both). Sent anyway, they must name the same record and version, or\n * the submission is refused naming the input.\n *\n * An input that references another record takes that record's primary key,\n * as a query row carries it (`'TCH-00026'`, or a UUID).\n */\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * Who asked an Agent for this run, as the platform knows them. Set by the\n * platform beside the run's input, never by the model, so a Function can\n * trust it for identity and authorization.\n */\nexport type FunctionCaller = (\n /** A signed-in member of the workspace. */\n | { kind: 'person'; userId: string }\n /** A system calling with one of the Agent's API keys. */\n | { kind: 'api-key'; apiKeyId: string }\n /** An end user signed in with a chat token your backend minted (`externalUserId` is your id for them). */\n | { kind: 'external-user'; externalUserId: string }\n /**\n * A customer on a messaging channel. `application` is the channel\n * application's name, or the workspace channel's slug; `customerId` is the\n * channel's id for the customer, such as a phone number.\n */\n | { kind: 'channel'; application: string; customerId: string }\n) & {\n /** The Agent whose tool call started this run. */\n agentId: string\n /** The conversation it was in. */\n conversationId: string\n}\n\n/**\n * The longest `ctx.person.confirm` waits, in minutes. A Function run ends at\n * ten minutes, waits included, so a question holds at most eight and leaves\n * two for the Function's own work, the same ceiling as `ctx.agent`'s\n * `timeoutMs`. A longer wait for a person belongs in a workflow's confirm node.\n */\nexport const PERSON_CONFIRM_MAX_MINUTES = 8\n/** How long `ctx.person.confirm` waits when the Function names no `timeoutMinutes`. */\nexport const PERSON_CONFIRM_DEFAULT_MINUTES = 5\n\n/** What `ctx.person.confirm` asks the person. */\nexport interface PersonConfirmRequest {\n /** What will be done, in one sentence the person can say yes to (\"Refund 49.00 to the card ending 4242\"). */\n summary: string\n /** Anything else they should see first, as plain text. */\n details?: string\n /** The confirm button's label; \"Confirm\" by default. */\n confirmLabel?: string\n /** The cancel button's label; \"Cancel\" by default. */\n cancelLabel?: string\n /**\n * How long the question stays open, in whole minutes from 1 to 8; 5 by\n * default. The run still ends at ten minutes, waits included.\n */\n timeoutMinutes?: number\n}\n\n/**\n * The person's answer. `unavailable`: no Agent started this run from a\n * conversation (a schedule, a manual run, a workflow, a dry run), or the Agent\n * was talking to nobody the platform can name, so there is nobody to ask; or\n * the conversation is on a messaging channel, which cannot show the question.\n */\nexport type PersonConfirmResult = 'confirmed' | 'cancelled' | 'expired' | 'unavailable'\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 * Who asked for this run, when an Agent's tool call started it: the person,\n * API key, chat-token user or channel customer in that conversation. Set by\n * the platform, not the model, and the same on every resumption. `null`\n * when no Agent started the run (a schedule, a manual run, an App, a\n * workflow), when the Agent was talking to nobody the platform can name\n * (an Agent another Function ran with no person behind it), and in dry\n * (dev) runs.\n */\n caller: FunctionCaller | null\n /**\n * The conversation's variables, by name: the values its entry point\n * supplied when it started (the SDK or API, a channel). Set by the\n * platform, not the model. `{}` when there are none or no Agent started\n * the run.\n */\n variables: Record<string, unknown>\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 person: {\n /**\n * Ask the person in the conversation that started this run to confirm\n * before the run acts. They answer on a card in web chat; in an API\n * conversation, the system holding the Agent's key answers. The Agent\n * waits for the answer, and so does this run, durably, without holding a\n * connection. A conversation on a messaging channel (WhatsApp, Telegram)\n * cannot show the question yet and answers `unavailable` at once.\n *\n * Call it at the top level of the handler, not inside `ctx.step.run`, and\n * act on the answer after it: do the work in a step only when it is\n * `confirmed`. The question is yours, not the Agent's model's: write\n * everything the person needs to decide in `summary` and `details`.\n */\n confirm(request: PersonConfirmRequest): Promise<PersonConfirmResult>\n }\n conversation: {\n /**\n * The conversation that started THIS run, as the person saw it.\n *\n * `null` when the run was not started from a conversation (a schedule, a\n * manual run, an App), when that conversation was deleted, and in dry (dev)\n * runs.\n *\n * Call it inside a `ctx.step.run` step — required, not a style choice.\n * Outside a step it reads the conversation again on every resumption, and\n * what it returns can change between reads: an earlier run from the same\n * conversation that finishes, or reads, in the meantime moves where this\n * run's part begins. Act on it in that same step and return only a\n * summary, because step results are kept to resume the run.\n *\n * When a step returns the transcript,\n * its `turns` or any of its turns — alone or inside objects and arrays up\n * to four levels deep — its trace row records only the turn count; a\n * result too large or too deep to check completely is not recorded at all.\n * Everything else is recorded as written: text copied out of it, including\n * new objects built from its turns (`turns.map(t => ({ role: t.role, text:\n * t.text }))`), a copy restored after the run resumes, an error message\n * built from it, and the Function's final return value (recorded as the\n * run's result). Requires the\n * `conversation:read` grant. Reads only the run's own conversation — there\n * is no way to name another one.\n */\n transcript(): Promise<ConversationTranscript | null>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n *\n * An Action input that attaches a file accepts `{ fileId }` for one of this\n * run's own file inputs; the platform stores it in the Action's media set.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /**\n * A credential the platform adds, which this process never sees. Needs a\n * `secret:<name>` grant either way.\n *\n * - `{ header, secret, prefix? }` puts the secret's value in one header.\n * - `{ githubApp: { secret, installationId } }` calls GitHub as a GitHub App\n * installation: the platform signs a JWT with the App's private key from\n * the secret (JSON `{ \"appId\": 123, \"privateKey\": \"-----BEGIN …\" }`), trades\n * it for the installation's one-hour token, reuses that until five minutes\n * before it expires, and sends `Authorization: Bearer <token>`. An event\n * GitHub sends to the App carries the id as `payload.installation.id`.\n */\n auth?: { header: string; secret: string; prefix?: string } | { githubApp: { secret: string; installationId: number | 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\n/**\n * What the platform attaches to a row it can version: `_meta` on each row of\n * an editable object type (one whose properties a governed Action may change).\n */\nexport interface BlueprintRecordMeta {\n /**\n * The record's edit version — `0` for a record nobody has edited. Send it,\n * as a string, as an Action's `expectedSubjectVersion`: a submission built\n * from a stale read then loses instead of overwriting. `subjectOf` does both\n * halves for you.\n *\n * Absent when the row changed while it was being read; read it again.\n */\n recordVersion: number\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n /**\n * Each row of an editable type carries `_meta: { recordVersion }`. A type\n * that defines its own `_meta` column keeps its value, and has no version.\n *\n * A timestamp property is ISO 8601 text in UTC (`'2026-10-17T09:17:00.000Z'`,\n * which `new Date()` reads), a date property `'YYYY-MM-DD'`.\n */\n rows: Array<T & { _meta?: BlueprintRecordMeta }>\n /**\n * The object type's stable id — what `subjectRef.objectTypeId` takes. Present\n * whenever rows came back.\n */\n objectTypeId?: string\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', 'mcp', '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', 'mcp'] as const\nexport type TicketedTriggerSource = (typeof TICKETED_TRIGGER_SOURCES)[number]\n\nexport function isEventTriggerSource(value: unknown): value is EventTriggerSource {\n return typeof value === 'string'\n && (EVENT_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n\nexport function isTicketedTriggerSource(value: unknown): value is TicketedTriggerSource {\n return typeof value === 'string'\n && (TICKETED_TRIGGER_SOURCES as readonly string[]).includes(value)\n}\n",
31
31
  "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 personConfirmTimeoutRefusal,\n signedUrlRemovedMessage,\n} from './messages'\nimport { FronteraCallError, refusalOf } from './errors'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n FunctionCaller,\n PersonConfirmRequest,\n PersonConfirmResult,\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 = new RegExp(String.fromCharCode(0), '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 that stays marked 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 * The `i`-th wait of `ctx.person.confirm`: 10 s, doubling, up to a minute. An\n * answer wakes the wait at once; the slice only bounds a missed wake-up.\n */\nfunction confirmSliceMs(i: number): number {\n return Math.min(10_000 * 2 ** i, AGENT_WAIT_SLICE_MS)\n}\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. */\n/** A refusal as a step memoizes it: the error's fields, since a step's value must be plain data. */\ntype Refusal = { call: string; status: number; message: string; code: string; details?: unknown }\nconst memoizedRefusal = (err: FronteraCallError): Refusal => ({\n call: err.call,\n status: err.status,\n message: err.message.slice(`${err.call} → ${err.status} `.length),\n code: err.code,\n ...(err.details !== undefined ? { details: err.details } : {}),\n})\n\ntype AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string; refusal?: Refusal }) & {\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; refusal?: Refusal }\n | { done: false; now: number }\n\ninterface Deps {\n runId: string\n workspaceId: string\n /** An Agent-started run's trusted context, from `startRun`. */\n context?: { caller: Record<string, unknown>; agentId: string; conversationId: string; variables: Record<string, unknown> } | null\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 * Thrown as a `FronteraCallError`, which carries the platform's `code`,\n * `status` and `details`, so a handler branches on\n * `err.code === 'ACTION_REQUEST_ALREADY_OPEN'` instead of the wording.\n */\n const refused = async (call: string, res: Response): Promise<FronteraCallError> => {\n const r = refusalOf(await res.text())\n return new FronteraCallError(call, res.status, r.message, r)\n }\n /** A memoized refusal thrown again on a replay, as the error it was. */\n const rebuilt = (r: Refusal) => new FronteraCallError(r.call, r.status, r.message, r)\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 await refused(`agent ${slug}`, 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; code?: string; details?: unknown }\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 FronteraCallError(`agent ${slug}`, body.status, body.message, body)\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.person.confirm` without a held connection, the way `agentDurable`\n * waits for a turn:\n *\n * person.confirm:<n>:open ask (the service finds the conversation)\n * person.confirm:<n>:read:<i> read the answer; answered or expired → done\n * person.confirm:<n>:wait:<i> park until the answer's wake-up, or a slice\n *\n * The read is the answer and the event only a wake-up (the service sends\n * `agent/turn.finished` keyed by the confirmation's id when it is answered).\n */\n let confirmCalls = 0\n const personConfirm = async (request: PersonConfirmRequest): Promise<PersonConfirmResult> => {\n if (stepScope.getStore()) {\n throw new Error('ctx.person.confirm must be called at the top level of the handler, not inside ctx.step.run')\n }\n const badTimeout = personConfirmTimeoutRefusal(request.timeoutMinutes)\n if (badTimeout) throw new Error(badTimeout)\n const base = `person.confirm:${confirmCalls++}`\n const opened = (await deps.step.run(`${base}:open`, async () => {\n const res = await scoped('/ctx/person-confirmation', {\n method: 'POST',\n body: JSON.stringify({ key: base, ...request }),\n })\n if (!res.ok) throw await refused('person.confirm', res)\n return ((await res.json()) as { data: { id?: string; status: string; expiresAt?: string } }).data\n })) as { id?: string; status: string; expiresAt?: string }\n if (opened.status !== 'pending' || !opened.id) return opened.status as PersonConfirmResult\n for (let i = 0; ; i++) {\n const read = (await deps.step.run(`${base}:read:${i}`, async () => {\n const res = await scoped(`/ctx/person-confirmation/${opened.id}`, { method: 'GET' }).catch(() => null)\n // Refused permanently: waiting on it would only spend the run's steps.\n if (res && res.status >= 400 && res.status < 500) throw await refused('person.confirm', res)\n // Unreachable or failing for now: read again after the next wait.\n const status = res?.ok ? ((await res.json()) as { data: { status: string } }).data.status : 'pending'\n return { status, now: Date.now() }\n })) as { status: string; now: number }\n if (read.status !== 'pending') return read.status as PersonConfirmResult\n const slice = confirmSliceMs(i)\n const remaining = Date.parse(opened.expiresAt ?? '') - read.now\n // Past its expiry by this run's clock but not the service's: a whole slice, then read again.\n const sliceMs = Math.max(1_000, remaining > 0 ? Math.min(slice, remaining) : slice)\n if (deps.step.waitForEvent) {\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 == \"${opened.id}\"`,\n })\n } else {\n // No durable wait on this platform: poll within this execution.\n await new Promise((resolve) => setTimeout(resolve, Math.min(sliceMs, 2_000)))\n }\n }\n }\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 err = await refused(`agent ${slug}`, res)\n const message = err.message\n await recordStep({ kind: 'agent', label, status: 'error', detail: { message }, durationMs: Date.now() - startedAt })\n return { ok: false, message, refusal: memoizedRefusal(err), 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 start.refusal ? rebuilt(start.refusal) : 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; refusal?: Refusal }): 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 const err = await refused(`agent ${slug}`, res)\n return settle({ error: err.message, refusal: memoizedRefusal(err) })\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 read.refusal ? rebuilt(read.refusal) : 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 caller: deps.context\n ? ({ ...deps.context.caller, agentId: deps.context.agentId, conversationId: deps.context.conversationId } as FunctionCaller)\n : null,\n variables: deps.context?.variables ?? {},\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 await refused(`file ${ref.fileId}`, 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) throw await refused(`ctx.plugin(\"${install}\").call(\"${capability}\")`, res)\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 await refused('ctx.http', 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 await refused('ctx.action.submit', 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 await refused('blueprint query', 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 person: { confirm: personConfirm },\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 await refused('ctx.conversation.transcript', 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",
32
32
  "frontera/automation/subject.ts": "import type { BlueprintQueryResult } from './types'\n\n/**\n * The subject half of an Action submission, for one row of a query.\n *\n * An Action that changes an existing record needs `subjectRef` (which record)\n * and `expectedSubjectVersion` (the version you read it at). Both come from the\n * same read, so build them from it:\n *\n * ```ts\n * const found = await ctx.blueprint.query('WorkOrder', {\n * where: { property: 'id', op: 'eq', value: ctx.input.work_order_id },\n * limit: 1,\n * })\n * const order = found.rows[0]\n * if (!order) return { submitted: false }\n * await ctx.step.run('assign', () => ctx.action.submit({\n * action: 'assignTechnician',\n * ...subjectOf(found, order, order.id),\n * input: { technician: ctx.input.technician_id },\n * }))\n * ```\n *\n * `objectId` is the row's primary key value. Throws, naming the reason, when\n * the row carries no version: its type has no editable properties (so no\n * Action can change it), or the row changed while it was read (read again).\n */\nexport function subjectOf(\n result: Pick<BlueprintQueryResult<unknown>, 'objectTypeId'>,\n row: unknown,\n objectId: string | number,\n): { subjectRef: { objectTypeId: string; objectId: string }; expectedSubjectVersion: string } {\n if (!result.objectTypeId) {\n throw new Error('subjectOf: pass the query result the row came from — it carries the object type id.')\n }\n const meta = row && typeof row === 'object' ? (row as { _meta?: unknown })._meta : undefined\n const version = meta && typeof meta === 'object'\n ? (meta as { recordVersion?: unknown }).recordVersion\n : undefined\n if (typeof version !== 'number') {\n throw new Error(\n 'subjectOf: this row has no record version. Either its object type has no editable properties, '\n + 'so no Action can change it, or the record changed while it was read — query it again.',\n )\n }\n return {\n subjectRef: { objectTypeId: result.objectTypeId, objectId: String(objectId) },\n expectedSubjectVersion: String(version),\n }\n}\n",
33
33
  "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",