meta.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. /**
  2. * Meta-block extraction: turn a Claude Code-format workflow script —
  3. * `export const meta = {...}` followed by a plain-JS body — into a validated
  4. * {@link WorkflowMeta} plus the body with the meta statement blanked
  5. * line-preservingly (error stacks keep the script's own line numbers).
  6. *
  7. * The scanner is a small string/comment-aware brace matcher, not a JS parser:
  8. * it only has to find the END of the meta object literal, and the literal is
  9. * contractually PURE (no interpolation, no computed values). Template strings
  10. * are tolerated as plain quotes but `${` inside one is rejected up front —
  11. * interpolation is where "literal" stops being checkable by evaluation. The
  12. * extracted text is then evaluated ALONE in an empty, timed vm context (a
  13. * non-literal reference throws there; an expression can still RUN, so the
  14. * result — not the source — is the contract: it must materialize to plain
  15. * JSON data and pass the shape validation).
  16. *
  17. * @module @deepseek-ai/dsh-workflow-workerthread/meta
  18. */
  19. import * as vm from 'node:vm'
  20. import { WorkflowError } from '@deepseek-ai/dsh-workflow'
  21. import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
  22. import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
  23. /** The result of {@link extractMeta}: the validated meta and the runnable body. */
  24. export interface ExtractedScript {
  25. meta: WorkflowMeta
  26. /** The script with the meta statement blanked (newlines preserved). */
  27. body: string
  28. }
  29. /**
  30. * Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
  31. * string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
  32. * index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
  33. * interpolation (`${` inside a backtick string) or an unterminated literal.
  34. */
  35. function scanObjectLiteral(source: string, start: number): number {
  36. let depth = 0
  37. let index = start
  38. while (index < source.length) {
  39. const ch = source.charAt(index)
  40. if (ch === '/' && source[index + 1] === '/') {
  41. const end = source.indexOf('\n', index)
  42. index = end === -1 ? source.length : end + 1
  43. continue
  44. }
  45. if (ch === '/' && source[index + 1] === '*') {
  46. const end = source.indexOf('*/', index + 2)
  47. if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
  48. index = end + 2
  49. continue
  50. }
  51. if (ch === '\'' || ch === '"' || ch === '`') {
  52. index = scanString(source, index, ch)
  53. continue
  54. }
  55. if (ch === '{' || ch === '[') depth += 1
  56. if (ch === '}' || ch === ']') {
  57. depth -= 1
  58. if (depth === 0) return index + 1
  59. }
  60. index += 1
  61. }
  62. throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
  63. }
  64. /** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
  65. function scanString(source: string, start: number, quote: string): number {
  66. let index = start + 1
  67. while (index < source.length) {
  68. const ch = source.charAt(index)
  69. if (ch === '\\') {
  70. index += 2
  71. continue
  72. }
  73. if (quote === '`' && ch === '$' && source[index + 1] === '{') {
  74. throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
  75. }
  76. if (ch === quote) return index + 1
  77. index += 1
  78. }
  79. throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
  80. }
  81. /** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
  82. function blankSpan(source: string, from: number, to: number): string {
  83. const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
  84. return source.slice(0, from) + blanked + source.slice(to)
  85. }
  86. /** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
  87. function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
  88. const violations: string[] = []
  89. /* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */
  90. if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
  91. return { violations: ['meta must be an object literal'] }
  92. }
  93. const record = meta as Record<string, unknown>
  94. const known = new Set(['name', 'description', 'whenToUse', 'phases'])
  95. for (const key of Object.keys(record)) {
  96. if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
  97. }
  98. if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
  99. if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
  100. if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
  101. const phases: WorkflowPhase[] = []
  102. if (record.phases !== undefined) {
  103. if (!Array.isArray(record.phases)) {
  104. violations.push('meta.phases must be an array')
  105. } else {
  106. record.phases.forEach((phase, index) => {
  107. if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
  108. violations.push(`meta.phases[${index}] must be an object`)
  109. return
  110. }
  111. const entry = phase as Record<string, unknown>
  112. for (const key of Object.keys(entry)) {
  113. if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
  114. }
  115. if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
  116. if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
  117. if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
  118. if (violations.length === 0) {
  119. phases.push({
  120. title: entry.title as string,
  121. ...entry.detail !== undefined ? { detail: entry.detail as string } : {},
  122. ...entry.model !== undefined ? { model: entry.model as string } : {},
  123. })
  124. }
  125. })
  126. }
  127. }
  128. if (violations.length > 0) return { violations }
  129. return {
  130. violations,
  131. meta: {
  132. name: record.name as string,
  133. description: record.description as string,
  134. ...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
  135. ...record.phases !== undefined ? { phases } : {},
  136. },
  137. }
  138. }
  139. /** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */
  140. const META_HEAD = /^export\s+const\s+meta\s*=\s*/
  141. /**
  142. * Index just past the leading trivia: whitespace and `//` / `/*`-style
  143. * comments. A hand-rolled character scan, NOT a prefix regex — an
  144. * all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run
  145. * ambiguously and backtracks EXPONENTIALLY when the match ultimately fails,
  146. * so a near-miss script (a comment header, then a forgotten `export`) would
  147. * spin the host synchronously inside `start()`, where no vm timeout applies.
  148. * The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is
  149. * the model's retry signal.
  150. */
  151. function skipLeadingTrivia(source: string): number {
  152. let index = 0
  153. while (index < source.length) {
  154. const ch = source.charAt(index)
  155. if (/\s/.test(ch)) {
  156. index += 1
  157. continue
  158. }
  159. if (ch === '/' && source[index + 1] === '/') {
  160. const end = source.indexOf('\n', index)
  161. if (end === -1) return source.length
  162. index = end + 1
  163. continue
  164. }
  165. if (ch === '/' && source[index + 1] === '*') {
  166. const end = source.indexOf('*/', index + 2)
  167. if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE')
  168. index = end + 2
  169. continue
  170. }
  171. break
  172. }
  173. return index
  174. }
  175. /**
  176. * Extract and validate the leading `export const meta = {...}` statement.
  177. * Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
  178. * or unscannable, `META_INVALID` when the literal evaluates to something
  179. * outside the meta contract (non-JSON data, wrong shape, unknown fields).
  180. * @param script - the full script text.
  181. * @param evalTimeoutMs - the vm timeout for evaluating the extracted literal.
  182. * @returns the validated meta and the line-preservingly blanked body.
  183. */
  184. export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
  185. const triviaEnd = skipLeadingTrivia(script)
  186. const match = META_HEAD.exec(script.slice(triviaEnd))
  187. if (!match) {
  188. throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
  189. }
  190. const literalStart = triviaEnd + match[0].length
  191. if (script[literalStart] !== '{') {
  192. throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
  193. }
  194. const literalEnd = scanObjectLiteral(script, literalStart)
  195. const literal = script.slice(literalStart, literalEnd)
  196. let evaluated: unknown
  197. try {
  198. // An EMPTY context: any non-literal reference (a variable, a call) throws
  199. // here. The result — data only — is what the contract checks; a getter or
  200. // IIFE can still run, which is why the timeout and the materialization
  201. // below are part of the same boundary.
  202. evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
  203. } catch (error: unknown) {
  204. throw new WorkflowError(
  205. `meta block failed to evaluate as a pure literal: ${renderThrown(error)}`,
  206. 'META_INVALID',
  207. { cause: error },
  208. )
  209. }
  210. let data: unknown
  211. try {
  212. data = materializeFromRealm(evaluated, 'meta')
  213. } catch (error: unknown) {
  214. /* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
  215. if (!(error instanceof MaterializeError)) throw error
  216. throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
  217. }
  218. const { meta, violations } = validateMetaShape(data)
  219. if (meta === undefined) {
  220. throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID')
  221. }
  222. // Blank the whole statement (including a trailing semicolon, if any) so the
  223. // body compiles standalone with its original line numbers.
  224. let statementEnd = literalEnd
  225. while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1
  226. if (script[statementEnd] === ';') statementEnd += 1
  227. return { meta, body: blankSpan(script, 0, statementEnd) }
  228. }