stillmade-sdk 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.claude/settings.json +15 -0
  2. package/.cursor/hooks.json +11 -0
  3. package/.gitignore +2 -0
  4. package/.stillmade/sdk-baseline.json +65 -0
  5. package/AGENTS.md +29 -7
  6. package/AGENT_CONTROL.md +2 -1
  7. package/BROWSER_APPLICATIONS.md +128 -3
  8. package/GITHUB_APPLICATIONS.md +184 -1
  9. package/NATIVE_APPLICATION_ADAPTATION.md +565 -0
  10. package/PASTE_TO_LLM.md +29 -10
  11. package/PORTABLE.md +5 -4
  12. package/REMIX.md +16 -10
  13. package/SDK.md +511 -46
  14. package/UI_AESTHETIC.md +37 -6
  15. package/api-index.json +90 -0
  16. package/blocks/connected-app/versions/{1.2.0 → 1.5.0}/task-selection.js +5 -3
  17. package/blocks/connected-app/versions/{1.4.0 → 1.5.0}/view.js +4 -2
  18. package/blocks/native/sm.ai-decide/versions/1.0.0/package.js +149 -0
  19. package/blocks/native/sm.ai-write/versions/1.0.0/package.js +147 -0
  20. package/blocks/native/sm.connect-app/versions/1.0.0/package.js +55 -0
  21. package/blocks/native/sm.contact-fields/versions/1.0.0/package.js +197 -0
  22. package/blocks/native/sm.continue-if/versions/1.0.0/package.js +168 -0
  23. package/blocks/native/sm.generate-image/versions/2.1.0/package.js +101 -0
  24. package/blocks/native/sm.meta-creative/versions/1.0.0/package.js +206 -0
  25. package/blocks/native/sm.new-items/versions/1.0.0/package.js +191 -0
  26. package/blocks/native/sm.voiceover/versions/2.1.5/package.js +179 -0
  27. package/blocks/native/sm.voiceover/versions/2.1.6/package.js +179 -0
  28. package/blocks/native/sm.when/versions/1.0.0/package.js +129 -0
  29. package/blocks/native/sm.write-summary/versions/1.0.0/package.js +133 -0
  30. package/blocks/occasion-story/package.js +1 -1
  31. package/blocks/occasion-story/versions/1.0.2/package.js +1682 -0
  32. package/blocks/photo-reel-composer/versions/1.0.1/package.js +3026 -0
  33. package/blocks/release-visuals/package.js +1 -1
  34. package/blocks/release-visuals/versions/1.0.2/package.js +1500 -0
  35. package/blocks/short-form-finish/package.js +1 -1
  36. package/blocks/short-form-finish/versions/1.0.3/package.js +1026 -0
  37. package/package.json +1 -1
  38. package/packages/block-cli/cli.js +16 -1
  39. package/packages/block-cli/format.js +2 -0
  40. package/packages/block-cli/ready-gate.js +164 -0
  41. package/packages/block-platform/block-readiness.js +3 -2
  42. package/packages/block-platform/canvas.js +1 -1
  43. package/packages/block-platform/catalog.js +6 -4
  44. package/packages/block-platform/chat-requirements.js +8 -3
  45. package/packages/block-platform/collaboration.js +5 -5
  46. package/packages/block-platform/first-revenue-project-types.js +2 -2
  47. package/packages/block-platform/flow-inspection.js +2 -2
  48. package/packages/block-platform/native-application-validation-context.js +20 -0
  49. package/packages/block-platform/plain-descriptions.js +67 -0
  50. package/packages/block-platform/product-ad-variants-project-type.js +6 -3
  51. package/packages/block-platform/project-context.js +4 -4
  52. package/packages/block-platform/project-type-composer.js +21 -12
  53. package/packages/block-platform/project-type-draft.js +2 -2
  54. package/packages/block-platform/project-type-recommendations.js +1 -1
  55. package/packages/block-platform/project-types.js +9 -4
  56. package/packages/block-platform/revenue-review-project-types.js +6 -5
  57. package/packages/block-platform/segment-runner.js +35 -11
  58. package/packages/block-platform/step-groups.js +78 -0
  59. package/packages/block-platform/steps.js +12 -8
  60. package/packages/block-platform/type-workspace.js +4 -4
  61. package/packages/block-platform/workflow-loop.js +4 -0
  62. package/packages/block-platform/workspace-input-readset.js +18 -4
  63. package/packages/block-sdk/admission.js +5 -17
  64. package/packages/block-sdk/agent-inputs.js +2 -0
  65. package/packages/block-sdk/agent-inspection.js +4 -2
  66. package/packages/block-sdk/browser-application-artifacts.js +1 -1
  67. package/packages/block-sdk/browser-application-execution.d.ts +2 -2
  68. package/packages/block-sdk/browser-application-execution.js +30 -12
  69. package/packages/block-sdk/browser-application-guest.d.ts +2 -0
  70. package/packages/block-sdk/browser-application-guest.js +16 -9
  71. package/packages/block-sdk/browser-application.d.ts +12 -4
  72. package/packages/block-sdk/browser-application.js +27 -13
  73. package/packages/block-sdk/builder-contract.js +25 -17
  74. package/packages/block-sdk/capabilities.js +38 -19
  75. package/packages/block-sdk/connected-app.js +26 -6
  76. package/packages/block-sdk/connection-adapters.js +19 -2
  77. package/packages/block-sdk/contracts-core.js +8 -0
  78. package/packages/block-sdk/contracts.js +9 -5
  79. package/packages/block-sdk/document-drafts.js +7 -1
  80. package/packages/block-sdk/document-store-binding.js +2 -1
  81. package/packages/block-sdk/file-bundle.js +4 -1
  82. package/packages/block-sdk/first-party-packages.js +18 -2
  83. package/packages/block-sdk/generation-catalog.js +3 -2
  84. package/packages/block-sdk/index.d.ts +20 -1
  85. package/packages/block-sdk/index.js +1 -0
  86. package/packages/block-sdk/native-application-package.d.ts +22 -0
  87. package/packages/block-sdk/native-application-package.js +77 -0
  88. package/packages/block-sdk/native_adapter.py +76 -0
  89. package/packages/block-sdk/package.json +1 -0
  90. package/packages/block-sdk/pipeline-runtime-host.d.ts +2 -0
  91. package/packages/block-sdk/project-flow-contract.js +2 -0
  92. package/packages/block-sdk/remix-policy.js +3 -1
  93. package/packages/block-sdk/resolver-admission.js +17 -0
  94. package/packages/block-sdk/runtime.js +4 -1
  95. package/packages/block-sdk/schema-fields.js +34 -2
  96. package/packages/block-sdk/semantic-relationships.js +14 -0
  97. package/packages/block-sdk/shared-controls.js +3 -1
  98. package/packages/block-sdk/shared-view.js +6 -1
  99. package/packages/block-sdk/source-rights.js +6 -5
  100. package/packages/block-sdk/testing.js +3 -0
  101. package/packages/design-system/theme.css +12 -3
@@ -1,3 +1,4 @@
1
+ import {plainBlockDescription} from './plain-descriptions.js';
1
2
  import {assert,plain,safeDocument,ROOT_CHAT_ID,stableStringify,valueMatches} from '../block-sdk/contracts.js';
2
3
  import {ADAPTERS,validateConnectionAdapter} from '../block-sdk/connection-adapters.js';
3
4
  import {STANDARD_SEMANTIC_RELATIONSHIPS} from '../block-sdk/semantic-contracts.js';
@@ -13,6 +14,7 @@ import {validateCondition} from './conditions.js';
13
14
  import {projectTypeEventCandidates} from './project-type-events.js';
14
15
  import {projectTypeRuleSequence} from './project-type-rules.js';
15
16
  import {packageForStage} from './stage-package.js';
17
+ import {normalizeGroups,joinGroupAt,groupOf} from './step-groups.js';
16
18
 
17
19
  const manifestOf=pkg=>pkg?.manifest || pkg;
18
20
  const identity=manifest=>`${manifest.id}@${manifest.version}`;
@@ -286,7 +288,7 @@ export function auditProjectTypeConnections(type,catalog=[],{contextValues=[],re
286
288
  catch(error){
287
289
  if(!['SEMANTIC','SCHEMA','LIMIT','CYCLE','CONNECTION'].includes(error.code))throw error;
288
290
  const reason=String(error.message),message=error.code==='CYCLE'?'These Blocks depend on each other. Change one source to break the loop.':error.code==='CONNECTION'?reason:'These Blocks disagree about the information they use. Choose compatible versions or fix their definitions.';
289
- const failed=(type.stages || []).map(stage=>{const manifest=manifestFor(type,stage,available),paused=stage.enabled===false;return {id:stage.id,label:stage.label || manifest?.name || stage.blockId,description:manifest?.description || '',state:paused?'ready':'broken',...(paused?{paused:true}:{contractIssue:{message,reason}}),requirements:[]};});
291
+ const failed=(type.stages || []).map(stage=>{const manifest=manifestFor(type,stage,available),paused=stage.enabled===false;return {id:stage.id,label:stage.label || manifest?.name || stage.blockId,description:plainBlockDescription(manifest),state:paused?'ready':'broken',...(paused?{paused:true}:{contractIssue:{message,reason}}),requirements:[]};});
290
292
  const failures=failed.filter(stage=>!stage.paused).map(stage=>({stageId:stage.id,state:'broken',code:error.code,message:`${stage.label}: ${message}`,reason}));
291
293
  return {stages:failed,issues:failures,connections:[],valid:false,ready:false,counts:{blocks:failures.length,requirements:0,missing:failures.length,questions:0},requirementsInspected:false};
292
294
  }
@@ -302,14 +304,15 @@ export function auditProjectTypeConnections(type,catalog=[],{contextValues=[],re
302
304
  }
303
305
  for(const stage of ordered) {
304
306
  const manifest=manifestFor(type,stage,available);
305
- const row={id:stage.id,label:stage.label || manifest?.name || stage.blockId,description:manifest?.description || '',state:'ready',requirements:[]};
307
+ const row={id:stage.id,label:stage.label || manifest?.name || stage.blockId,description:plainBlockDescription(manifest),state:'ready',requirements:[]};
306
308
  row.connectionIssues=issues.filter(issue=>issue.connectionId && issue.stageId===stage.id);
307
309
  stages.push(row);
308
310
  if(stage.enabled===false){row.paused=true;if(row.connectionIssues.length)row.state='broken';continue;}
309
311
  if(!manifest){row.state='broken';issues.push({stageId:stage.id,message:`${row.label} is unavailable. Choose a Block.`});continue;}
310
312
  const stageValues=[...values,...(eventCandidates.get(stage.id) || [])];
311
313
  for(const [key,port] of Object.entries(manifest.inputs || {})) {
312
- const requirement=normalized(key,port),permissions=manifest.permissions?.project || [];
314
+ // An input a native host never reads (the original Editor's) cannot be asked for.
315
+ const requirement=normalized(key,executable(stage,manifest,key)?port:{...port,required:false}),permissions=manifest.permissions?.project || [];
313
316
  const requirementResolver=resolver || createUniversalResolver({semantics,adapters:builtinSemanticAdapters(requirement,{permissions,semantics}),requestFromUser:pendingAnswer});
314
317
  const edge=declared.find(item=>item.to?.stage===stage.id && item.to.port===key);
315
318
  let decision;
@@ -519,7 +522,7 @@ export function chooseProjectTypeConverterRoute(type,{stageId,key,sourceStageId,
519
522
  return applyConverterPath(type,stageId,key,candidateId,{stageId:sourceStageId,port:sourceOutputKey},path,catalog);
520
523
  }
521
524
 
522
- export function chooseProjectTypeSource(type,stageId,key,candidateId,catalog=[],{semantics,contextValues=[]}={}) {
525
+ export function chooseProjectTypeSource(type,stageId,key,candidateId,catalog=[],{semantics,contextValues=[],experimentalBrowser,experimentalNative}={}) {
523
526
  assert(typeof candidateId==='string' && candidateId.length<=2000,'CONNECTION','Choose one current source.');
524
527
  semantics ||= semanticRegistryForManifests((type.stages || []).filter(stage=>stage.enabled!==false).map(stage=>manifestFor(type,stage,[...catalog,...(type.packages || [])])));
525
528
  const converterSearch=candidateId.startsWith('["block-conversion')?{stageId,key}:undefined;
@@ -538,7 +541,7 @@ export function chooseProjectTypeSource(type,stageId,key,candidateId,catalog=[],
538
541
  const match=validateConnectionAdapter(producer.outputs[source.port],consumer.inputs[key],{permissions:consumer.permissions?.project || [],semantics,...(candidate.adapter?{adapter:{id:candidate.adapter.id,version:Number(candidate.adapter.version)}}:{})});
539
542
  const removed=(type.connections||[]).filter(edge=>edge.to.stage===stageId&&edge.to.port===key);
540
543
  const next=detachUnusedConverterBranches(type,{...type,stages:type.stages.map(stage=>stage.id===stageId?withoutInputSetting(stage,key):stage),connections:[...(type.connections || []).filter(edge=>edge.to.stage!==stageId || edge.to.port!==key),{from:{stage:source.stageId,port:source.port},to:{stage:stageId,port:key},...(match.adapter?{adapter:match.adapter}:{})}]},removed);
541
- const validation=validateProjectTypeDraft(next,catalog);
544
+ const validation=validateProjectTypeDraft(next,catalog,{experimentalBrowser,experimentalNative});
542
545
  assert(validation.valid,'PROJECT_TYPE',validation.errors.map(error=>error.message).join(' '));
543
546
  return next;
544
547
  }
@@ -613,17 +616,17 @@ export function setProjectTypeLoop(type,loopId,loop,catalog=[]) {
613
616
  return next;
614
617
  }
615
618
 
616
- export function removeProjectTypeConnection(type,connectionId,catalog=[]) {
619
+ export function removeProjectTypeConnection(type,connectionId,catalog=[],options={}) {
617
620
  assert(typeof connectionId==='string' && connectionId.length<=2000,'CONNECTION','Choose an inspected connection.');
618
621
  const removed=(type.connections||[]).filter(edge=>stableStringify(edge)===connectionId);
619
622
  const connections=(type.connections || []).filter(edge=>stableStringify(edge)!==connectionId);
620
623
  assert(connections.length<(type.connections || []).length,'CONNECTION','This connection changed. Inspect the current flow again.');
621
- const next=detachUnusedConverterBranches(type,{...type,connections},removed),validation=validateProjectTypeDraft(next,catalog);
624
+ const next=detachUnusedConverterBranches(type,{...type,connections},removed),validation=validateProjectTypeDraft(next,catalog,options);
622
625
  assert(validation.valid,'PROJECT_TYPE',validation.errors.map(error=>error.message).join(' '));
623
626
  return next;
624
627
  }
625
628
 
626
- export function editProjectTypeDraft(type,action,catalog=[]) {
629
+ export function editProjectTypeDraft(type,action,catalog=[],options={}) {
627
630
  const available=[...catalog,...(type.packages || [])];
628
631
  let next={...type,stages:[...type.stages],connections:[...(type.connections || [])]};
629
632
  const index=type.stages.findIndex(stage=>stage.id===action.stageId);
@@ -666,6 +669,7 @@ export function editProjectTypeDraft(type,action,catalog=[]) {
666
669
  } else {
667
670
  assert(Number.isInteger(action.index) && action.index>=0 && action.index<=next.stages.length,'STEP','Choose a place in the flow.');
668
671
  next.stages.splice(action.index,0,replacement);
672
+ next=joinGroupAt(next,replacement.id,action.index);
669
673
  }
670
674
  } else if(action.kind==='set-enabled') {
671
675
  assert(typeof action.enabled==='boolean','STEP','Choose whether this Block is enabled.');
@@ -676,6 +680,7 @@ export function editProjectTypeDraft(type,action,catalog=[]) {
676
680
  assert(manifest,'MISSING_BLOCK','The pinned Block package is missing.');
677
681
  const copy={...structuredClone(stage),id:action.id || `step-${crypto.randomUUID().slice(0,12)}`,label:`${stage.label || manifest.name} copy`.slice(0,120)};
678
682
  next.stages.splice(index+1,0,copy);
683
+ next=joinGroupAt(next,copy.id,index+1);
679
684
  next.connections.push(...(type.connections || []).filter(edge=>edge.to.stage===stage.id).map(edge=>({...structuredClone(edge),to:{...edge.to,stage:copy.id}})));
680
685
  } else if(action.kind==='remove') {
681
686
  next.stages.splice(index,1);
@@ -683,12 +688,16 @@ export function editProjectTypeDraft(type,action,catalog=[]) {
683
688
  if(next.rules)next.rules=next.rules.filter(rule=>rule.stepId!==stage.id && rule.endStepId!==stage.id);
684
689
  if(next.loops)next.loops=next.loops.filter(loop=>loop.startStepId!==stage.id && loop.endStepId!==stage.id && loop.definition.until?.stage!==stage.id && !loop.definition.feedback.some(edge=>edge.from.stage===stage.id || edge.to.stage===stage.id));
685
690
  } else if(action.kind==='move') {
686
- assert(Number.isInteger(action.index) && action.index>=0 && action.index<next.stages.length,'STEP','Choose a place in the flow.');
687
- next.stages.splice(index,1);next.stages.splice(action.index,0,stage);
691
+ // action.job moves every part of the Step group the Block belongs to.
692
+ const parts=action.job&&groupOf(type,stage.id)?.stages||[stage.id];
693
+ const moving=next.stages.filter(item=>parts.includes(item.id)),rest=next.stages.filter(item=>!parts.includes(item.id));
694
+ assert(Number.isInteger(action.index) && action.index>=0 && action.index<=rest.length,'STEP','Choose a place in the flow.');
695
+ rest.splice(action.index,0,...moving);next.stages=rest;
688
696
  } else assert(false,'STEP','Choose a supported edit.');
697
+ next=normalizeGroups(next);
689
698
  const validationCatalog=catalog.filter(item=>!next.packages?.some(pkg=>identity(pkg.manifest)===identity(manifestOf(item))));
690
- next=connectProjectType(next,validationCatalog);
691
- const validation=validateProjectTypeDraft(next,validationCatalog);
699
+ next=normalizeGroups(connectProjectType(next,validationCatalog,options));
700
+ const validation=validateProjectTypeDraft(next,validationCatalog,options);
692
701
  assert(validation.valid,'PROJECT_TYPE',validation.errors.map(error=>error.message).join(' '));
693
702
  return next;
694
703
  }
@@ -4,11 +4,11 @@ import {validateProjectType} from './project-types.js';
4
4
  import {validateProjectTypeRules} from './project-type-rules.js';
5
5
  import {validateProjectTypeLoops} from './project-type-loops.js';
6
6
 
7
- export function validateProjectTypeDraft(type,catalog=[]) {
7
+ export function validateProjectTypeDraft(type,catalog=[],{experimentalBrowser,experimentalNative}={}) {
8
8
  try {
9
9
  safeDocument(type);assert(plain(type),'PROJECT_TYPE','Expected a Project Type draft.');
10
10
  const structuralType={...type,connections:[]};delete structuralType.rules;delete structuralType.loops;
11
- const structure=validateProjectType(structuralType,catalog,{allowEmpty:true,semantics:createSemanticRegistry()});
11
+ const structure=validateProjectType(structuralType,catalog,{allowEmpty:true,semantics:createSemanticRegistry(),experimentalBrowser,experimentalNative});
12
12
  if(!structure.valid)return structure;
13
13
  assert(type.connections===undefined || Array.isArray(type.connections) && type.connections.length<=500,'CONNECTION','Expected at most 500 connections.');
14
14
  for(const edge of type.connections || []) {
@@ -324,5 +324,5 @@ export function recommendProjectTypeCombinations({goal,requirements,handoffRequi
324
324
  });
325
325
  };
326
326
  const missingHandoffProducers=handoffs.filter(handoff=>!available.some(candidate=>resultSlotsForManifest(candidate.manifest).some(slot=>!slot.viaAction&&semanticCompatibility(slot.contract,handoff.from,semantics).compatible))).map(handoff=>({...handoff.from,key:`${handoff.key}_producer`,required:true,description:`Block output needed for ${handoff.description}`}));
327
- return {goal,requirements:wanted,sourceMaterials:supplied,resultPins,handoffRequirements:handoffs,executionDevices,combinations:results.map(result=>({...result,transformationPlan:transformationPlan(result),blocks:result.keys.map((key,index)=>{const candidate=catalog.get(key);return {key,identity:listingIdentity({kind:'block',id:candidate.release?.entityId||(candidate.origin==='built-in'?candidate.manifest.id:null),version:candidate.manifest.version,digest:candidate.release?.digest||candidate.sourceDigest}),id:candidate.manifest.id,version:candidate.manifest.version,name:candidate.manifest.name,description:candidate.manifest.description,reasons:explainBlock(candidate,index,result),origin:candidate.origin || 'catalog',...(candidate.release?{release:candidate.release}:{}),...(candidate.sourceDigest?{sourceDigest:candidate.sourceDigest}:{})};})})),missingCapabilities:[...wanted.filter(requirement=>!available.some(candidate=>covers(candidate.manifest,requirement))),...missingHandoffProducers],search:{evaluations,truncated:evaluations>=maxEvaluations,maxSteps},executionVerified:false};
327
+ return {goal,requirements:wanted,sourceMaterials:supplied,resultPins,handoffRequirements:handoffs,executionDevices,combinations:results.map(result=>({...result,transformationPlan:transformationPlan(result),blocks:result.keys.map((key,index)=>{const candidate=catalog.get(key);return {key,identity:listingIdentity({kind:'block',id:candidate.release?.entityId||(candidate.origin==='built-in'?candidate.manifest.id:null),version:candidate.manifest.version,digest:candidate.release?.digest||candidate.sourceDigest}),id:candidate.manifest.id,version:candidate.manifest.version,name:candidate.manifest.name,description:candidate.manifest.description,reasons:explainBlock(candidate,index,result),origin:candidate.origin || 'catalog',...(candidate.manifest.permissions?.capabilities?.includes('connection.execute')?{connected:true}:{}),...(candidate.release?{release:candidate.release}:{}),...(candidate.sourceDigest?{sourceDigest:candidate.sourceDigest}:{})};})})),missingCapabilities:[...wanted.filter(requirement=>!available.some(candidate=>covers(candidate.manifest,requirement))),...missingHandoffProducers],search:{evaluations,truncated:evaluations>=maxEvaluations,maxSteps},executionVerified:false};
328
328
  }
@@ -13,7 +13,9 @@ import { validatePackage } from '../block-sdk/testing.js';
13
13
  import {semanticRegistryForManifests} from '../block-sdk/package-semantics.js';
14
14
  import {isBrowserApplication,experimentalBrowserSource} from './experimental-browser-context.js';
15
15
  import {packageForStage} from './stage-package.js';
16
- export function validateProjectType(type, catalog, {allowEmpty=false,semantics,experimentalBrowser}={}) {
16
+ import {validateStepGroups} from './step-groups.js';
17
+ import {isNativeApplicationPackage,nativeApplicationValidationSource} from './native-application-validation-context.js';
18
+ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,experimentalBrowser,experimentalNative}={}) {
17
19
  const errors = [];
18
20
  try {
19
21
  safeDocument(type);
@@ -24,7 +26,7 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
24
26
  validateProjectOutcome(type.outcome);
25
27
  assert(type.acceptanceCriteria===undefined||Array.isArray(type.acceptanceCriteria)&&type.acceptanceCriteria.length<=24&&new Set(type.acceptanceCriteria.map(item=>item.key)).size===type.acceptanceCriteria.length&&type.acceptanceCriteria.every(item=>plain(item)&&Object.keys(item).length===3&&typeof item.key==='string'&&/^[a-z][a-z0-9_]{0,63}$/.test(item.key)&&typeof item.description==='string'&&item.description.trim().length>=3&&item.description.length<=240&&['artifact-inspection','workflow-receipt','human-review'].includes(item.verification)),'PROJECT_TYPE','Result checks must be distinct, bounded and unverified until a real result is inspected.');
26
28
  assert(plain(type) && type.schemaVersion === 1 && ID.test(type.id || '') && VERSION.test(type.version || ''), 'PROJECT_TYPE', 'Invalid project type identity');
27
- assert(Object.keys(type).every(k => ['schemaVersion', 'id', 'version', 'name', 'description', 'stages', 'connections', 'legacyMode', 'summary', 'packages', 'provenance','license','defaults','onboarding','outcome','rules','loops','projectInputs','startingChat','acceptanceCriteria','goalSourceMaterials','goalSourceInputs'].includes(k)), 'ROOT_PROTECTED', 'Project type contains unsupported settings');
29
+ assert(Object.keys(type).every(k => ['schemaVersion', 'id', 'version', 'name', 'description', 'stages', 'connections', 'legacyMode', 'summary', 'packages', 'provenance','license','defaults','onboarding','outcome','rules','loops','projectInputs','startingChat','acceptanceCriteria','goalSourceMaterials','goalSourceInputs','groups'].includes(k)), 'ROOT_PROTECTED', 'Project type contains unsupported settings');
28
30
  assert(type.startingChat === undefined || type.startingChat === 'native', 'ROOT_PROTECTED', 'Starting Chat is provided by StillMade.');
29
31
  allowEmpty = allowEmpty || type.startingChat === 'native';
30
32
  assert(type.license === undefined || typeof type.license === 'string' && type.license.length > 0 && type.license.length <= 200,'LICENSE','Use a bounded license identifier');
@@ -32,7 +34,8 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
32
34
  const available = [...catalog], identities = new Set(catalog.map(b => `${(b.manifest || b).id}@${(b.manifest || b).version}`));
33
35
  const embedded = new Set();
34
36
  for (const pkg of type.packages || []) {
35
- if(isBrowserApplication(pkg))experimentalBrowserSource(experimentalBrowser,pkg);
37
+ if(isNativeApplicationPackage(pkg))nativeApplicationValidationSource(experimentalNative,pkg);
38
+ else if(isBrowserApplication(pkg))experimentalBrowserSource(experimentalBrowser,pkg);
36
39
  else validatePackage(pkg);
37
40
  const key = `${pkg.manifest.id}@${pkg.manifest.version}`;
38
41
  const known = catalog.find(block => `${(block.manifest || block).id}@${(block.manifest || block).version}` === key);
@@ -54,7 +57,8 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
54
57
  assert(block, 'MISSING_BLOCK', `Missing or ambiguous ${stage.blockId}@${stage.version}`);
55
58
  const manifest=block.manifest || block;
56
59
  if(['browser-application','native-application'].includes(manifest.runtime)){
57
- experimentalBrowserSource(experimentalBrowser,block);
60
+ if(isNativeApplicationPackage(block))nativeApplicationValidationSource(experimentalNative,block);
61
+ else experimentalBrowserSource(experimentalBrowser,block);
58
62
  assert(!stage.failurePolicy&&!stage.condition,'BROWSER_APPLICATION_APPROVAL','Experimental application Steps require a direct operation without failure automation or bypass.');
59
63
  assert(!type.loops?.length&&!type.rules?.length,'BROWSER_APPLICATION_APPROVAL','Experimental application workflows cannot declare automatic rules or loops.');
60
64
  }
@@ -66,6 +70,7 @@ export function validateProjectType(type, catalog, {allowEmpty=false,semantics,e
66
70
  stages.set(stage.id, manifest);
67
71
  }
68
72
  assert(allowEmpty&&type.stages.length===0||type.stages.some(stage=>stage.enabled !== false),'STEP','Keep at least one enabled step');
73
+ validateStepGroups(type,stages);
69
74
  validateProjectTypeRules(type,available);
70
75
  validateProjectTypeLoops(type);
71
76
  semantics ||= semanticRegistryForManifests([...stages.values()]);
@@ -1,15 +1,16 @@
1
1
  // Published Type releases embed exact Block releases. Never import a Block's
2
2
  // floating current package here: a newer Block release would silently rewrite
3
- // these immutable Type versions. Adopt a newer Block in a new Type version.
4
- import shortForm from '../../blocks/short-form-finish/versions/1.0.1/package.js';
5
- import occasion from '../../blocks/occasion-story/versions/1.0.1/package.js';
6
- import release from '../../blocks/release-visuals/versions/1.0.1/package.js';
3
+ // these immutable Type versions. Adopt a newer Block in a new Type version
4
+ // (1.0.3 adopted the releases that confirm media rights once at sign-up).
5
+ import shortForm from '../../blocks/short-form-finish/versions/1.0.3/package.js';
6
+ import occasion from '../../blocks/occasion-story/versions/1.0.2/package.js';
7
+ import release from '../../blocks/release-visuals/versions/1.0.2/package.js';
7
8
  import editor from '../../blocks/native/sm.editor/versions/2.2.9/package.js';
8
9
 
9
10
  // Production workspaces accept original media through their declared SDK ports.
10
11
  // No special shell branches, hidden provider spending or fixture media in config.
11
12
  function type(key,name,description,pkg,config,brief){return Object.freeze({
12
- schemaVersion:1,id:'stillmade.'+key,version:'1.0.2',name,description,license:'MIT',startingChat:'native',
13
+ schemaVersion:1,id:'stillmade.'+key,version:'1.0.3',name,description,license:'MIT',startingChat:'native',
13
14
  summary:{text:description,origin:'human',generation:'authored',status:'ready'},
14
15
  defaults:{aspectRatio:config.aspect,brief},onboarding:[],packages:[pkg,editor].map(p=>structuredClone(p)),
15
16
  stages:[{id:'make',blockId:pkg.manifest.id,version:pkg.manifest.version,label:name,config},{id:'finish',blockId:editor.manifest.id,version:editor.manifest.version,label:'Editor',config:{commit:true,renderFps:30,renderResolution:1080,renderQuality:'high'}}],
@@ -23,14 +23,23 @@ import {pinCurrentSemanticSource,pinExplicitFlowSource,assertCurrentSemanticSour
23
23
  import {workflowDependencyOrder} from './workflow-dependency-order.js';
24
24
  import {USER_REQUEST_LIMIT,userRequestRequirement,chatRequestRequirement,checkedUserRequestAnswer} from '../block-sdk/interactive-requirements.js';
25
25
  import {validateWorkflowApproval} from '../block-sdk/workflow-approvals.js';
26
+ import {groupOf} from './step-groups.js';
26
27
  import {UNATTENDED_HOSTED_OPERATIONS} from './unattended.js';
27
28
  export {UNATTENDED_HOSTED_OPERATIONS};
28
29
 
29
30
  const runtimes=new Set(['recipe','javascript']);
30
31
  const remoteRuntime=runtime=>['capability','comfyui'].includes(runtime);
31
32
  // Remote Steps that may run in an unattended plan: granted connection actions
32
- // and granted hosted operations.
33
- const unattendedConnection=pkg=>pkg?.manifest?.runtime==='capability'&&(pkg.capability?.operation==='connection.execute'||UNATTENDED_HOSTED_OPERATIONS.includes(pkg.capability?.operation));
33
+ // and granted hosted operations. AI text and image generation are the exception:
34
+ // they run on their own only when the schedule approved a credit cap for them
35
+ // (below); otherwise a scheduled run stops before them for the owner.
36
+ const scheduleApprovedOperation=pkg=>['text.generate','image.generate'].includes(pkg?.capability?.operation);
37
+ const unattendedConnection=pkg=>pkg?.manifest?.runtime==='capability'&&(pkg.capability?.operation==='connection.execute'||UNATTENDED_HOSTED_OPERATIONS.includes(pkg.capability?.operation)&&!scheduleApprovedOperation(pkg));
38
+ // Hosted text and image generation also join when the schedule approved a per-run
39
+ // credit cap for them (unattendedText / unattendedImage).
40
+ const textGeneration=pkg=>pkg?.manifest?.runtime==='capability'&&pkg.capability?.operation==='text.generate';
41
+ const imageGeneration=pkg=>pkg?.manifest?.runtime==='capability'&&pkg.capability?.operation==='image.generate';
42
+ const unattendedStep=(pkg,plan)=>unattendedConnection(pkg)||plan?.unattendedText===true&&textGeneration(pkg)||plan?.unattendedImage===true&&imageGeneration(pkg);
34
43
  const hasInteractiveRequirements=pkg=>Object.values(pkg?.manifest.inputs || {}).some(port=>port.semantic && !port.context && (!port.sources || port.sources.includes('user') || port.sources.includes('chat')));
35
44
  const edgeKey=edge=>stableStringify({source:edge.source,target:edge.target,sourceHandle:edge.sourceHandle,targetHandle:edge.targetHandle,adapter:edge.data?.stillmadeAdapter ?? null});
36
45
  function frozen(value){if(value && typeof value==='object'){Object.freeze(value);for(const child of Object.values(value))frozen(child);}return value;}
@@ -44,10 +53,12 @@ function localDependencyOrder(placements,type) {
44
53
  }
45
54
 
46
55
  /** Capture the production definition and current placement settings, never fixtures. */
47
- export async function planSegment(projectType,canvas,startStepId,{interactive=false,background=false,unattended=false,endStepId,includeIndependent=false,reuseCurrentSources=false,batchUpdate=false,hostedUpdate=false,experimentalBrowser,applicationOperations={}}={}) {
56
+ export async function planSegment(projectType,canvas,startStepId,{interactive=false,background=false,unattended=false,unattendedText=false,unattendedImage=false,endStepId,includeIndependent=false,reuseCurrentSources=false,batchUpdate=false,hostedUpdate=false,experimentalBrowser,experimentalBrowserValidation,applicationOperations={}}={}) {
48
57
  if(experimentalBrowser!==undefined)assertExperimentalBrowserExecutionAuthority(experimentalBrowser);
49
58
  assert(!(interactive&&background),'SEGMENT_APPROVAL','Choose interactive or background execution');
50
59
  assert(typeof unattended==='boolean'&&(!unattended||background&&!interactive&&!includeIndependent&&!reuseCurrentSources),'SEGMENT_APPROVAL','Unattended actions require the explicit background connection host.');
60
+ assert(typeof unattendedText==='boolean'&&(!unattendedText||unattended),'SEGMENT_APPROVAL','Unattended text generation requires an unattended background host.');
61
+ assert(typeof unattendedImage==='boolean'&&(!unattendedImage||unattended),'SEGMENT_APPROVAL','Unattended image generation requires an unattended background host.');
51
62
  assert(typeof includeIndependent==='boolean' && (!includeIndependent || interactive && !background),'SEGMENT_APPROVAL','Independent Steps require an explicit interactive workflow run');
52
63
  assert(typeof reuseCurrentSources==='boolean' && (!reuseCurrentSources || interactive && !background && !includeIndependent && endStepId===startStepId),'SEGMENT_APPROVAL','Reusing current sources requires an interactive single-Step update.');
53
64
  assert(typeof batchUpdate==='boolean' && (!batchUpdate || reuseCurrentSources),'SEGMENT_APPROVAL','A batch update needs an explicit current-source update plan.');
@@ -56,22 +67,32 @@ export async function planSegment(projectType,canvas,startStepId,{interactive=fa
56
67
  safeDocument(operations);assert(plain(operations),'BROWSER_APPLICATION_OPERATION','Select application operations by Step ID.');
57
68
  safeDocument(type);assert(plain(graph) && Array.isArray(graph.nodes) && Array.isArray(graph.edges),'SEGMENT_CANVAS','A saved production Canvas is required');
58
69
  if(graph.projectType)assert(stableStringify(graph.projectType)===stableStringify(type),'SEGMENT_CHANGED','The workflow and Canvas definitions differ');
59
- const compiled=await compileTypeWorkspace(type,{experimentalBrowser}),active=type.stages.filter(step=>step.enabled!==false),start=active.findIndex(step=>step.id===startStepId);
70
+ const compiled=await compileTypeWorkspace(type,{experimentalBrowser:experimentalBrowser ?? experimentalBrowserValidation}),active=type.stages.filter(step=>step.enabled!==false);
71
+ // One Step per job: with no explicit range, an interactive run from any part
72
+ // of a Step group runs that whole job, from its first enabled part to its last.
73
+ const job=interactive&&!includeIndependent&&endStepId===undefined?groupOf(type,startStepId):null,jobParts=job?active.filter(step=>job.stages.includes(step.id)).map(step=>step.id):[];
74
+ if(jobParts.length){startStepId=jobParts[0];endStepId=jobParts.at(-1);}
75
+ const start=active.findIndex(step=>step.id===startStepId);
60
76
  assert(start>=0,'SEGMENT_STEP','Choose an enabled starting Step');
61
77
  const end=endStepId===undefined?active.length-1:active.findIndex(step=>step.id===endStepId);
62
78
  assert(end>=start,'SEGMENT_STEP','The execution range must end at an enabled Step at or after its start');
63
79
  const steps=[],selected=new Set();let nextStageId=null,stopReason='complete';
64
- const placements=active.slice(start,end+1);
80
+ let placements=active.slice(start,end+1);
81
+ // A source-only context can describe upstream applications, never run them.
82
+ if(experimentalBrowser===undefined){const boundary=placements.findIndex(step=>isBrowserApplication(packageForStage(type,step)));if(boundary>=0){nextStageId=placements[boundary].id;stopReason='runtime-approval';placements=placements.slice(0,boundary);}}
65
83
  const applicationPlacements=placements.filter(step=>isBrowserApplication(packageForStage(type,step)));
66
84
  assert(!applicationPlacements.length||interactive&&!background&&!includeIndependent&&!reuseCurrentSources&&!batchUpdate&&!hostedUpdate,'BROWSER_APPLICATION_APPROVAL','Application operations require an interactive connected run without batch, independent or source-reuse modes.');
67
85
  assert(Object.keys(operations).every(id=>applicationPlacements.some(step=>step.id===id)),'BROWSER_APPLICATION_OPERATION','Operations must belong to selected application Steps.');
68
86
  const executionOrder=includeIndependent?localDependencyOrder(placements,type):placements;
87
+ // A When Block starts an automation: every Step after it runs, in order, even
88
+ // one that only uses its own settings (such as an app task listing new leads).
89
+ const automationRoot=packageForStage(type,placements[0])?.manifest.id==='sm.when';
69
90
  for(const placement of executionOrder) {
70
91
  const pkg=packageForStage(type,placement);
71
92
  const applicationOperation=isBrowserApplication(pkg)?experimentalBrowserOperation(experimentalBrowser,pkg,operations[placement.id]):undefined;
72
93
  if(requiresDesktopExecution(pkg)&&(!interactive||background||includeIndependent)){nextStageId=placement.id;stopReason='runtime-approval';break;}
73
94
  if(pkg?.manifest.state&&!interactive&&!background){nextStageId=placement.id;stopReason='runtime-approval';break;}
74
- if(!pkg || !(runtimes.has(pkg.manifest.runtime)||unattended&&unattendedConnection(pkg)||isBrowserApplication(pkg)&&interactive||interactive&&!includeIndependent&&(!reuseCurrentSources||hostedUpdate&&pkg.manifest.runtime==='capability')&&remoteRuntime(pkg.manifest.runtime))){nextStageId=placement.id;stopReason=pkg?'runtime-approval':'host-workspace';break;}
95
+ if(!pkg || !(runtimes.has(pkg.manifest.runtime)||unattended&&(unattendedConnection(pkg)||unattendedText&&textGeneration(pkg)||unattendedImage&&imageGeneration(pkg))||isBrowserApplication(pkg)&&interactive||interactive&&!includeIndependent&&(!reuseCurrentSources||hostedUpdate&&pkg.manifest.runtime==='capability')&&remoteRuntime(pkg.manifest.runtime))){nextStageId=placement.id;stopReason=pkg?'runtime-approval':'host-workspace';break;}
75
96
  const incoming=await Promise.all((type.connections || []).filter(edge=>edge.to.stage===placement.id).map(async edge=>{
76
97
  // External converted values arrive raw. Pin the producing contract so the
77
98
  // executor can check its type/role without trusting a caller's descriptor.
@@ -91,7 +112,7 @@ export async function planSegment(projectType,canvas,startStepId,{interactive=fa
91
112
  const outputContract=pin?{...currentOutputContract,...pin.outputContract}:currentOutputContract;
92
113
  return {...edge,outputContract:structuredClone(outputContract),...(savedSource?{savedSource}:{})};
93
114
  }));
94
- if(!includeIndependent && steps.length && !incoming.some(edge=>selected.has(edge.from.stage))){nextStageId=placement.id;stopReason='unconnected-step';break;}
115
+ if(!includeIndependent && steps.length && !automationRoot && !incoming.some(edge=>selected.has(edge.from.stage))){nextStageId=placement.id;stopReason='unconnected-step';break;}
95
116
  assert(incoming.every(edge=>reuseCurrentSources?!!edge.savedSource || active.findIndex(step=>step.id===edge.from.stage)<active.indexOf(placement):includeIndependent?selected.has(edge.from.stage) || active.findIndex(step=>step.id===edge.from.stage)<start:active.findIndex(step=>step.id===edge.from.stage)<active.indexOf(placement)),'SEGMENT_ORDER',includeIndependent?'A required source is outside this local run. Start from that source or review the workflow boundary.':'A production Step depends on a later Step. Reorder the workflow first');
96
117
  const candidates=graph.nodes.filter(node=>node.id===stageNodeId(placement)),node=candidates[0];
97
118
  assert(candidates.length===1 && node.type==='programmableBlockNode','SEGMENT_PIN','The compiled Step is missing or duplicated');
@@ -110,7 +131,7 @@ export async function planSegment(projectType,canvas,startStepId,{interactive=fa
110
131
  assert(steps.length,'SEGMENT_RUNTIME','Choose a recipe or JavaScript Step to run a connected segment');
111
132
  if(endStepId!==undefined && stopReason==='complete' && active[end+1]){nextStageId=active[end+1].id;stopReason='selection-boundary';}
112
133
  const semanticManifests=active.map(stage=>packageForStage(type,stage,BUILTIN_BLOCKS)?.manifest).filter(manifest=>manifest?.semantics?.length).map(manifest=>({id:manifest.id,version:manifest.version,semantics:structuredClone(manifest.semantics)}));
113
- const plan={schemaVersion:1,workflowDigest:compiled.digest,startStepId,steps,nextStageId,stopReason,...(includeIndependent?{includeIndependent:true}:{}),...(unattended?{unattended:true}:{}),...(background&&steps.some(step=>step.package.manifest.state)?{background:true}:{}),...(interactive&&(includeIndependent||steps.some(step=>isBrowserApplication(step.package)||requiresDesktopExecution(step.package)||remoteRuntime(step.package.manifest.runtime)||step.package.manifest.state||hasInteractiveRequirements(step.package)||hasInteractiveRequirements(step.fallbackPackage)))?{interactive:true}:{})};
134
+ const plan={schemaVersion:1,workflowDigest:compiled.digest,startStepId,steps,nextStageId,stopReason,...(includeIndependent?{includeIndependent:true}:{}),...(unattended?{unattended:true}:{}),...(unattendedText?{unattendedText:true}:{}),...(unattendedImage?{unattendedImage:true}:{}),...(background&&steps.some(step=>step.package.manifest.state)?{background:true}:{}),...(interactive&&(includeIndependent||steps.some(step=>isBrowserApplication(step.package)||requiresDesktopExecution(step.package)||remoteRuntime(step.package.manifest.runtime)||step.package.manifest.state||hasInteractiveRequirements(step.package)||hasInteractiveRequirements(step.fallbackPackage)))?{interactive:true}:{})};
114
135
  if(semanticManifests.length)plan.semanticManifests=semanticManifests;
115
136
  if(type.projectInputs?.length){plan.projectInputs=structuredClone(type.projectInputs);plan.projectTypeIdentity={id:type.id,version:type.version};}
116
137
  if(reuseCurrentSources){plan.reuseCurrentSources=true;plan.interactive=true;}
@@ -181,7 +202,7 @@ async function runSegmentInternal(suppliedPlan,{runId=crypto.randomUUID(),source
181
202
  assert(!plan.includeIndependent || plan.includeIndependent===true && interactive && plan.interactive && !background && !hosted,'SEGMENT_APPROVAL','Independent Steps require an interactive local workflow run');
182
203
  assert(!rebind || typeof verifyHostedResult==='function' || !plan.steps.slice(0,prior.length).some(step=>remoteRuntime(step.package.manifest.runtime)),'SEGMENT_APPROVAL','Hosted receipts require a separate project ownership adapter');
183
204
  const unattendedHost=plan.unattended===true&&background===true&&!interactive&&typeof authorizeHostedStep==='function'&&typeof verifyHostedResult==='function';
184
- assert(!plan.unattended||unattendedHost&&plan.steps.filter(step=>remoteRuntime(step.package.manifest.runtime)).every(step=>unattendedConnection(step.package)&&!step.failurePolicy&&!step.fallbackPackage),'SEGMENT_APPROVAL','Unattended Steps require exact current grants and verified connection receipts; automatic remote retries are unavailable.');
205
+ assert(!plan.unattended||unattendedHost&&plan.steps.filter(step=>remoteRuntime(step.package.manifest.runtime)).every(step=>unattendedStep(step.package,plan)&&!step.failurePolicy&&!step.fallbackPackage),'SEGMENT_APPROVAL','Unattended Steps require exact current grants and verified connection receipts; automatic remote retries are unavailable.');
185
206
  assert(!hosted||(interactive===true&&plan.interactive===true&&typeof verifyHostedResult==='function')||unattendedHost,'SEGMENT_APPROVAL','Hosted Steps require an approved host and verified execution receipts');
186
207
  safeDocument(memorySources);assert(plain(memorySources),'SEGMENT_STATE','Memory sources must be keyed by placement.');
187
208
  safeDocument(memory);assert(plain(memory)&&Object.keys(memory).every(id=>plan.steps.some(step=>step.id===id&&step.package.manifest.state)),'SEGMENT_STATE','Memory must belong to a selected stateful placement');
@@ -202,7 +223,7 @@ async function runSegmentInternal(suppliedPlan,{runId=crypto.randomUUID(),source
202
223
  await fence(step);
203
224
  const pkg=step.package,m=pkg.manifest;
204
225
  const startingState=m.state?validateBlockState(m,memory[step.id]??step.state):undefined,stateSource=memorySources[step.id]??step.stateSource;
205
- assert((runtimes.has(m.runtime)||unattendedHost&&unattendedConnection(pkg)||interactive&&plan.interactive&&(remoteRuntime(m.runtime)||isBrowserApplication(pkg))) && await wait(digest(pkg))===step.packageDigest,'SEGMENT_PIN','The Step package pin changed');
226
+ assert((runtimes.has(m.runtime)||unattendedHost&&unattendedStep(pkg,plan)||interactive&&plan.interactive&&(remoteRuntime(m.runtime)||isBrowserApplication(pkg))) && await wait(digest(pkg))===step.packageDigest,'SEGMENT_PIN','The Step package pin changed');
206
227
  if(step.failurePolicy?.action==='fallback_block'){validateFailurePolicy(pinnedFailurePolicy(step.failurePolicy),m,[step.fallbackPackage]);assert(await wait(digest(step.fallbackPackage))===step.fallbackPackageDigest,'SEGMENT_PIN','The fallback Block package pin changed');}
207
228
  else assert(step.fallbackPackage===undefined&&step.fallbackPackageDigest===undefined,'SEGMENT_PIN','Unexpected fallback package');
208
229
  const applicationOptions=isBrowserApplication(pkg)?{operation:step.applicationOperation,stepId:step.id,runId,planDigest:plan.planDigest,workflowDigest:plan.workflowDigest,signal:controller.signal,check:()=>fence(step)}:null;
@@ -219,6 +240,9 @@ async function runSegmentInternal(suppliedPlan,{runId=crypto.randomUUID(),source
219
240
  if(selectedPrimary!==null)input[selectedPrimary]=structuredClone(origin);
220
241
  let changedPrimaryRepresentation=false;const workspaceSources=new Map(),connectionSources={};
221
242
  const context=await wait(resolveContext(structuredClone(step),structuredClone(origin)));await fence(step);
243
+ // An upstream gate that produced nothing for a required input means "this
244
+ // branch was not taken": the run ends here, successfully, without asking.
245
+ if(incoming.some(edge=>ids.has(edge.from.stage)&&edge.to.port!==selectedPrimary&&m.inputs[edge.to.port]?.required!==false&&completed.has(edge.from.stage)&&!Object.hasOwn(completed.get(edge.from.stage).outputs,edge.from.port))){result.stoppedAt={stepId:step.id,reason:'branch-not-taken'};break;}
222
246
  for(const edge of incoming){
223
247
  assert(edge.to.stage===step.id && Object.hasOwn(m.inputs,edge.to.port),'SEGMENT_INPUT','Connection targets an unavailable input');
224
248
  let output,value,omitted=false;
@@ -398,7 +422,7 @@ async function runSegmentInternal(suppliedPlan,{runId=crypto.randomUUID(),source
398
422
  const alternate=executionPackage!==pkg,executionStep=alternate?{...step,package:executionPackage,packageDigest:step.fallbackPackageDigest,label:`${step.label} / ${executionPackage.manifest.name}`}:step;
399
423
  await wait(onStartStep({runId,stepId:step.id,packageDigest:executionStep.packageDigest,placementDigest:step.packageDigest,attempt,inputDigest,capturedInputs:captureRunInputIdentities({...bound,...runtimeAnswers,...runtimeChatValues},activeSourceMap(),m.inputs),inputsComplete:executionPackage.manifest.runtime!=='javascript'}));await fence(step);
400
424
  const dispatchInputDigest=inputDigest;
401
- if(unattendedHost&&unattendedConnection(executionPackage))await wait(authorizeHostedStep(structuredClone(executionPackage),structuredClone(executionInput),{step:structuredClone(executionStep),runId,inputDigest,signal:controller.signal,check:()=>fence(step)}));
425
+ if(unattendedHost&&unattendedStep(executionPackage,plan))await wait(authorizeHostedStep(structuredClone(executionPackage),structuredClone(executionInput),{step:structuredClone(executionStep),runId,inputDigest,signal:controller.signal,check:()=>fence(step)}));
402
426
  const execution=isBrowserApplication(executionPackage)?await executeExperimentalBrowser(experimentalBrowser,executionPackage,executionInput,applicationOptions):await execute(structuredClone(executionPackage),structuredClone({...executionInput,...runtimeAnswers,...runtimeChatValues}),{...(recoverLastValid?{recoverLastValid}:{}),...(requestRuntimeAnswer && executionPackage.manifest.runtime==='javascript'?{requestFromUser:question=>requestRuntimeAnswer(question,attempt,executionStep.label,executionStep.packageDigest)}:{}),...(requestRuntimeChat && executionPackage.manifest.runtime==='javascript'?{requestFromChat:question=>requestRuntimeChat(question,attempt,executionStep)}:{}),runId,inputDigest,...(materialInputDigest?{workspaceMaterializedInput:structuredClone(bound)}:{}),workspaceSources:structuredClone([...workspaceSources.values()]),semanticSources:structuredClone(semantic.provenance),step:structuredClone(executionStep),...(executionPackage.manifest.state?{state:structuredClone(startingState),stateSource:structuredClone(stateSource)}:{}),signal:controller.signal,check:()=>fence(step)});
403
427
  if(requiresDesktopExecution(executionPackage)){
404
428
  assert(!execution.skipped,'DESKTOP_RECEIPT','The native executor must return a completed native action; only the host may apply a reviewed skip or fallback.');
@@ -0,0 +1,78 @@
1
+ // One Step a person sees is one job, and a job can be made of several parts
2
+ // (contract rule block-scope). A group names the parts of one job: consecutive
3
+ // Steps in `stages`. Each part stays a real Step, so its permissions, approvals,
4
+ // credits, memory and runs are unchanged; groups only change what people see.
5
+ import {assert,plain} from '../block-sdk/contracts.js';
6
+ import {BLOCK_NAME_MAX_LENGTH} from '../block-sdk/builder-contract.js';
7
+
8
+ export const MAX_GROUP_PARTS=12;
9
+ const GROUP_KEYS=['id','label','stages','settings','result'];
10
+
11
+ /** Checks `type.groups` against the stages and their manifests (Map stageId → manifest). */
12
+ export function validateStepGroups(type,manifests){
13
+ if(type.groups===undefined)return;
14
+ assert(Array.isArray(type.groups)&&type.groups.length<=50,'STEP_GROUP','Expected at most 50 Step groups');
15
+ const order=new Map(type.stages.map((stage,index)=>[stage.id,index])),used=new Set(),ids=new Set();
16
+ for(const group of type.groups){
17
+ assert(plain(group)&&Object.keys(group).every(key=>GROUP_KEYS.includes(key)),'STEP_GROUP','A Step group has an id, a name, its parts, settings and a result');
18
+ assert(/^[a-z][a-z0-9-]*$/.test(group.id||'')&&!ids.has(group.id)&&!order.has(group.id),'STEP_GROUP','Each Step group needs its own ID');ids.add(group.id);
19
+ assert(typeof group.label==='string'&&group.label.trim()&&group.label.length<=BLOCK_NAME_MAX_LENGTH,'STEP_GROUP',`Name each Step in ${BLOCK_NAME_MAX_LENGTH} characters or fewer`);
20
+ assert(Array.isArray(group.stages)&&group.stages.length>=2&&group.stages.length<=MAX_GROUP_PARTS,'STEP_GROUP',`A Step groups 2 to ${MAX_GROUP_PARTS} parts`);
21
+ const indexes=group.stages.map(id=>order.get(id));
22
+ assert(indexes.every(Number.isInteger)&&group.stages.every(id=>!used.has(id)),'STEP_GROUP','A part belongs to one Step and must exist');
23
+ assert(indexes.every((index,at)=>!at||index===indexes[at-1]+1),'STEP_GROUP','The parts of a Step must be next to each other, in order');
24
+ for(const id of group.stages)used.add(id);
25
+ // A When part starts its job; it is never in the middle of one.
26
+ assert(group.stages.slice(1).every(id=>manifests.get(id)?.id!=='sm.when'),'STEP_GROUP','When starts its Step');
27
+ assert(group.settings===undefined||Array.isArray(group.settings)&&group.settings.length<=16&&group.settings.every(item=>plain(item)&&group.stages.includes(item.stage)&&Object.hasOwn(manifests.get(item.stage)?.inputs||{},item.input)&&typeof item.label==='string'&&item.label.trim()&&item.label.length<=60),'STEP_GROUP','Step settings must name an input of one of its parts');
28
+ assert(group.result===undefined||plain(group.result)&&group.stages.includes(group.result.stage)&&Object.hasOwn(manifests.get(group.result.stage)?.outputs||{},group.result.port),'STEP_GROUP','A Step result must be an output of one of its parts');
29
+ }
30
+ }
31
+
32
+ /** Keeps groups consistent after an edit: missing parts leave, a part moved away leaves (the longest run stays), and a group under two parts is dissolved. */
33
+ export function normalizeGroups(type){
34
+ if(!Array.isArray(type.groups))return type;
35
+ const order=new Map(type.stages.map((stage,index)=>[stage.id,index]));
36
+ const groups=type.groups.flatMap(group=>{
37
+ const present=group.stages.filter(id=>order.has(id)).sort((a,b)=>order.get(a)-order.get(b)),runs=[];
38
+ for(const id of present){if(runs.length&&order.get(id)===order.get(runs.at(-1).at(-1))+1)runs.at(-1).push(id);else runs.push([id]);}
39
+ const stages=runs.reduce((best,run)=>run.length>best.length?run:best,[]);
40
+ if(stages.length<2)return [];
41
+ const settings=(group.settings||[]).filter(item=>stages.includes(item.stage)),result=group.result&&stages.includes(group.result.stage)?group.result:undefined;
42
+ return [{id:group.id,label:group.label,stages,...(settings.length?{settings}:{}),...(result?{result}:{})}];
43
+ });
44
+ if(!groups.length){const {groups:_dropped,...rest}=type;return rest;}
45
+ return {...type,groups};
46
+ }
47
+
48
+ /** The group a Step belongs to, if any. */
49
+ export const groupOf=(type,stageId)=>(type.groups||[]).find(group=>group.stages.includes(stageId))||null;
50
+
51
+ /** What people see in order: one item per job, a group or a single Step. */
52
+ export function stepItems(type){
53
+ const items=[],seen=new Set();
54
+ for(const stage of type.stages){
55
+ if(seen.has(stage.id))continue;
56
+ const group=groupOf(type,stage.id);
57
+ if(group){for(const id of group.stages)seen.add(id);items.push({kind:'group',id:group.id,label:group.label,stages:[...group.stages],group});}
58
+ else{seen.add(stage.id);items.push({kind:'stage',id:stage.id,label:stage.label,stages:[stage.id]});}
59
+ }
60
+ return items;
61
+ }
62
+
63
+ /** A Step inserted strictly between two parts of a job becomes one of its parts. */
64
+ export function joinGroupAt(type,stageId,index){
65
+ if(!Array.isArray(type.groups))return type;
66
+ const before=type.stages[index-1]?.id,after=type.stages[index+1]?.id;
67
+ return {...type,groups:type.groups.map(group=>before&&after&&group.stages.includes(before)&&group.stages.includes(after)?{...group,stages:[...group.stages.slice(0,group.stages.indexOf(after)),stageId,...group.stages.slice(group.stages.indexOf(after))]}:group)};
68
+ }
69
+
70
+ /** Group consecutive Steps into one job. */
71
+ export function groupSteps(type,stageIds,label,{id=`job-${crypto.randomUUID().slice(0,8)}`}={}){
72
+ const groups=(type.groups||[]).filter(group=>!group.stages.some(stage=>stageIds.includes(stage)));
73
+ return {...type,groups:[...groups,{id,label:String(label||'').trim().slice(0,BLOCK_NAME_MAX_LENGTH),stages:[...stageIds]}]};
74
+ }
75
+ /** Show a job's parts as separate Steps again. */
76
+ export function ungroupStep(type,groupId){return normalizeGroups({...type,groups:(type.groups||[]).filter(group=>group.id!==groupId)});}
77
+ /** Rename a job. */
78
+ export function renameGroup(type,groupId,label){return {...type,groups:(type.groups||[]).map(group=>group.id===groupId?{...group,label:String(label||'').trim().slice(0,BLOCK_NAME_MAX_LENGTH)}:group)};}
@@ -4,6 +4,7 @@ import {validateProjectType} from './project-types.js';
4
4
  import {validateCondition} from './conditions.js';
5
5
  import {HOST_PORTS} from './type-workspace.js';
6
6
  import {packageForStage} from './stage-package.js';
7
+ import {normalizeGroups,joinGroupAt} from './step-groups.js';
7
8
 
8
9
  // `stages` is the existing on-disk field for Step instances. Keep it stable so
9
10
  // releases, digests, project links and instance IDs do not need migration.
@@ -41,7 +42,9 @@ function connection(type,from,to,catalog) {
41
42
  validateConnectionAdapter(out,input,{permissions:b.permissions?.project || [],...(match.adapter?{adapter:match.adapter}:{})});
42
43
  return {from:{stage:from.id,port:out.name},to:{stage:to.id,port:input.name},...(match.adapter?{adapter:match.adapter}:{})};
43
44
  }
44
- function validated(type,catalog) {const result=validateProjectType(type,catalog);assert(result.valid,'STEP',result.errors.map(error=>error.message).join('. '));return type;}
45
+ // Every edit keeps Step groups consistent: missing parts leave a group, and a
46
+ // group that is no longer one run of parts is shown as separate Steps again.
47
+ function validated(type,catalog,options) {type=normalizeGroups(type);const result=validateProjectType(type,catalog,options);assert(result.valid,'STEP',result.errors.map(error=>error.message).join('. '));return type;}
45
48
  function pinPackage(type,pkg,catalog) {
46
49
  const manifest=pkg?.manifest;assert(manifest,'MANIFEST','Choose a Block package');
47
50
  const packages=[...(type.packages || [])],matches=[...catalog,...packages].filter(candidate=>candidate?.manifest?.id===manifest.id&&candidate.manifest.version===manifest.version),existing=matches.find(candidate=>stableStringify(candidate)===stableStringify(pkg));
@@ -52,10 +55,10 @@ function pinPackage(type,pkg,catalog) {
52
55
  }
53
56
  return packages;
54
57
  }
55
- function insertConfigured(type,pkg,index,catalog,step) {
58
+ function insertConfigured(type,pkg,index,catalog,step,options) {
56
59
  assert(Number.isInteger(index)&&index>=0&&index<=type.stages.length,'STEP','Choose a position in the workflow');
57
60
  const packages=pinPackage(type,pkg,catalog),nextType={...type,packages},stages=[...type.stages.slice(0,index),step,...type.stages.slice(index)];
58
- if(step.enabled===false)return validated({...nextType,stages},catalog);
61
+ if(step.enabled===false)return validated({...nextType,stages},catalog,options);
59
62
  const previous=type.stages.slice(0,index).findLast(item=>item.enabled!==false),next=type.stages.slice(index).find(item=>item.enabled!==false);
60
63
  const incoming=previous?connection(nextType,previous,step,catalog):null,outgoing=next?connection(nextType,step,next,catalog):null;
61
64
  const oldOutput=previous&&automaticPort(manifestFor(nextType,previous,catalog),'outputs'),oldInput=next&&automaticPort(manifestFor(nextType,next,catalog),'inputs');
@@ -65,16 +68,17 @@ function insertConfigured(type,pkg,index,catalog,step) {
65
68
  for(const edge of [incoming,outgoing].filter(Boolean)) {
66
69
  assert(!connections.some(existing=>existing.to.stage===edge.to.stage&&existing.to.port===edge.to.port),'STEP_INCOMPATIBLE','Another Block already supplies this input. Choose a compatible position in its connected workflow.');connections.push(edge);
67
70
  }
68
- return validated({...type,packages,stages,connections},catalog);
71
+ // A Step placed between two parts of a job becomes one of its parts.
72
+ return validated(joinGroupAt({...type,packages,stages,connections},step.id,index),catalog,options);
69
73
  }
70
- export function insertStep(type,pkg,index,catalog=[],{id}={}) {
74
+ export function insertStep(type,pkg,index,catalog=[],{id,experimentalBrowser,experimentalNative}={}) {
71
75
  const manifest=pkg?.manifest;assert(manifest,'MANIFEST','Choose a Block package');
72
76
  assert(manifest.id!==ROOT_CHAT_ID,'ROOT_PROTECTED','Starting chat is provided by the host and cannot be inserted as another Step');
73
- return insertConfigured(type,pkg,index,catalog,{id:id||`step-${crypto.randomUUID().slice(0,12)}`,blockId:manifest.id,version:manifest.version,label:manifest.name});
77
+ return insertConfigured(type,pkg,index,catalog,{id:id||`step-${crypto.randomUUID().slice(0,12)}`,blockId:manifest.id,version:manifest.version,label:manifest.name},{experimentalBrowser,experimentalNative});
74
78
  }
75
- export function insertionLocations(type,pkg,catalog=[]) {
79
+ export function insertionLocations(type,pkg,catalog=[],options={}) {
76
80
  return Array.from({length:type.stages.length+1},(_,index)=>{
77
- try{insertStep(type,pkg,index,catalog,{id:`insertion-preview-${index}`});return {index,compatible:true};}
81
+ try{insertStep(type,pkg,index,catalog,{...options,id:`insertion-preview-${index}`});return {index,compatible:true};}
78
82
  catch(error){return {index,compatible:false,reason:error.message};}
79
83
  });
80
84
  }
@@ -19,8 +19,8 @@ export function stageHost(stage) {
19
19
  return {phase:declaration?.phase||'blockStep',nodeId:host.canvasNode||!declaration?stageNodeId(stage):null,exportVideo:!!host.exportVideo,...(host.exportKind?{exportKind:host.exportKind}:{}),...(host.inspectClip?{inspectClip:true}:{}),...(host.editorTab?{editorTab:host.editorTab}:{})};
20
20
  }
21
21
 
22
- export function typeWorkspaceSupport(type,{experimentalBrowser}={}) {
23
- const validation = validateProjectType(type, BUILTIN_BLOCKS,{experimentalBrowser});
22
+ export function typeWorkspaceSupport(type,{experimentalBrowser,experimentalNative}={}) {
23
+ const validation = validateProjectType(type, BUILTIN_BLOCKS,{experimentalBrowser,experimentalNative});
24
24
  if (!validation.valid) return { supported: false, reasons: validation.errors.map(e => e.message) };
25
25
  const recipes = new Set([...(type.packages || []),...BUILTIN_BLOCKS.filter(pkg=>pkg.manifest.runtime!=='host')].map(pkg => `${pkg.manifest.id}@${pkg.manifest.version}`));
26
26
  const reasons = [];
@@ -51,14 +51,14 @@ export function projectAnchorTarget(project, anchor) {
51
51
  }
52
52
  // All executable source travels with the project. Installing a newer release
53
53
  // cannot change these nodes or silently replace a project's workflow definition.
54
- export async function compileTypeWorkspace(type,{experimentalBrowser}={}) {
54
+ export async function compileTypeWorkspace(type,{experimentalBrowser,experimentalNative}={}) {
55
55
  const definition = structuredClone(type);
56
56
  // Embed exact SDK-owned built-ins so saved projects carry their implementation.
57
57
  for (const stage of definition.stages || []) {
58
58
  const builtin = BUILTIN_BLOCKS.find(pkg => pkg.manifest.id === stage.blockId && pkg.manifest.version === stage.version && pkg.manifest.runtime !== 'host');
59
59
  if (builtin && !definition.packages?.some(pkg => pkg.manifest.id === stage.blockId && pkg.manifest.version === stage.version)) (definition.packages ||= []).push(structuredClone(builtin));
60
60
  }
61
- const support = typeWorkspaceSupport(definition,{experimentalBrowser});
61
+ const support = typeWorkspaceSupport(definition,{experimentalBrowser,experimentalNative});
62
62
  assert(support.supported, 'UNSUPPORTED_TYPE', support.reasons.join('\n'));
63
63
  const nodes = [];
64
64
  for (const stage of definition.stages.filter(stage=>stage.enabled !== false)) {
@@ -4,6 +4,10 @@ import {assertInputSource} from '../block-sdk/input-source-policy.js';
4
4
  import {semanticRegistryForManifests} from '../block-sdk/package-semantics.js';
5
5
  import {validateDataPredicate,conditionMatches} from './conditions.js';
6
6
 
7
+ // Repeat workflow is tucked away in the product for now (owner decision,
8
+ // October 9, 2026): the loop panel is hidden on Steps. The loop runtime, routes
9
+ // and tests stay in place; flip this to true to show the panel again.
10
+ export const WORKFLOW_LOOPS_ENABLED=false;
7
11
  export const MAX_WORKFLOW_ITERATIONS=100;
8
12
 
9
13
  const endpoint=value=>plain(value) && Object.keys(value).length===2 && ['stage','port'].every(key=>typeof value[key]==='string' && value[key].length>0 && value[key].length<=200);