diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index cdbeb6df0c..59cb88bdb5 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -216,8 +216,12 @@ interface JobOutputRead { interface JobView { /** The registry-issued id (`-N`). */ readonly id: JobId - /** The producer kind the job was registered with. */ - readonly kind: JobKind + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ readonly label: string /** Owning session; absent for an unowned job, which every caller can see. */ diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index a502fff316..fd2f897979 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -216,8 +216,12 @@ interface JobOutputRead { interface JobView { /** The registry-issued id (`-N`). */ readonly id: JobId - /** The producer kind the job was registered with. */ - readonly kind: JobKind + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ readonly label: string /** Owning session; absent for an unowned job, which every caller can see. */ diff --git a/packages/api/job-controller/tests/built-lib.e2e.ts b/packages/api/job-controller/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..1dc564bc3b --- /dev/null +++ b/packages/api/job-controller/tests/built-lib.e2e.ts @@ -0,0 +1,42 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the generated job Remote codecs. This package's + * program compiles only the `JobKindMap` merges it can see (`bash`, + * `subagent`), while the Host registers `pwsh`, `pty-send`, and every other + * producer kind, so the wire projection's `kind` must reach the codec as an + * open string rather than the literal union this program observes. + */ + +const artifact = join(fileURLToPath(new URL('..', import.meta.url)), 'lib/typert.host.js') + +interface BuiltInvocation { + readonly method: string + readonly result: { readonly schema: { safeParse(value: unknown): { success: boolean } } } +} + +const row = { + id: 'pty-send-1', + kind: 'pty-send', + label: 'send', + status: 'running', + startedAt: 1, + output: { total: 0, earliest: 0 }, +} + +describe.skipIf(!existsSync(artifact))('job Remote built codecs', () => { + it('accept a row whose kind this program never compiled, and still reject a status outside the lifecycle', async () => { + const { TYPERT } = await import(pathToFileURL(artifact).href) as { TYPERT: { invocations: readonly BuiltInvocation[] } } + const schemaOf = (method: string): BuiltInvocation['result']['schema'] => { + const invocation = TYPERT.invocations.find(candidate => candidate.method === method) + if (invocation === undefined) throw new Error(`no generated job.${method} invocation`) + return invocation.result.schema + } + expect(schemaOf('rows').safeParse({ type: 'rows', jobs: [row] }).success).toBe(true) + expect(schemaOf('observe').safeParse({ type: 'opened', job: row, from: 0 }).success).toBe(true) + expect(schemaOf('observe').safeParse({ type: 'status', job: { ...row, status: 'paused' } }).success).toBe(false) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4317556058..a93a19f888 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4248,7 +4248,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobView', - declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: JobKind;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n };\n}', + declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n };\n}', }, { name: 'JsonSchemaNode', diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts index d3c73ae018..b20e1c693b 100644 --- a/packages/jobs/jobs/src/view.ts +++ b/packages/jobs/jobs/src/view.ts @@ -56,8 +56,12 @@ export interface JobChunk { export interface JobView { /** The registry-issued id (`-N`). */ readonly id: JobId - /** The producer kind the job was registered with. */ - readonly kind: JobKind + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ readonly label: string /** Owning session; absent for an unowned job, which every caller can see. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 5bcde91862..8b62cf2356 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -114,6 +114,15 @@ describe('JobRegistry seam', () => { expect([viewHasReported, specHasRecord, hooksHaveReadOutput]).toEqual([false, false, false]) }) + it('keeps the projection kind an open string, so a program compiling fewer JobKindMap merges accepts every Host kind', () => { + // A browser bundle or a generated Remote codec sees only the merges its + // own program compiles; a literal union here would reject `pwsh`, + // `pty-send`, and every kind another package registers. + type KindIsOpenString = string extends JobView['kind'] ? true : false + const viewKindOpen: KindIsOpenString = true + expect(viewKindOpen).toBe(true) + }) + it('loading a second implementation throws (one jobs service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubJobRegistry)