fix(jobs): carry the projection kind as an open string on the wire

JobView.kind was typed as the merge-extensible JobKind, so the job-controller program, which compiles only the merges it can see, generated a strict codec of bash | subagent for job.rows and job.observe. The first pwsh, pty-send, or workflow job failed frame validation on both ends and the browser dropped the whole roster. The projection now carries kind as a string; JobSpec.kind keeps the Host-side JobKind. A built-artifact smoke parses a pty-send row through the generated schemas, and the seam spec pins the open type.
This commit is contained in:
Yichen Jiang committed 2026-09-04 12:10:22 +08:00
1 parent b76bfbcc1f
commit 2c0ca45f76
6 files changed
+70 -7

No files matched your search

+6 -2
View File
@@ -216,8 +216,12 @@ interface JobOutputRead {
interface JobView {
/** The registry-issued id (`<kind>-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. */
+6 -2
View File
@@ -216,8 +216,12 @@ interface JobOutputRead {
interface JobView {
/** The registry-issued id (`<kind>-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. */
@@ -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)
})
})
@@ -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',
+6 -2
View File
@@ -56,8 +56,12 @@ export interface JobChunk {
export interface JobView {
/** The registry-issued id (`<kind>-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. */
+9
View File
@@ -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)