From 6e824f8f850c3745e6397bd68f6d39d254c1436b Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:13 +0200 Subject: [PATCH 1/2] integrate(#252): propagate opt-in Artist Context through actual generation stages Only workflows with an explicit start node propagate intent; reject mixed origins and missing records, with seven pure regression tests. --- .../workflows/artist-intent-experimental.yml | 3 + package.json | 2 +- .../workflows/intentPropagation.test.mjs | 49 ++++++++++++++++ src/areas/workflows/intentPropagation.ts | 26 +++++++++ src/areas/workflows/workflowRunStore.ts | 58 ++++++++++++++++++- 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/areas/workflows/intentPropagation.test.mjs create mode 100644 src/areas/workflows/intentPropagation.ts diff --git a/.github/workflows/artist-intent-experimental.yml b/.github/workflows/artist-intent-experimental.yml index 9b4355e3..fd361c96 100644 --- a/.github/workflows/artist-intent-experimental.yml +++ b/.github/workflows/artist-intent-experimental.yml @@ -4,6 +4,7 @@ on: push: branches: - integration/artist-intent-core-2026-10-10 + - integration/artist-intent-propagation-2026-10-10 workflow_dispatch: permissions: @@ -73,6 +74,8 @@ jobs: run: node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs electron/main/storage-directory-guard.test.ts - name: Windows artist-intent regression run: node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs + - name: Windows intent propagation safety tests + run: node --experimental-strip-types --test src/areas/workflows/intentPropagation.test.mjs - name: Windows main-process typecheck run: npx --no-install tsc --noEmit -p tsconfig.node.json - name: Windows built-ins typecheck diff --git a/package.json b/package.json index 7d26c1ba..a81adb00 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs && node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs && node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs && node --experimental-strip-types --test src/areas/workflows/intentPropagation.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/workflows/intentPropagation.test.mjs b/src/areas/workflows/intentPropagation.test.mjs new file mode 100644 index 00000000..a34b9169 --- /dev/null +++ b/src/areas/workflows/intentPropagation.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { assessIntentSources } from './intentPropagation.ts' + +test('opt-out workflows do not receive arbitrary artist intent', () => { + assert.deepEqual(assessIntentSources([]), { source: undefined, conflict: false }) + assert.deepEqual(assessIntentSources([{ filePath: 'external.glb' }]), { source: undefined, conflict: false }) +}) + +test('a single stamped asset propagates its own request', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'stage.png', intentAssetPath: 'stage.png' }, + ]), { source: 'stage.png', conflict: false }) +}) + +test('two matching stamped copies remain attributable to the same request', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'stage1.glb', intentAssetPath: 'root.png' }, + { filePath: 'stage2.glb', intentAssetPath: 'root.png' }, + ]), { source: 'root.png', conflict: false }) +}) + +test('two distinct stamped assets fail closed rather than forging ownership', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'first.glb', intentAssetPath: 'first.png' }, + { filePath: 'second.glb', intentAssetPath: 'second.png' }, + ]), { source: undefined, conflict: true }) +}) + +test('a mixed stamped and unstamped image is an explicit conflict', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'stamped.glb', intentAssetPath: 'stamped.glb' }, + { filePath: 'other.png' }, + ]), { source: undefined, conflict: true }) +}) + +test('non-file text inputs do not erase attached intent', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'staged.png', intentAssetPath: 'staged.png' }, + { filePath: undefined }, + undefined, + ]), { source: 'staged.png', conflict: false }) +}) + +test('blank metadata markers are not treated as a source', () => { + assert.deepEqual(assessIntentSources([ + { filePath: 'model.glb', intentAssetPath: '' }, + ]), { source: undefined, conflict: false }) +}) diff --git a/src/areas/workflows/intentPropagation.ts b/src/areas/workflows/intentPropagation.ts new file mode 100644 index 00000000..1ffb5786 --- /dev/null +++ b/src/areas/workflows/intentPropagation.ts @@ -0,0 +1,26 @@ +/** + * Pure, fail-closed resolver for artists' intent provenance across workflow + * edges. A text-only input never dilutes an asset's intent. + */ +export interface IntentBearingOutput { + filePath?: string + intentAssetPath?: string +} + +export type IntentSourceDecision = + | { source: string; conflict: false } + | { source: undefined; conflict: boolean } + +export function assessIntentSources( + inputs: readonly (IntentBearingOutput | undefined)[], +): IntentSourceDecision { + const assets = inputs.filter((value): value is IntentBearingOutput => + typeof value?.filePath === 'string' && value.filePath.length > 0) + const tagged = assets.filter((value) => typeof value.intentAssetPath === 'string' + && value.intentAssetPath.length > 0) + if (tagged.length === 0) return { source: undefined, conflict: false } + if (tagged.length !== assets.length) return { source: undefined, conflict: true } + const sources = new Set(tagged.map((value) => value.intentAssetPath!)) + if (sources.size !== 1) return { source: undefined, conflict: true } + return { source: [...sources][0], conflict: false } +} diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 81346431..35517dea 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -9,6 +9,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' import { isBranchStarter, isSceneOutput, resolveDataSource, reachesSceneOutput, nearestUpstreamWaits } from './nodeBehaviors' import { assignSlotFilePaths } from './slotInputs' import { resolveBoundWorkflowParams } from './workflowParamBindings' +import { assessIntentSources } from './intentPropagation' import type { SlotInputType } from './slotInputs' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -49,7 +50,32 @@ function flushResume(): void { if (fn) fn() } -interface NodeOutput { filePath?: string; text?: string; outputType?: string; sourceAssetPath?: string } +interface NodeOutput { filePath?: string; text?: string; outputType?: string; sourceAssetPath?: string; intentAssetPath?: string } + +/** Carry only an explicitly initiated modly.intent.v1 across generated assets. */ +async function carryArtistIntent( + sourcePath: string, + destinationPath: string, + node: WFNode, + ext: WorkflowExtension, +): Promise { + const result = await window.electron.extensions.runProcess( + 'intent-travel', + { filePath: sourcePath, nodeId: 'carry' }, + { + destinationPath, + stage: node.data.label ?? ext.name, + details: { + node_id: node.id, + extension_id: node.data.extensionId ?? null, + output_type: ext.output, + }, + }, + ) + if (!result.success || !result.result?.filePath) { + throw new Error(result.error ?? 'Artist context could not be carried to the next stage') + } +} function isSceneMeshOutput(output: NodeOutput | undefined): output is NodeOutput & { filePath: string } { return output?.outputType === 'mesh' && typeof output.filePath === 'string' @@ -348,6 +374,10 @@ async function executeExtensionNode( const nodeInputSourceAssetPath = distinctSources.size === 1 ? [...distinctSources][0] : undefined const incomingEdges = workflow.edges.filter((e) => e.target === node.id) + const intentDecision = assessIntentSources(incomingEdges.map((edge) => resolveSource(edge.source))) + if (intentDecision.conflict) { + throw new Error('Artist context has ambiguous inputs. Use a single staged source instead of silently losing attribution.') + } if (ext?.inputs && ext.inputs.length > 1) { // Scene is intentionally a single-input-only model contract. The guard @@ -480,8 +510,12 @@ async function executeExtensionNode( useAppStore.getState().updateCurrentJob({ status: 'generating', progress: st.progress, step: st.step }) } } else { + const isIntentStart = ext?.extensionId === 'intent-travel' && ext?.nodeId === 'start' + if (isIntentStart && !nodeInputPath && selectedImageData) nodeInputText = selectedImageData if (ext?.input === 'mesh' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming mesh connection`) - if (ext?.input === 'image' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming image connection`) + if (ext?.input === 'image' && !nodeInputPath && !(isIntentStart && selectedImageData)) { + throw new Error(`${ext.name} needs an incoming image connection`) + } if (ext?.input === 'audio' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming audio connection`) if (ext?.input === 'text' && !nodeInputText) throw new Error(`${ext.name} needs an incoming text connection`) @@ -518,7 +552,25 @@ async function executeExtensionNode( } const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) - nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType, sourceAssetPath: nodeInputSourceAssetPath }) + const isArtistStart = ext?.extensionId === 'intent-travel' && ext.nodeId === 'start' + const isArtistCarry = ext?.extensionId === 'intent-travel' && ext.nodeId === 'carry' + let outputIntentPath: string | undefined + if ((isArtistStart || isArtistCarry) && nodeInputPath) { + // The explicit process node verified/created its modly.intent.v1 sidecar. + outputIntentPath = nodeInputPath + } else if ( + intentDecision.source && nodeInputPath && ext + && (outputType === 'image' || outputType === 'mesh') + ) { + if (intentDecision.source !== nodeInputPath) { + await carryArtistIntent(intentDecision.source, nodeInputPath, node, ext) + } + outputIntentPath = nodeInputPath + } + nodeOutputs.set(node.id, { + filePath: nodeInputPath, text: nodeInputText, outputType, + sourceAssetPath: nodeInputSourceAssetPath, intentAssetPath: outputIntentPath, + }) const output = nodeOutputs.get(node.id) const url = isSceneMeshOutput(output) ? toWorkspaceUrl(output.filePath, workspaceDir) : undefined From b7def4f75f99e7503eeb2fb0f78b28d7ae3c7009 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:15:26 +0200 Subject: [PATCH 2/2] docs(intent): describe opt-in stage propagation, failure contracts and acceptance ledger --- .../experimental-artist-intent-propagation.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/experimental-artist-intent-propagation.md diff --git a/docs/experimental-artist-intent-propagation.md b/docs/experimental-artist-intent-propagation.md new file mode 100644 index 00000000..2a5c0e58 --- /dev/null +++ b/docs/experimental-artist-intent-propagation.md @@ -0,0 +1,63 @@ +# Artist Context — Explicit Start, Automatic Stage Carry + +Source: [OmarB97's Modly #252](https://github.com/lightningpixel/modly/pull/252). +This experimental slice stacks on the standalone +[Artist Context core](https://github.com/JayceeB1/modly/pull/6). + +## What changes + +Only a workflow with the **Remember Artist Context** (intent-travel/start) +node is opted in. Its output is a staged image containing a new +\`modly.intent.v1\` sidecar. A processing stage receiving that image or a +derived image/mesh automatically runs intent-travel/carry before publishing +its output, appending a stage event and retaining the immutable request ID, +artist words, style and project. + +The goal is to preserve the original brief through: + +Image → Remember Artist Context → Style → Generator → Rig/Optimize → GLB Export + +The **Carry Artist Context** node also remains available for an explicit +source/destination transfer when a workflow cannot infer the connection. + +## Fail-closed rules + +- Workflows without an explicit start node remain unchanged. +- A generated stage never invents an original request or ancestry. +- Multiple input asset paths must **all** carry the same intent marker. + Mixed tagged/untagged files or different roots produce an explicit error + rather than silently attributing the output to the wrong source. +- Text-only edge inputs do not invalidate one tagged image/mesh. +- Processors can return the original path unchanged (e.g. Full detail); + no copy is needed then. +- If the processor fails to carry the record, the workflow returns an + error rather than declaring completion with missing metadata. +- The renderer cannot choose arbitrary artist-intent sidecar paths; + the underlying process extension rechecks physical workspace location + and sidecar size limits. +- A drag/drop image in \`selectedImageData\` can initialize the record, + with the same image size cap as the source processor. + +## Tests and evidence + +- Seven pure Node regression cases validate the decision logic for opt-in, + common origins, conflicting sources, orphaned/unmarked sources and text. +- The helper is wired into the existing Node test command and runs in + Windows and Linux checks. +- Integration compilation (Electron, TypeScript) ensures the runner and + extension signatures remain compatible. + +## Remaining acceptance work + +- [ ] Real Windows desktop run with a reference PNG and installed generators + (Style → 3D model → optimizer → exporter) +- [ ] Inspect the exported \`.tags.json\`: stable \`intent_id\`, original + sentences, stage events, project, \`derived_from\` unchanged +- [ ] Verify restart/continue and interrupted stage scenarios, including + source-output races and large inputs +- [ ] Review storage retention: staged source image copies currently remain + in \`Workflows/intent-sources\` until cleaned intentionally +- [ ] Validate compatibility with multi-input texture and scene generators + +Not an automatic change to all existing Modly workflows; **not approved for +release** until runtime smoke tests and storage retention policy are reviewed.