mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(observability): add mothership tracing (#4253)
This commit is contained in:
committed by
GitHub
parent
41a1b50ace
commit
0aeab026a8
122
scripts/generate-mship-contracts.ts
Normal file
122
scripts/generate-mship-contracts.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bun
|
||||
// Drive every mothership contract generator, then biome-format the
|
||||
// outputs so the committed files match what biome produces on commit
|
||||
// (avoids the stale-drift that comes from comparing raw json2ts output
|
||||
// against biome-formatted source).
|
||||
//
|
||||
// `--check` regenerates into a temp directory, formats identically,
|
||||
// and compares against the committed files — same semantics as the
|
||||
// old per-script `--check`, but accounts for post-generate formatting.
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const GENERATORS = [
|
||||
'scripts/sync-mothership-stream-contract.ts',
|
||||
'scripts/sync-tool-catalog.ts',
|
||||
'scripts/sync-request-trace-contract.ts',
|
||||
'scripts/sync-trace-spans-contract.ts',
|
||||
'scripts/sync-trace-attributes-contract.ts',
|
||||
'scripts/sync-trace-attribute-values-contract.ts',
|
||||
'scripts/sync-trace-events-contract.ts',
|
||||
]
|
||||
|
||||
// Generated files under this path. We biome-format this whole dir on
|
||||
// each generate (and the temp copy on each check).
|
||||
const GENERATED_DIR = 'apps/sim/lib/copilot/generated'
|
||||
|
||||
// `tool-schemas-v1.ts` goes through biome's `--unsafe` bracket-quote
|
||||
// fixer which reformats every key of TOOL_RUNTIME_SCHEMAS. Strip it
|
||||
// from the format pass so generator output stays stable on both sides.
|
||||
const FORMAT_EXCLUDE = new Set(['tool-schemas-v1.ts'])
|
||||
|
||||
function run(cmd: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function runGenerators(outputOverride?: string): void {
|
||||
const env = { ...process.env }
|
||||
for (const script of GENERATORS) {
|
||||
const args = ['bun', 'run', script]
|
||||
if (outputOverride) {
|
||||
// Individual scripts don't accept a custom output dir; for
|
||||
// --check we generate in place and snapshot before/after via
|
||||
// git-index comparison (see runCheck).
|
||||
}
|
||||
run(args, ROOT, env)
|
||||
}
|
||||
}
|
||||
|
||||
function formatGenerated(dir: string): void {
|
||||
const files = readdirNoThrow(dir).filter((f) => !FORMAT_EXCLUDE.has(f) && f.endsWith('.ts'))
|
||||
if (files.length === 0) return
|
||||
const paths = files.map((f) => join(dir, f))
|
||||
run(['bunx', 'biome', 'check', '--write', ...paths], ROOT)
|
||||
}
|
||||
|
||||
function readdirNoThrow(dir: string): string[] {
|
||||
try {
|
||||
// Bun has fs.readdirSync available as a CommonJS import
|
||||
const fs = require('node:fs') as typeof import('node:fs')
|
||||
return fs.readdirSync(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function runCheck(): void {
|
||||
const targetDir = resolve(ROOT, GENERATED_DIR)
|
||||
// Snapshot current committed state
|
||||
const committed: Record<string, string> = {}
|
||||
for (const f of readdirNoThrow(targetDir)) {
|
||||
if (!f.endsWith('.ts')) continue
|
||||
committed[f] = readFileSync(join(targetDir, f), 'utf8')
|
||||
}
|
||||
|
||||
// Regenerate in place + format, then diff against the snapshot
|
||||
runGenerators()
|
||||
formatGenerated(targetDir)
|
||||
|
||||
const stale: string[] = []
|
||||
for (const [name, oldContent] of Object.entries(committed)) {
|
||||
if (FORMAT_EXCLUDE.has(name)) continue
|
||||
const newContent = readFileSync(join(targetDir, name), 'utf8')
|
||||
if (newContent !== oldContent) stale.push(name)
|
||||
}
|
||||
|
||||
// Restore the committed state regardless of outcome (--check is readonly).
|
||||
for (const [name, content] of Object.entries(committed)) {
|
||||
const fs = require('node:fs') as typeof import('node:fs')
|
||||
fs.writeFileSync(join(targetDir, name), content, 'utf8')
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
`Generated contracts are stale: ${stale.join(', ')}. Run: bun run mship:generate`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('All generated contracts up to date.')
|
||||
}
|
||||
|
||||
function runGenerate(): void {
|
||||
runGenerators()
|
||||
formatGenerated(resolve(ROOT, GENERATED_DIR))
|
||||
console.log('Generated + formatted mothership contracts.')
|
||||
}
|
||||
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
if (checkOnly) runCheck()
|
||||
else runGenerate()
|
||||
155
scripts/sync-trace-attribute-values-contract.ts
Normal file
155
scripts/sync-trace-attribute-values-contract.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts`
|
||||
* from the Go-side `contracts/trace-attribute-values-v1.schema.json`
|
||||
* contract.
|
||||
*
|
||||
* Unlike span-names / attribute-keys / event-names (each of which is a
|
||||
* single enum), this contract carries MULTIPLE enums — one per span
|
||||
* attribute whose value set is closed. The schema's `$defs` holds one
|
||||
* definition per enum (e.g. `CopilotRequestCancelReason`,
|
||||
* `CopilotAbortOutcome`, …). For each $def we emit a TS `as const`
|
||||
* object named after the Go type, so call sites read as:
|
||||
*
|
||||
* span.setAttribute(
|
||||
* TraceAttr.CopilotRequestCancelReason,
|
||||
* CopilotRequestCancelReason.ExplicitStop,
|
||||
* )
|
||||
*
|
||||
* Skipped $defs: anything that doesn't have a string-only `enum`
|
||||
* array. That filters out wrapper structs the reflector adds
|
||||
* incidentally (e.g. `TraceAttributeValuesV1AllDefs`).
|
||||
*/
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-attribute-values-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts',
|
||||
)
|
||||
|
||||
interface ExtractedEnum {
|
||||
/** The Go type name — becomes the TS const + type name. */
|
||||
name: string
|
||||
/** The value strings, sorted for diff stability. */
|
||||
values: string[]
|
||||
}
|
||||
|
||||
function extractEnums(schema: Record<string, unknown>): ExtractedEnum[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
const out: ExtractedEnum[] = []
|
||||
for (const [name, def] of Object.entries(defs)) {
|
||||
if (!def || typeof def !== 'object') continue
|
||||
const enumValues = (def as Record<string, unknown>).enum
|
||||
if (!Array.isArray(enumValues)) continue
|
||||
if (!enumValues.every((v) => typeof v === 'string')) continue
|
||||
out.push({ name, values: (enumValues as string[]).slice().sort() })
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* PascalCase identifier for a wire enum value. Mirrors the algorithm
|
||||
* used by the span-names + attribute-keys scripts, so
|
||||
* `explicit_stop` -> `ExplicitStop`, matching what a reader would
|
||||
* guess from Go's exported constants.
|
||||
*/
|
||||
function toValueIdent(value: string): string {
|
||||
const parts = value.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for enum value: ${value}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for value "${value}" starts with a digit`,
|
||||
)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
|
||||
function renderEnum(e: ExtractedEnum): string {
|
||||
const seen = new Map<string, string>()
|
||||
const lines = e.values.map((v) => {
|
||||
const ident = toValueIdent(v)
|
||||
const prev = seen.get(ident)
|
||||
if (prev && prev !== v) {
|
||||
throw new Error(
|
||||
`Enum ${e.name}: identifier collision — "${prev}" and "${v}" both map to "${ident}"`,
|
||||
)
|
||||
}
|
||||
seen.set(ident, v)
|
||||
return ` ${ident}: ${JSON.stringify(v)},`
|
||||
})
|
||||
|
||||
return `export const ${e.name} = {
|
||||
${lines.join('\n')}
|
||||
} as const;
|
||||
|
||||
export type ${e.name}Key = keyof typeof ${e.name};
|
||||
export type ${e.name}Value = (typeof ${e.name})[${e.name}Key];`
|
||||
}
|
||||
|
||||
function render(enums: ExtractedEnum[]): string {
|
||||
const body = enums.map(renderEnum).join('\n\n')
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
//
|
||||
// Source: copilot/copilot/contracts/trace-attribute-values-v1.schema.json
|
||||
// Regenerate with: bun run trace-attribute-values-contract:generate
|
||||
//
|
||||
// Canonical closed-set value vocabularies for mothership OTel
|
||||
// attributes. Call sites should reference e.g.
|
||||
// \`CopilotRequestCancelReason.ExplicitStop\` rather than the raw
|
||||
// string literal, so typos become compile errors and the Go contract
|
||||
// remains the single source of truth.
|
||||
|
||||
${body}
|
||||
`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
||||
const inputPath = inputArg
|
||||
? resolve(ROOT, inputArg.slice('--input='.length))
|
||||
: DEFAULT_CONTRACT_PATH
|
||||
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const enums = extractEnums(schema)
|
||||
if (enums.length === 0) {
|
||||
throw new Error(
|
||||
'No enum $defs found in trace-attribute-values-v1.schema.json — did you add the Go type to TraceAttributeValuesV1AllDefs?',
|
||||
)
|
||||
}
|
||||
const rendered = render(enums)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace attribute values contract is stale. Run: bun run trace-attribute-values-contract:generate',
|
||||
)
|
||||
}
|
||||
console.log('Trace attribute values contract is up to date.')
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(dirname(OUTPUT_PATH), { recursive: true })
|
||||
await writeFile(OUTPUT_PATH, rendered, 'utf8')
|
||||
console.log(`Generated trace attribute values types -> ${OUTPUT_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
168
scripts/sync-trace-attributes-contract.ts
Normal file
168
scripts/sync-trace-attributes-contract.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-attributes-v1.ts`
|
||||
* from the Go-side `contracts/trace-attributes-v1.schema.json`
|
||||
* contract.
|
||||
*
|
||||
* The contract is a single-enum JSON Schema listing every CUSTOM
|
||||
* (non-OTel-semconv) span attribute key used in mothership. We emit:
|
||||
* - A `TraceAttr` const object keyed by PascalCase identifier whose
|
||||
* values are the exact wire strings, so call sites look like
|
||||
* `span.setAttribute(TraceAttr.ChatId, …)` instead of the raw
|
||||
* `span.setAttribute('chat.id', …)`.
|
||||
* - A `TraceAttrKey` union and a `TraceAttrValue` union type so
|
||||
* helpers that take an attribute key are well-typed.
|
||||
* - A sorted `TraceAttrValues` readonly array for tests/enumeration.
|
||||
*
|
||||
* This is the attribute-key twin of `sync-trace-spans-contract.ts`
|
||||
* (span names). The two files share the enum-extraction + identifier
|
||||
* PascalCase + collision-detection pattern so a reader who understands
|
||||
* one understands both.
|
||||
*
|
||||
* For OTel semantic-convention keys (e.g. `http.request.method`,
|
||||
* `db.system`, `gen_ai.system`, `messaging.*`, `net.*`,
|
||||
* `service.name`, `deployment.environment`), import from
|
||||
* `@opentelemetry/semantic-conventions` directly — they live in the
|
||||
* upstream package, not in this contract.
|
||||
*/
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-attributes-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-attributes-v1.ts',
|
||||
)
|
||||
|
||||
function extractAttrKeys(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
const nameDef = defs.TraceAttributesV1Name
|
||||
if (
|
||||
!nameDef ||
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-attributes-v1.schema.json is missing $defs.TraceAttributesV1Name.enum',
|
||||
)
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
throw new Error('TraceAttributesV1Name enum must be string-only')
|
||||
}
|
||||
return (enumValues as string[]).slice().sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a wire attribute key like `copilot.vfs.input.media_type_claimed`
|
||||
* into an identifier-safe PascalCase key like
|
||||
* `CopilotVfsInputMediaTypeClaimed`.
|
||||
*
|
||||
* Same algorithm as the span-name sync script so readers can learn one
|
||||
* and reuse it.
|
||||
*/
|
||||
function toIdentifier(name: string): string {
|
||||
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for attribute key: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for attribute "${name}" starts with a digit`,
|
||||
)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
|
||||
function render(attrKeys: string[]): string {
|
||||
const pairs = attrKeys.map((name) => ({ name, ident: toIdentifier(name) }))
|
||||
|
||||
// Identifier collisions silently override earlier keys and break
|
||||
// type safety — fail loudly instead.
|
||||
const seen = new Map<string, string>()
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const arrayEntries = attrKeys.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
//
|
||||
// Source: copilot/copilot/contracts/trace-attributes-v1.schema.json
|
||||
// Regenerate with: bun run trace-attributes-contract:generate
|
||||
//
|
||||
// Canonical custom mothership OTel span attribute keys. Call sites
|
||||
// should reference \`TraceAttr.<Identifier>\` (e.g.
|
||||
// \`TraceAttr.ChatId\`, \`TraceAttr.ToolCallId\`) rather than raw
|
||||
// string literals, so the Go-side contract is the single source of
|
||||
// truth and typos become compile errors.
|
||||
//
|
||||
// For OTel semantic-convention keys (\`http.*\`, \`db.*\`,
|
||||
// \`gen_ai.*\`, \`net.*\`, \`messaging.*\`, \`service.*\`,
|
||||
// \`deployment.environment\`), import from
|
||||
// \`@opentelemetry/semantic-conventions\` directly — those are owned
|
||||
// by the upstream OTel spec, not by this contract.
|
||||
|
||||
export const TraceAttr = {
|
||||
${constLines}
|
||||
} as const;
|
||||
|
||||
export type TraceAttrKey = keyof typeof TraceAttr;
|
||||
export type TraceAttrValue = (typeof TraceAttr)[TraceAttrKey];
|
||||
|
||||
/** Readonly sorted list of every canonical custom attribute key. */
|
||||
export const TraceAttrValues: readonly TraceAttrValue[] = [
|
||||
${arrayEntries}
|
||||
] as const;
|
||||
`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
||||
const inputPath = inputArg
|
||||
? resolve(ROOT, inputArg.slice('--input='.length))
|
||||
: DEFAULT_CONTRACT_PATH
|
||||
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const attrKeys = extractAttrKeys(schema)
|
||||
const rendered = render(attrKeys)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace attributes contract is stale. Run: bun run trace-attributes-contract:generate',
|
||||
)
|
||||
}
|
||||
console.log('Trace attributes contract is up to date.')
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(dirname(OUTPUT_PATH), { recursive: true })
|
||||
await writeFile(OUTPUT_PATH, rendered, 'utf8')
|
||||
console.log(`Generated trace attributes types -> ${OUTPUT_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
137
scripts/sync-trace-events-contract.ts
Normal file
137
scripts/sync-trace-events-contract.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-events-v1.ts` from
|
||||
* the Go-side `contracts/trace-events-v1.schema.json` contract.
|
||||
*
|
||||
* Mirrors the span-names + attribute-keys sync scripts exactly — the
|
||||
* only difference is the $defs key (`TraceEventsV1Name`), the output
|
||||
* path, and the generated const name (`TraceEvent`). Keeping the
|
||||
* scripts structurally identical means a reader who understands one
|
||||
* understands all three, and drift between them gets caught
|
||||
* immediately in code review.
|
||||
*/
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-events-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-events-v1.ts',
|
||||
)
|
||||
|
||||
function extractEventNames(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
const nameDef = defs.TraceEventsV1Name
|
||||
if (
|
||||
!nameDef ||
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-events-v1.schema.json is missing $defs.TraceEventsV1Name.enum',
|
||||
)
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
throw new Error('TraceEventsV1Name enum must be string-only')
|
||||
}
|
||||
return (enumValues as string[]).slice().sort()
|
||||
}
|
||||
|
||||
function toIdentifier(name: string): string {
|
||||
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for event name: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for event "${name}" starts with a digit`,
|
||||
)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
|
||||
function render(eventNames: string[]): string {
|
||||
const pairs = eventNames.map((name) => ({ name, ident: toIdentifier(name) }))
|
||||
|
||||
const seen = new Map<string, string>()
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const arrayEntries = eventNames.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
//
|
||||
// Source: copilot/copilot/contracts/trace-events-v1.schema.json
|
||||
// Regenerate with: bun run trace-events-contract:generate
|
||||
//
|
||||
// Canonical mothership OTel span event names. Call sites should
|
||||
// reference \`TraceEvent.<Identifier>\` (e.g.
|
||||
// \`TraceEvent.RequestCancelled\`) rather than raw string literals,
|
||||
// so the Go-side contract is the single source of truth and typos
|
||||
// become compile errors.
|
||||
|
||||
export const TraceEvent = {
|
||||
${constLines}
|
||||
} as const;
|
||||
|
||||
export type TraceEventKey = keyof typeof TraceEvent;
|
||||
export type TraceEventValue = (typeof TraceEvent)[TraceEventKey];
|
||||
|
||||
/** Readonly sorted list of every canonical event name. */
|
||||
export const TraceEventValues: readonly TraceEventValue[] = [
|
||||
${arrayEntries}
|
||||
] as const;
|
||||
`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
||||
const inputPath = inputArg
|
||||
? resolve(ROOT, inputArg.slice('--input='.length))
|
||||
: DEFAULT_CONTRACT_PATH
|
||||
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const eventNames = extractEventNames(schema)
|
||||
const rendered = render(eventNames)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace events contract is stale. Run: bun run trace-events-contract:generate',
|
||||
)
|
||||
}
|
||||
console.log('Trace events contract is up to date.')
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(dirname(OUTPUT_PATH), { recursive: true })
|
||||
await writeFile(OUTPUT_PATH, rendered, 'utf8')
|
||||
console.log(`Generated trace events types -> ${OUTPUT_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
155
scripts/sync-trace-spans-contract.ts
Normal file
155
scripts/sync-trace-spans-contract.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-spans-v1.ts` from the
|
||||
* Go-side `contracts/trace-spans-v1.schema.json` contract.
|
||||
*
|
||||
* The contract is a single-enum JSON Schema. We emit:
|
||||
* - A `TraceSpansV1Name` const object (key-as-value) for ergonomic
|
||||
* access: `TraceSpansV1Name['copilot.vfs.read_file']`.
|
||||
* - A `TraceSpansV1NameValue` union type.
|
||||
* - A sorted `TraceSpansV1Names` readonly array (useful for tests that
|
||||
* verify coverage, and for tooling that wants to enumerate names).
|
||||
*
|
||||
* We deliberately do NOT pass through `json-schema-to-typescript` —
|
||||
* it would generate a noisy `TraceSpansV1` object type for the wrapper
|
||||
* that drives reflection; the wrapper type has no runtime use on the Sim
|
||||
* side and would obscure the actual enum.
|
||||
*/
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-spans-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-spans-v1.ts',
|
||||
)
|
||||
|
||||
function extractSpanNames(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
const nameDef = defs.TraceSpansV1Name
|
||||
if (
|
||||
!nameDef ||
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-spans-v1.schema.json is missing $defs.TraceSpansV1Name.enum',
|
||||
)
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
throw new Error('TraceSpansV1Name enum must be string-only')
|
||||
}
|
||||
return (enumValues as string[]).slice().sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a wire name like "copilot.recovery.check_replay_gap" into an
|
||||
* identifier-safe PascalCase key like "CopilotRecoveryCheckReplayGap",
|
||||
* so call sites read as `TraceSpan.CopilotRecoveryCheckReplayGap`
|
||||
* instead of `TraceSpan["copilot.recovery.check_replay_gap"]`.
|
||||
*
|
||||
* Splits on `.`, `_`, and non-alphanumeric characters; capitalizes each
|
||||
* part; collapses. Strict mapping (not a best-effort heuristic), so the
|
||||
* same input always produces the same identifier.
|
||||
*/
|
||||
function toIdentifier(name: string): string {
|
||||
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for span name: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
// Safety: identifiers may not start with a digit.
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for span "${name}" starts with a digit`,
|
||||
)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
|
||||
function render(spanNames: string[]): string {
|
||||
const pairs = spanNames.map((name) => ({ name, ident: toIdentifier(name) }))
|
||||
|
||||
// Guard against collisions: if two wire names ever collapse to the
|
||||
// same PascalCase identifier, we want a clear build failure, not a
|
||||
// silent override.
|
||||
const seen = new Map<string, string>()
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const arrayEntries = spanNames.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
//
|
||||
// Source: copilot/copilot/contracts/trace-spans-v1.schema.json
|
||||
// Regenerate with: bun run trace-spans-contract:generate
|
||||
//
|
||||
// Canonical mothership OTel span names. Call sites should reference
|
||||
// \`TraceSpan.<Identifier>\` (e.g. \`TraceSpan.CopilotVfsReadFile\`)
|
||||
// rather than raw string literals, so the Go-side contract is the
|
||||
// single source of truth and typos become compile errors.
|
||||
|
||||
export const TraceSpan = {
|
||||
${constLines}
|
||||
} as const;
|
||||
|
||||
export type TraceSpanKey = keyof typeof TraceSpan;
|
||||
export type TraceSpanValue = (typeof TraceSpan)[TraceSpanKey];
|
||||
|
||||
/** Readonly sorted list of every canonical span name. */
|
||||
export const TraceSpanValues: readonly TraceSpanValue[] = [
|
||||
${arrayEntries}
|
||||
] as const;
|
||||
`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
const inputArg = process.argv.find((a) => a.startsWith('--input='))
|
||||
const inputPath = inputArg
|
||||
? resolve(ROOT, inputArg.slice('--input='.length))
|
||||
: DEFAULT_CONTRACT_PATH
|
||||
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const spanNames = extractSpanNames(schema)
|
||||
const rendered = render(spanNames)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace spans contract is stale. Run: bun run trace-spans-contract:generate',
|
||||
)
|
||||
}
|
||||
console.log('Trace spans contract is up to date.')
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(dirname(OUTPUT_PATH), { recursive: true })
|
||||
await writeFile(OUTPUT_PATH, rendered, 'utf8')
|
||||
console.log(`Generated trace spans types -> ${OUTPUT_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user