Skip to content

Commit 6db8733

Browse files
committed
refactor(node): share intent runtime helpers
1 parent a51b7a8 commit 6db8733

4 files changed

Lines changed: 145 additions & 172 deletions

File tree

packages/node/src/cli/commands/assemblies.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,8 +1458,14 @@ export async function create(
14581458
})
14591459
}
14601460

1461-
if (singleAssembly) {
1462-
// Single-assembly mode: collect file paths, then create one assembly with all inputs
1461+
function handleEmitterError(err: Error): void {
1462+
abortController.abort()
1463+
queue.clear()
1464+
outputctl.error(err)
1465+
reject(err)
1466+
}
1467+
1468+
function runSingleAssemblyEmitter(): void {
14631469
const collectedPaths: string[] = []
14641470

14651471
emitter.on('job', (job: Job) => {
@@ -1470,13 +1476,6 @@ export async function create(
14701476
}
14711477
})
14721478

1473-
emitter.on('error', (err: Error) => {
1474-
abortController.abort()
1475-
queue.clear()
1476-
outputctl.error(err)
1477-
reject(err)
1478-
})
1479-
14801479
emitter.on('end', async () => {
14811480
if (collectedPaths.length === 0) {
14821481
resolve({ results: [], hasFailures: false })
@@ -1533,13 +1532,13 @@ export async function create(
15331532

15341533
resolve({ results, hasFailures })
15351534
})
1536-
} else {
1537-
// Default mode: one assembly per file with p-queue concurrency limiting
1535+
}
1536+
1537+
function runPerFileEmitter(): void {
15381538
emitter.on('job', (job: Job) => {
15391539
const inPath = job.inputPath
15401540
const outputPlan = job.out
15411541
outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1542-
// Add job to queue - p-queue handles concurrency automatically
15431542
queue
15441543
.add(async () => {
15451544
const result = await processAssemblyJob(inPath, outputPlan)
@@ -1553,19 +1552,19 @@ export async function create(
15531552
})
15541553
})
15551554

1556-
emitter.on('error', (err: Error) => {
1557-
abortController.abort()
1558-
queue.clear()
1559-
outputctl.error(err)
1560-
reject(err)
1561-
})
1562-
15631555
emitter.on('end', async () => {
1564-
// Wait for all queued jobs to complete
15651556
await queue.onIdle()
15661557
resolve({ results, hasFailures })
15671558
})
15681559
}
1560+
1561+
emitter.on('error', handleEmitterError)
1562+
1563+
if (singleAssembly) {
1564+
runSingleAssemblyEmitter()
1565+
} else {
1566+
runPerFileEmitter()
1567+
}
15691568
})
15701569
}
15711570

packages/node/src/cli/commands/image-describe.ts

Lines changed: 70 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,11 @@
1-
import { statSync } from 'node:fs'
21
import { Command, Option } from 'clipanion'
2+
import { z } from 'zod'
33

44
import type { InterpolatableRobotAiChatInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/ai-chat.ts'
55
import type { InterpolatableRobotImageDescribeInstructionsWithHiddenFieldsInput } from '../../alphalib/types/robots/image-describe.ts'
6-
import {
7-
concurrencyOption,
8-
countProvidedInputs,
9-
deleteAfterProcessingOption,
10-
inputPathsOption,
11-
recursiveOption,
12-
reprocessStaleOption,
13-
validateSharedFileProcessingOptions,
14-
watchOption,
15-
} from '../fileProcessingOptions.ts'
16-
import { prepareIntentInputs } from '../intentRuntime.ts'
17-
import type { AssembliesCreateOptions } from './assemblies.ts'
6+
import type { IntentFileCommandDefinition, PreparedIntentInputs } from '../intentRuntime.ts'
7+
import { GeneratedWatchableFileIntentCommand } from '../intentRuntime.ts'
188
import * as assembliesCommands from './assemblies.ts'
19-
import { AuthenticatedCommand } from './BaseCommand.ts'
209

2110
const imageDescribeFields = ['labels', 'altText', 'title', 'caption', 'description'] as const
2211

@@ -31,15 +20,6 @@ const wordpressDescribeFields = [
3120

3221
const defaultDescribeModel = 'anthropic/claude-sonnet-4-5'
3322

34-
function isHttpUrl(value: string): boolean {
35-
try {
36-
const url = new URL(value)
37-
return url.protocol === 'http:' || url.protocol === 'https:'
38-
} catch {
39-
return false
40-
}
41-
}
42-
4323
function parseFields(value: string[] | undefined): ImageDescribeField[] {
4424
const rawFields = (value ?? [])
4525
.flatMap((part) => part.split(','))
@@ -259,7 +239,27 @@ function buildDescribeStep({
259239
return buildAiChatStep({ fields, model, profile })
260240
}
261241

262-
export class ImageDescribeCommand extends AuthenticatedCommand {
242+
const imageDescribeBaseDefinition = {
243+
commandLabel: 'image describe',
244+
execution: {
245+
kind: 'single-step',
246+
fields: [],
247+
fixedValues: {},
248+
resultStepName: 'describe',
249+
schema: z.object({}),
250+
},
251+
inputPolicy: {
252+
kind: 'required',
253+
},
254+
outputDescription: 'Write the JSON result to this path or directory',
255+
} satisfies IntentFileCommandDefinition
256+
257+
type ResolvedDescribeRequest = {
258+
profile: 'wordpress' | null
259+
requestedFields: ImageDescribeField[]
260+
}
261+
262+
export class ImageDescribeCommand extends GeneratedWatchableFileIntentCommand {
263263
static override paths = [['image', 'describe']]
264264

265265
static override usage = Command.Usage({
@@ -283,17 +283,6 @@ export class ImageDescribeCommand extends AuthenticatedCommand {
283283
],
284284
})
285285

286-
outputPath = Option.String('--out,-o', {
287-
description: 'Write the JSON result to this path or directory',
288-
required: true,
289-
})
290-
291-
inputs = inputPathsOption('Provide an input path, directory, URL, or - for stdin')
292-
293-
inputBase64 = Option.Array('--input-base64', {
294-
description: 'Provide base64-encoded input content directly',
295-
})
296-
297286
fields = Option.Array('--fields', {
298287
description:
299288
'Describe output fields to generate, for example labels or altText,title,caption,description',
@@ -307,102 +296,65 @@ export class ImageDescribeCommand extends AuthenticatedCommand {
307296
description: `Model to use for generated text fields (default: ${defaultDescribeModel})`,
308297
})
309298

310-
recursive = recursiveOption()
311-
312-
deleteAfterProcessing = deleteAfterProcessingOption()
313-
314-
reprocessStale = reprocessStaleOption()
315-
316-
watch = watchOption()
317-
318-
concurrency = concurrencyOption()
319-
320-
private getProvidedInputCount(): number {
321-
return countProvidedInputs({
322-
inputs: this.inputs,
323-
inputBase64: this.inputBase64,
324-
})
325-
}
326-
327-
private hasTransientInputSources(): boolean {
328-
return (
329-
(this.inputs?.some((input) => isHttpUrl(input)) ?? false) ||
330-
(this.inputBase64?.length ?? 0) > 0
331-
)
299+
protected override getIntentDefinition(): IntentFileCommandDefinition {
300+
return imageDescribeBaseDefinition
332301
}
333302

334-
private isDirectoryOutputTarget(): boolean {
335-
try {
336-
return statSync(this.outputPath).isDirectory()
337-
} catch {
338-
return false
303+
protected override getIntentRawValues(): Record<string, unknown> {
304+
return {
305+
fields: this.fields,
306+
forProfile: this.forProfile,
307+
model: this.model,
339308
}
340309
}
341310

342-
protected override async run(): Promise<number | undefined> {
343-
if (this.getProvidedInputCount() === 0) {
344-
this.output.error('image describe requires --input or --input-base64')
345-
return 1
346-
}
347-
348-
const sharedValidationError = validateSharedFileProcessingOptions({
349-
explicitInputCount: this.getProvidedInputCount(),
350-
singleAssembly: false,
351-
watch: this.watch,
352-
watchRequiresInputsMessage: 'image describe --watch requires --input or --input-base64',
353-
})
354-
if (sharedValidationError != null) {
355-
this.output.error(sharedValidationError)
356-
return 1
357-
}
358-
359-
if (this.watch && this.hasTransientInputSources()) {
360-
this.output.error('--watch is only supported for filesystem inputs')
361-
return 1
362-
}
363-
364-
const explicitFields = parseFields(this.fields)
365-
const profile = resolveProfile(this.forProfile)
311+
private resolveDescribeRequest(rawValues: Record<string, unknown>): ResolvedDescribeRequest {
312+
const explicitFields = parseFields(rawValues.fields as string[] | undefined)
313+
const profile = resolveProfile(rawValues.forProfile as string | undefined)
366314
const requestedFields = resolveRequestedFields({ explicitFields, profile })
367315
validateRequestedFields({
368316
explicitFields,
369317
fields: requestedFields,
370-
model: this.model,
318+
model: rawValues.model as string,
371319
profile,
372320
})
373321

374-
const preparedInputs = await prepareIntentInputs({
375-
inputValues: this.inputs ?? [],
376-
inputBase64Values: this.inputBase64 ?? [],
377-
})
322+
return {
323+
profile,
324+
requestedFields,
325+
}
326+
}
378327

379-
try {
380-
if (this.watch && preparedInputs.hasTransientInputs) {
381-
this.output.error('--watch is only supported for filesystem inputs')
382-
return 1
383-
}
384-
385-
const { hasFailures } = await assembliesCommands.create(this.output, this.client, {
386-
del: this.deleteAfterProcessing,
387-
inputs: preparedInputs.inputs,
388-
recursive: this.recursive,
389-
reprocessStale: this.reprocessStale,
390-
watch: this.watch,
391-
concurrency: this.concurrency,
392-
output: this.outputPath,
393-
outputMode: this.isDirectoryOutputTarget() ? 'directory' : 'file',
394-
stepsData: {
395-
describe: buildDescribeStep({
396-
fields: requestedFields,
397-
model: this.model,
398-
profile,
399-
}),
400-
} satisfies AssembliesCreateOptions['stepsData'],
401-
})
402-
403-
return hasFailures ? 1 : undefined
404-
} finally {
405-
await Promise.all(preparedInputs.cleanup.map((cleanup) => cleanup()))
328+
protected override validateBeforePreparingInputs(
329+
rawValues: Record<string, unknown>,
330+
): number | undefined {
331+
const validationError = super.validateBeforePreparingInputs(rawValues)
332+
if (validationError != null) {
333+
return validationError
406334
}
335+
336+
this.resolveDescribeRequest(rawValues)
337+
return undefined
338+
}
339+
340+
protected override async executePreparedInputs(
341+
rawValues: Record<string, unknown>,
342+
preparedInputs: PreparedIntentInputs,
343+
): Promise<number | undefined> {
344+
const { profile, requestedFields } = this.resolveDescribeRequest(rawValues)
345+
const { hasFailures } = await assembliesCommands.create(this.output, this.client, {
346+
...this.getCreateOptions(preparedInputs.inputs),
347+
output: this.outputPath,
348+
outputMode: this.resolveOutputMode(),
349+
stepsData: {
350+
describe: buildDescribeStep({
351+
fields: requestedFields,
352+
model: rawValues.model as string,
353+
profile,
354+
}),
355+
},
356+
})
357+
358+
return hasFailures ? 1 : undefined
407359
}
408360
}

0 commit comments

Comments
 (0)