feat: add subgraph promoted-widget ratchet PR-A scaffolding

Additive prep slice for ADR 0009 (subgraph promoted widgets use linked
inputs). No production code path uses these modules yet.

- previewExposureSchema: parse properties.previewExposures with the
  same warn-don't-throw + JSON-string fallback pattern as
  parseProxyWidgets
- proxyWidgetQuarantineSchema: parse properties.proxyWidgetErrorQuarantine;
  reasons enum per ADR; hostValue typed as TWidgetValue at the API
  boundary (z.unknown internally), attemptedAtVersion pinned to 1
- previewExposureStore: host-scoped Pinia store keyed by
  (rootGraphId, hostNodeLocator); add/set/remove/move/clearGraph;
  resolveChain stubbed to single link (full nested-host walk in PR-B)
- Export serializedProxyWidgetTupleSchema + SerializedProxyWidgetTuple
  from promotionSchema so quarantine entries can re-use the existing
  tuple shape

Amp-Thread-ID: https://ampcode.com/threads/T-019dfad3-5f1f-7249-8fd0-e59d36099186
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-06 09:37:39 -07:00
parent ca54877f9d
commit 62ba80b2df
7 changed files with 681 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { parsePreviewExposures } from './previewExposureSchema'
describe(parsePreviewExposures, () => {
it('parses a valid array of preview exposure objects', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'preview2',
sourceNodeId: '7',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(input)).toEqual(input)
})
it('parses JSON-string input', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
})
it('returns empty array for undefined', () => {
expect(parsePreviewExposures(undefined)).toEqual([])
})
it('returns empty array for malformed JSON string', () => {
expect(parsePreviewExposures('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(
parsePreviewExposures({
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
})
).toEqual([])
expect(parsePreviewExposures(42)).toEqual([])
})
it('returns empty array when entries are missing required fields', () => {
expect(
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
).toEqual([])
expect(
parsePreviewExposures([
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
])
).toEqual([])
})
it('returns empty array when entries have wrong types', () => {
expect(
parsePreviewExposures([
{
name: 123,
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
expect(
parsePreviewExposures([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
})
})

View File

@@ -0,0 +1,33 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
export const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
export const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = previewExposuresPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(
`Invalid assignment for properties.previewExposures:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.previewExposures:', e)
}
return []
}

View File

@@ -3,11 +3,14 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetTupleSchema = z.union([
export const serializedProxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
const baseEntry = {
originalEntry: ['10', 'seed'] as [string, string],
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
attemptedAtVersion: 1 as const
}
describe(parseProxyWidgetErrorQuarantine, () => {
it('parses a valid entry without hostValue', () => {
expect(parseProxyWidgetErrorQuarantine([baseEntry])).toEqual([baseEntry])
})
it('parses a valid entry with hostValue', () => {
const entry = { ...baseEntry, hostValue: 42 }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses a 2-tuple originalEntry', () => {
const entry = { ...baseEntry, originalEntry: ['10', 'seed'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses a 3-tuple originalEntry', () => {
const entry = { ...baseEntry, originalEntry: ['3', 'text', '1'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it.each([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
] as const)('parses reason %s', (reason) => {
const entry = { ...baseEntry, reason }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses JSON-string input', () => {
const input = JSON.stringify([baseEntry])
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
})
it('returns empty array for undefined', () => {
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
})
it('returns empty array for malformed JSON string', () => {
expect(parseProxyWidgetErrorQuarantine('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(parseProxyWidgetErrorQuarantine(baseEntry)).toEqual([])
})
it('returns empty array when attemptedAtVersion is not 1', () => {
const entry = { ...baseEntry, attemptedAtVersion: 2 }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
it('returns empty array when reason is not in the enum', () => {
const entry = { ...baseEntry, reason: 'somethingElse' }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
it('returns empty array when originalEntry is malformed', () => {
const entry = { ...baseEntry, originalEntry: ['only-one'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
})

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
])
export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),
attemptedAtVersion: z.literal(1)
})
export const proxyWidgetErrorQuarantinePropertySchema = z.array(
proxyWidgetErrorQuarantineEntrySchema
)
export type ProxyWidgetErrorQuarantineEntry = Omit<
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
'hostValue'
> & { hostValue?: TWidgetValue }
export function parseProxyWidgetErrorQuarantine(
property: NodeProperty | undefined
): ProxyWidgetErrorQuarantineEntry[] {
try {
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
const error = fromZodError(result.error)
console.warn(
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
}
return []
}

View File

@@ -0,0 +1,258 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { usePreviewExposureStore } from './previewExposureStore'
describe(usePreviewExposureStore, () => {
let store: ReturnType<typeof usePreviewExposureStore>
const rootGraphA = 'root-graph-a' as UUID
const rootGraphB = 'root-graph-b' as UUID
const hostA = createNodeLocatorId(rootGraphA, 7)
const hostB = createNodeLocatorId(rootGraphA, 8)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = usePreviewExposureStore()
})
describe('getExposures', () => {
it('returns empty readonly array for unknown host', () => {
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
})
describe('addExposure', () => {
it('appends a new exposure and returns it with name = sourcePreviewName when no collision', () => {
const entry = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
})
expect(entry).toEqual({
name: '$$canvas-image-preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
})
expect(store.getExposures(rootGraphA, hostA)).toEqual([entry])
})
it('disambiguates name collisions via nextUniqueName', () => {
const first = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
const second = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
const third = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '44',
sourcePreviewName: 'preview'
})
expect(first.name).toBe('preview')
expect(second.name).toBe('preview_1')
expect(third.name).toBe('preview_2')
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'preview',
'preview_1',
'preview_2'
])
})
})
describe('setExposures', () => {
it('replaces the array for the host', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
const next = [
{
name: 'replaced',
sourceNodeId: '99',
sourcePreviewName: 'other'
}
]
store.setExposures(rootGraphA, hostA, next)
expect(store.getExposures(rootGraphA, hostA)).toEqual(next)
})
it('clears the host bucket when given an empty array', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.setExposures(rootGraphA, hostA, [])
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
})
describe('removeExposure', () => {
beforeEach(() => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
})
it('removes the matching entry by name', () => {
store.removeExposure(rootGraphA, hostA, 'preview_1')
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'preview'
])
})
it('is a no-op when no entry matches', () => {
const before = store.getExposures(rootGraphA, hostA)
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
})
})
describe('moveExposure', () => {
beforeEach(() => {
store.setExposures(rootGraphA, hostA, [
{ name: 'a', sourceNodeId: '1', sourcePreviewName: 'a' },
{ name: 'b', sourceNodeId: '2', sourcePreviewName: 'b' },
{ name: 'c', sourceNodeId: '3', sourcePreviewName: 'c' }
])
})
it('reorders entries from -> to', () => {
store.moveExposure(rootGraphA, hostA, 0, 2)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'b',
'c',
'a'
])
})
it('is a no-op for equal indices', () => {
store.moveExposure(rootGraphA, hostA, 1, 1)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'a',
'b',
'c'
])
})
it('is a no-op for out-of-bounds indices', () => {
store.moveExposure(rootGraphA, hostA, -1, 2)
store.moveExposure(rootGraphA, hostA, 0, 5)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'a',
'b',
'c'
])
})
})
describe('clearGraph', () => {
it('removes all hosts under the rootGraphId without affecting others', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '1',
sourcePreviewName: 'p'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '2',
sourcePreviewName: 'p'
})
const hostInB = createNodeLocatorId(rootGraphB, 7)
store.addExposure(rootGraphB, hostInB, {
sourceNodeId: '3',
sourcePreviewName: 'p'
})
store.clearGraph(rootGraphA)
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
expect(store.getExposures(rootGraphA, hostB)).toEqual([])
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
})
})
describe('isolation between (rootGraphId, hostNodeLocator) pairs', () => {
it('keeps separate buckets per host and per root graph', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '1',
sourcePreviewName: 'p'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '2',
sourcePreviewName: 'p'
})
const hostInB = createNodeLocatorId(rootGraphB, 7)
store.addExposure(rootGraphB, hostInB, {
sourceNodeId: '3',
sourcePreviewName: 'p'
})
expect(store.getExposures(rootGraphA, hostA)).toHaveLength(1)
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
expect(store.getExposures(rootGraphA, hostA)[0].sourceNodeId).toBe('1')
expect(store.getExposures(rootGraphA, hostB)[0].sourceNodeId).toBe('2')
expect(store.getExposures(rootGraphB, hostInB)[0].sourceNodeId).toBe('3')
})
})
describe('resolveChain', () => {
it('returns a single-link chain for an existing exposure', () => {
const entry = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
expect(store.resolveChain(rootGraphA, hostA, entry.name)).toEqual({
rootGraphId: rootGraphA,
hostNodeLocator: hostA,
name: 'preview',
source: {
sourceNodeId: '42',
sourcePreviewName: 'preview'
}
})
})
it('returns undefined when the named exposure is missing', () => {
expect(store.resolveChain(rootGraphA, hostA, 'absent')).toBeUndefined()
})
it('does not yet walk nested-host chains (PR-A stub)', () => {
// Set up a host whose exposure points to a sourceNodeId that itself
// has its own preview exposure registered. PR-A must surface only the
// direct source — no recursion through the inner host.
const innerHost = createNodeLocatorId(rootGraphA, 99)
store.addExposure(rootGraphA, innerHost, {
sourceNodeId: 'inner-leaf',
sourcePreviewName: 'inner-preview'
})
const outer = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '99',
sourcePreviewName: 'outer-preview'
})
const resolved = store.resolveChain(rootGraphA, hostA, outer.name)
expect(resolved?.source).toEqual({
sourceNodeId: '99',
sourcePreviewName: 'outer-preview'
})
})
})
})

View File

@@ -0,0 +1,173 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])
/**
* A resolved chain of preview exposures from a host node down to the originating
* source preview.
*
* @remarks
* PR-A skeleton type: this currently represents a single-link chain (the
* direct exposure on the host). PR-B will expand this to model a full
* walk through nested subgraph hosts so that an interior host's exposure
* may resolve through one or more enclosing hosts to its ultimate source.
*/
export interface ResolvedPreviewChain {
rootGraphId: UUID
hostNodeLocator: string
name: string
source: {
sourceNodeId: string
sourcePreviewName: string
}
}
export const usePreviewExposureStore = defineStore('previewExposure', () => {
// Keyed by (rootGraphId, hostNodeLocator).
const exposures = ref(new Map<UUID, Map<string, PreviewExposure[]>>())
function _getHostsForGraph(
rootGraphId: UUID
): Map<string, PreviewExposure[]> {
const hosts = exposures.value.get(rootGraphId)
if (hosts) return hosts
const nextHosts = new Map<string, PreviewExposure[]>()
exposures.value.set(rootGraphId, nextHosts)
return nextHosts
}
function _getExposuresRef(
rootGraphId: UUID,
hostNodeLocator: string
): PreviewExposure[] | undefined {
return exposures.value.get(rootGraphId)?.get(hostNodeLocator)
}
function getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[] {
return _getExposuresRef(rootGraphId, hostNodeLocator) ?? EMPTY_EXPOSURES
}
function setExposures(
rootGraphId: UUID,
hostNodeLocator: string,
next: readonly PreviewExposure[]
): void {
const hosts = _getHostsForGraph(rootGraphId)
if (next.length === 0) {
hosts.delete(hostNodeLocator)
if (hosts.size === 0) exposures.value.delete(rootGraphId)
return
}
hosts.set(hostNodeLocator, [...next])
}
function addExposure(
rootGraphId: UUID,
hostNodeLocator: string,
source: { sourceNodeId: string; sourcePreviewName: string }
): PreviewExposure {
const hosts = _getHostsForGraph(rootGraphId)
const current = hosts.get(hostNodeLocator) ?? []
const existingNames = current.map((e) => e.name)
const name = nextUniqueName(source.sourcePreviewName, existingNames)
const entry: PreviewExposure = {
name,
sourceNodeId: source.sourceNodeId,
sourcePreviewName: source.sourcePreviewName
}
hosts.set(hostNodeLocator, [...current, entry])
return entry
}
function removeExposure(
rootGraphId: UUID,
hostNodeLocator: string,
name: string
): void {
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
if (!current?.length) return
const next = current.filter((e) => e.name !== name)
if (next.length === current.length) return
setExposures(rootGraphId, hostNodeLocator, next)
}
function moveExposure(
rootGraphId: UUID,
hostNodeLocator: string,
fromIndex: number,
toIndex: number
): void {
const hosts = exposures.value.get(rootGraphId)
const current = hosts?.get(hostNodeLocator)
if (!hosts || !current?.length) return
if (
fromIndex < 0 ||
fromIndex >= current.length ||
toIndex < 0 ||
toIndex >= current.length ||
fromIndex === toIndex
)
return
const next = [...current]
const [entry] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, entry)
hosts.set(hostNodeLocator, next)
}
function clearGraph(rootGraphId: UUID): void {
exposures.value.delete(rootGraphId)
}
/**
* Resolve the chain of exposures from a host down to the originating source preview.
*
* @remarks
* PR-A stub: returns a single-link chain wrapping the direct exposure if it
* exists on `(rootGraphId, hostNodeLocator)`, otherwise `undefined`. No nested
* subgraph host walking is performed yet.
*
* TODO(PR-B): implement full nested-host chain walking so that when an
* exposure's `sourceNodeId` itself refers to a subgraph host, this method
* follows the named exposure on that interior host recursively until it
* reaches a leaf (non-host) source.
*/
function resolveChain(
rootGraphId: UUID,
hostNodeLocator: string,
name: string
): ResolvedPreviewChain | undefined {
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
const entry = current?.find((e) => e.name === name)
if (!entry) return undefined
return {
rootGraphId,
hostNodeLocator,
name: entry.name,
source: {
sourceNodeId: entry.sourceNodeId,
sourcePreviewName: entry.sourcePreviewName
}
}
}
return {
getExposures,
setExposures,
addExposure,
removeExposure,
moveExposure,
clearGraph,
resolveChain
}
})