@nextcommerce/campaigns-os 1.34.1 → 1.37.2

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 (119) hide show
  1. package/AGENTS.md +42 -13
  2. package/CHANGELOG.md +117 -0
  3. package/README.md +48 -21
  4. package/contracts/agent-relevant-change-policy.v1.json +1 -0
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +31 -29
  7. package/contracts/fixtures/progress/observation.v0.json +89 -0
  8. package/contracts/release-ledger.json +425 -0
  9. package/contracts/supported-surface.json +20 -5
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/demo/apollo-v0/NOTICE.txt +51 -0
  12. package/demo/apollo-v0/assets/css/demo.css +2 -0
  13. package/demo/apollo-v0/assets/css/landing/tokens.css +35 -0
  14. package/demo/apollo-v0/assets/css/next-core.css +16124 -0
  15. package/demo/apollo-v0/assets/images/1x1_1.svg +19 -0
  16. package/demo/apollo-v0/assets/images/1x1_2.svg +19 -0
  17. package/demo/apollo-v0/assets/images/affirm-logo.svg +24 -0
  18. package/demo/apollo-v0/assets/images/apple-pay-logo.svg +4 -0
  19. package/demo/apollo-v0/assets/images/bancontact-logo.svg +1 -0
  20. package/demo/apollo-v0/assets/images/cc-visa.svg +28 -0
  21. package/demo/apollo-v0/assets/images/cc_amex.svg +20 -0
  22. package/demo/apollo-v0/assets/images/cc_discover.svg +21 -0
  23. package/demo/apollo-v0/assets/images/cc_master.svg +22 -0
  24. package/demo/apollo-v0/assets/images/credit-card-flags.svg +38 -0
  25. package/demo/apollo-v0/assets/images/demo-inline-40788e52a7c75b78.svg +1 -0
  26. package/demo/apollo-v0/assets/images/demo-inline-686e73c8840a0f0a.svg +1 -0
  27. package/demo/apollo-v0/assets/images/google-pay-logo.svg +7 -0
  28. package/demo/apollo-v0/assets/images/guarantee-badge.png +0 -0
  29. package/demo/apollo-v0/assets/images/icon-dollar.svg +5 -0
  30. package/demo/apollo-v0/assets/images/icon-guarantee.svg +5 -0
  31. package/demo/apollo-v0/assets/images/icon-shipping.svg +5 -0
  32. package/demo/apollo-v0/assets/images/icons8-lock-24_1icons8-lock-24.png +0 -0
  33. package/demo/apollo-v0/assets/images/ideal-logo.svg +30 -0
  34. package/demo/apollo-v0/assets/images/klarna-logo.svg +9 -0
  35. package/demo/apollo-v0/assets/images/landing/_shared/16x9.svg +19 -0
  36. package/demo/apollo-v0/assets/images/landing/_shared/1x1_1.svg +19 -0
  37. package/demo/apollo-v0/assets/images/landing/_shared/4x3.svg +19 -0
  38. package/demo/apollo-v0/assets/images/landing/_shared/arrow-right.svg +6 -0
  39. package/demo/apollo-v0/assets/images/landing/_shared/cta-guarantee-icon.png +0 -0
  40. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-1.svg +8 -0
  41. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-2.svg +10 -0
  42. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-3.svg +13 -0
  43. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-4.svg +11 -0
  44. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-quote.svg +6 -0
  45. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-verified.svg +10 -0
  46. package/demo/apollo-v0/assets/images/landing/bottomcta-1/check-bullet.svg +5 -0
  47. package/demo/apollo-v0/assets/images/landing/faq-1/faq-chevron.svg +6 -0
  48. package/demo/apollo-v0/assets/images/landing/footer-1/footer-logo.png +0 -0
  49. package/demo/apollo-v0/assets/images/landing/guarantee-1/guarantee-1-badge.svg +8 -0
  50. package/demo/apollo-v0/assets/images/landing/hero-1/icon-check.svg +5 -0
  51. package/demo/apollo-v0/assets/images/landing/hero-1/icon-star.svg +6 -0
  52. package/demo/apollo-v0/assets/images/landing/hero-1/icon-verified.svg +6 -0
  53. package/demo/apollo-v0/assets/images/landing/icons-5/icon-batteries.svg +7 -0
  54. package/demo/apollo-v0/assets/images/landing/icons-5/icon-cuff-checking.svg +6 -0
  55. package/demo/apollo-v0/assets/images/landing/icons-5/icon-dual-user.svg +5 -0
  56. package/demo/apollo-v0/assets/images/landing/icons-5/icon-fda-cleared.svg +5 -0
  57. package/demo/apollo-v0/assets/images/landing/icons-5/icon-heartbeat.svg +17 -0
  58. package/demo/apollo-v0/assets/images/landing/icons-5/icon-lcd-display.svg +10 -0
  59. package/demo/apollo-v0/assets/images/landing/icons-5/icon-movement-error.svg +7 -0
  60. package/demo/apollo-v0/assets/images/landing/icons-5/icon-portable.png +0 -0
  61. package/demo/apollo-v0/assets/images/landing/icons-5/icon-reading-memory.svg +6 -0
  62. package/demo/apollo-v0/assets/images/landing/icons-5/icon-wrist-comfort.svg +5 -0
  63. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-1.jpg +0 -0
  64. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-2.jpg +0 -0
  65. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-3.jpg +0 -0
  66. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-4.jpg +0 -0
  67. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-1.png +0 -0
  68. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-2.png +0 -0
  69. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-3.png +0 -0
  70. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-4.png +0 -0
  71. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-5.png +0 -0
  72. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-6.png +0 -0
  73. package/demo/apollo-v0/assets/images/landing/nav-1/flag-us.png +0 -0
  74. package/demo/apollo-v0/assets/images/landing/reviews-3/star-card.svg +3 -0
  75. package/demo/apollo-v0/assets/images/landing/reviews-3/star-lg.svg +3 -0
  76. package/demo/apollo-v0/assets/images/landing/reviews-3/star-sm.svg +3 -0
  77. package/demo/apollo-v0/assets/images/landing/reviews-3/verified.svg +10 -0
  78. package/demo/apollo-v0/assets/images/landing/testimonials-2/testimonials-2-reactions.svg +9 -0
  79. package/demo/apollo-v0/assets/images/landing/testimonials-2/testimonials-2-stars.svg +6 -0
  80. package/demo/apollo-v0/assets/images/link-logo.svg +1 -0
  81. package/demo/apollo-v0/assets/images/next-dark.svg +8 -0
  82. package/demo/apollo-v0/assets/images/paypal-logo.svg +5 -0
  83. package/demo/apollo-v0/assets/images/paypal-txt.svg +8 -0
  84. package/demo/apollo-v0/assets/images/paypal.svg +22 -0
  85. package/demo/apollo-v0/assets/images/sepa-logo.svg +275 -0
  86. package/demo/apollo-v0/assets/images/twint-logo.svg +1 -0
  87. package/demo/apollo-v0/assets/images/united-states-flag-icon.webp +0 -0
  88. package/demo/apollo-v0/assets/images/upsell-payment-logos.svg +38 -0
  89. package/demo/apollo-v0/assets/images/usps.png +0 -0
  90. package/demo/apollo-v0/checkout/index.html +1370 -0
  91. package/demo/apollo-v0/landing/index.html +1836 -0
  92. package/demo/apollo-v0/provenance.json +342 -0
  93. package/demo/apollo-v0/receipt/index.html +292 -0
  94. package/demo/apollo-v0/upsell-bundle-stepper/index.html +401 -0
  95. package/docs/activation-and-evidence.md +37 -0
  96. package/docs/build-packet.md +107 -0
  97. package/docs/demo-preview.md +63 -0
  98. package/docs/diagnostics.md +55 -0
  99. package/docs/orientation-contract-reference.md +2 -1
  100. package/docs/progress-snapshots.md +142 -0
  101. package/docs/runtime-readiness.md +1 -1
  102. package/docs/supported-surface.md +13 -1
  103. package/package.json +13 -3
  104. package/schemas/campaign-runtime-build-context.v0.schema.json +42 -0
  105. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +370 -0
  106. package/skills/next-campaigns-build/SKILL.md +17 -1
  107. package/skills/next-campaigns-os/SKILL.md +19 -3
  108. package/skills/next-campaigns-os-setup/SKILL.md +17 -1
  109. package/skills/next-campaigns-polish/SKILL.md +18 -2
  110. package/skills/next-campaigns-qa/SKILL.md +24 -8
  111. package/skills.json +5 -5
  112. package/src/cli.mjs +74 -4
  113. package/src/consent.mjs +2 -2
  114. package/src/demo-artifact.mjs +97 -0
  115. package/src/demo.mjs +85 -0
  116. package/src/diagnostic.mjs +101 -0
  117. package/src/install-mode.mjs +13 -1
  118. package/src/progress-node.mjs +177 -0
  119. package/src/progress.mjs +133 -0
@@ -0,0 +1,177 @@
1
+ // Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
2
+ import {randomBytes,createHash} from 'node:crypto';
3
+ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
4
+ import {join,resolve,dirname} from 'node:path';
5
+ import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
6
+ import {specMaterialHash} from './spec-identity.mjs';
7
+ import {sameFile} from './fs-identity.mjs';
8
+ import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
9
+ import {boundedResponseText,isLoopbackHostname} from './remit.mjs';
10
+ export const PROGRESS_OBSERVATION = Symbol('canonical progress observation');
11
+ export const PROGRESS_ENDPOINT = '/api/progress';
12
+ const accepted = (value,values,fallback='unknown')=>values.includes(value)?value:fallback;
13
+ const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
14
+ const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
15
+ const digest = value=>`sha256:${createHash('sha256').update(value).digest('hex')}`;
16
+ const read = path=>{try{return JSON.parse(readFileSync(path,'utf8'));}catch{return null;}};
17
+ function atomic(path,value) {const tmp=`${path}.${randomBytes(8).toString('hex')}.tmp`;writeFileSync(tmp,`${canonicalProgressJson(value)}\n`,{mode:0o600});renameSync(tmp,path);}
18
+ export function resolveProgressEndpoint(args,workspace,context) {
19
+ // A foreign context cannot donate a source endpoint or quietly fall back.
20
+ const named=context?.packet_path;
21
+ const bound=typeof named==='string'&&named.trim()&&sameFile(resolve(workspace.targetRepo,named),workspace.packetPath,{requireExisting:true});
22
+ const explicit=args['proxy-base'];
23
+ const source=context?.intake?.proxy_base;
24
+ if (!explicit && !bound && source) return {ok:false,reason:'source_binding_invalid'};
25
+ if (explicit===undefined && (source===null||source===undefined) && workspace.packet?.spec?.spec_url) {
26
+ try { if (new URL(workspace.packet.spec.spec_url).origin!==CANONICAL_REMIT_SCOPE) return {ok:false,reason:'source_binding_missing'}; } catch { return {ok:false,reason:'source_binding_invalid'}; }
27
+ }
28
+ const raw=explicit!==undefined?explicit:source!==null&&source!==undefined?source:CANONICAL_REMIT_SCOPE;
29
+ if (typeof raw!=='string'||!raw.trim()) return {ok:false,reason:'endpoint_invalid'};
30
+ try {
31
+ const url=new URL(raw);
32
+ if (url.username||url.password||url.search||url.hash||!['https:','http:'].includes(url.protocol)||(url.protocol==='http:'&&!isLoopbackHostname(url.hostname))) return {ok:false,reason:'endpoint_invalid'};
33
+ const base=url.href.replace(/\/+$/,'');
34
+ return {ok:true,base,scope:normalizeConsentScope(base)};
35
+ } catch {return {ok:false,reason:'endpoint_invalid'};}
36
+ }
37
+ export function projectProgressObservation({workspace,context,report,doctor,continuation,qaResult=null,packageVersion}) {
38
+ const packet=workspace.packet;
39
+ let spec=null;
40
+ try {spec=JSON.parse(readFileSync(resolve(dirname(workspace.packetPath),packet.spec.local_path),'utf8'));}catch{}
41
+ const mapId=id(packet?.spec?.map_id);
42
+ const localHash=spec?hash(specMaterialHash(spec)):null;
43
+ const localMapId=id(spec?.spec_identity?.map_id||spec?.map_id);
44
+ const baseline=context?.intake?.saved_map_revision;
45
+ const contextBound=typeof context?.packet_path==='string'&&context.packet_path.trim()&&sameFile(resolve(workspace.targetRepo,context.packet_path),workspace.packetPath,{requireExisting:true});
46
+ const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
47
+ const build=hash(doctor?.derived?.build_output_fingerprint?.value);
48
+ const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
49
+ const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&mapId&&localMapId===mapId&&report?.identity?.map_id===mapId&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
50
+ const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
51
+ const verdict=qaResult?.verdict;
52
+ const qa=verdict?{
53
+ verdict_id:id(verdict.run_id),disposition:accepted(verdict.disposition,['ready','ready_with_exceptions','blocked']),
54
+ binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
55
+ publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
56
+ }:null;
57
+ const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,16).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?accepted(gate?.status,['pass','blocked','waived','not_applicable']):'unknown'}));
58
+ const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
59
+ const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
60
+ const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
61
+ return {
62
+ schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
63
+ identity:{map_id:mapId,map_revision_hash:contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null),map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
64
+ stages:PROGRESS_STAGES.map(stage=>{
65
+ const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
66
+ const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
67
+ return {stage,status,source_build_fingerprint:source,build_binding:reportBound&&build&&source===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'?'matching':'unconfirmed'};
68
+ }),
69
+ preview:{present:Boolean(preview),url_hash:preview?digest(preview):null},
70
+ continuation:{stage,blocked:continuation?.ok!==true||(Array.isArray(continuation?.divergences)&&continuation.divergences.length>0)||stage==='unknown'||gates.some(gate=>['blocked','unknown'].includes(gate.state))||actions.includes('unknown'),divergent:Array.isArray(continuation?.divergences)&&continuation.divergences.length>0,action_ids:actions,gates},qa,
71
+ };
72
+ }
73
+ async function lock(dir,fn,{budgetMs=1500}={}) {
74
+ const path=join(dir,'.allocation-lock'); const start=Date.now();const token=randomBytes(16).toString('hex');
75
+ const abandoned=(unownedMtime=null)=>{
76
+ try {
77
+ const stat=lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink())return false;
78
+ const owner=read(join(path,'owner.json'));
79
+ if(Number.isInteger(owner?.pid)&&owner.pid>0&&typeof owner.token==='string') {
80
+ try {process.kill(owner.pid,0);return false;}catch(error){return error.code==='ESRCH';}
81
+ }
82
+ // A killed process can leave the directory before writing its owner.
83
+ // Give a live allocator ample time to finish that tiny synchronous gap.
84
+ return Date.now()-(unownedMtime??stat.mtimeMs)>10000;
85
+ }catch{return false;}
86
+ };
87
+ const recover=()=>{
88
+ if(!abandoned())return;
89
+ let originalMtime;try{originalMtime=lstatSync(path).mtimeMs;}catch{return;}
90
+ const claim=join(path,'.recovery');
91
+ // Only this exclusive claimant can rename the old lock. An interrupted
92
+ // recovery claim fails closed for explicit offline recovery; recursively
93
+ // stealing recovery claims would reintroduce a check/rename race.
94
+ try {mkdirSync(claim);atomic(join(claim,'owner.json'),{pid:process.pid,token});}catch{return;}
95
+ let moved=false;
96
+ try {
97
+ if(!abandoned(originalMtime))return;
98
+ const tomb=`${path}.abandoned-${token}`;
99
+ renameSync(path,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});
100
+ }catch{}finally{if(!moved&&read(join(claim,'owner.json'))?.token===token){try{rmSync(claim,{recursive:true,force:true});}catch{}}}
101
+ };
102
+ while (true) {
103
+ try {mkdirSync(path);atomic(join(path,'owner.json'),{pid:process.pid,token});break;}catch(error){if(error.code!=='EEXIST'||Date.now()-start>=budgetMs)throw new Error('progress.lock_unavailable');recover();await new Promise(resolve=>setTimeout(resolve,20));}
104
+ }
105
+ try{return await fn();}finally{if(read(join(path,'owner.json'))?.token===token)rmSync(path,{recursive:true,force:true});}
106
+ }
107
+ export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
108
+ mkdirSync(dir,{recursive:true,mode:0o700});
109
+ return lock(dir,async()=>{
110
+ const latest=read(join(dir,'latest.json'));
111
+ const latestValid=latest&&(await verifyProgressSnapshot(latest)).ok;
112
+ const observationHash=digest(canonicalProgressJson(observation));
113
+ if (latestValid) {
114
+ const {snapshot_id,stream_id,sequence,previous_snapshot_id,observed_at,...old}=latest;
115
+ if (digest(canonicalProgressJson(old))===observationHash) return {snapshot:latest,reused:true};
116
+ }
117
+ // A new spec/build identity starts an independent history; stage completion
118
+ // from the old output cannot become completion for the new output.
119
+ const sameIdentity=latestValid&&canonicalProgressJson(latest.identity)===canonicalProgressJson(observation.identity);
120
+ const snapshot={...observation,stream_id:sameIdentity?latest.stream_id:`progress_${randomBytes(16).toString('hex')}`,sequence:sameIdentity?latest.sequence+1:1,previous_snapshot_id:sameIdentity?latest.snapshot_id:null,observed_at:now().toISOString()};
121
+ snapshot.snapshot_id=await progressSnapshotId(snapshot);
122
+ if (!(await verifyProgressSnapshot(snapshot)).ok) throw new Error('progress.invalid_projection');
123
+ atomic(join(dir,`${snapshot.snapshot_id.slice(7)}.snapshot.json`),snapshot);
124
+ atomic(join(dir,'latest.json'),snapshot);
125
+ const history=readdirSync(dir).filter(name=>/^[0-9a-f]{64}\.snapshot\.json$/.test(name)).map(name=>({name,at:read(join(dir,name))?.observed_at||''})).sort((a,b)=>a.name===`${snapshot.snapshot_id.slice(7)}.snapshot.json`?-1:b.name===`${snapshot.snapshot_id.slice(7)}.snapshot.json`?1:b.at.localeCompare(a.at)||b.name.localeCompare(a.name));
126
+ const limit=Math.min(32,Math.max(1,historyLimit));
127
+ for (const old of history.slice(limit)) if(old.name!==`${snapshot.snapshot_id.slice(7)}.snapshot.json`) {rmSync(join(dir,old.name),{force:true});rmSync(join(dir,old.name.replace('.snapshot.json','.remit.json')),{force:true});}
128
+ return {snapshot,reused:false};
129
+ });
130
+ }
131
+ export async function remitProgressSnapshot(snapshot,{base,campaignKey,fetchImpl=globalThis.fetch,budgetMs=2000}={}) {
132
+ const bytes=canonicalProgressJson(snapshot);const deadline=Date.now()+Math.min(2000,Math.max(1,budgetMs));
133
+ let reason='transport_failed';let attempts=0;let httpStatus=null;
134
+ for (let attempt=0;attempt<2&&Date.now()<deadline;attempt++) {
135
+ attempts++;
136
+ try {
137
+ const response=await fetchImpl(`${base}${PROGRESS_ENDPOINT}`,{method:'POST',redirect:'error',headers:{'Content-Type':'application/json','Accept':'application/json','X-Campaign-Key':campaignKey},body:bytes,signal:AbortSignal.timeout(Math.max(1,deadline-Date.now()))});
138
+ httpStatus=response.status;
139
+ let ack=null;try{ack=JSON.parse(await boundedResponseText(response,{maxBodyBytes:4096,timeoutMs:Math.max(1,deadline-Date.now())}));}catch{}
140
+ if ((response.ok||response.status===409)&&ack?.ok===true&&ack.snapshot_id===snapshot.snapshot_id&&ack.digest===snapshot.snapshot_id) return {state:'ok',reason:'acknowledged',attempts,http_status:httpStatus};
141
+ reason=response.status===409?'conflict':response.ok?'ack_invalid':'http_refused';
142
+ if (!(response.status===429||response.status>=500)) break;
143
+ } catch {reason='transport_failed';}
144
+ }
145
+ return {state:'failed',reason,attempts,http_status:httpStatus};
146
+ }
147
+ export async function observeProgress(args,continuation,{qaResult=null,packageVersion,resolveKey,env=process.env,configPath,fetchImpl,warn=message=>process.stderr.write(`${message}\n`)}={}) {
148
+ if (args['no-write']===true) return {state:'disabled',reason:'no_write'};
149
+ try {
150
+ const source=continuation?.[PROGRESS_OBSERVATION];
151
+ if (!source) return {state:'skipped',reason:'observation_unavailable'};
152
+ const {workspace,context}=source;
153
+ const endpoint=resolveProgressEndpoint(args,workspace,context);
154
+ const observation=projectProgressObservation({...source,continuation,qaResult,packageVersion});
155
+ const storageScope=digest(canonicalProgressJson({packet:workspace.packetPath,context:workspace.contextPath,report:workspace.reportPath,endpoint:endpoint.ok?endpoint.scope:null})).slice(7);
156
+ const dir=join(workspace.targetRepo,'.campaign-runtime','progress',storageScope);
157
+ const {snapshot,reused}=await persistProgressObservation(observation,{dir});
158
+ const metaPath=join(dir,`${snapshot.snapshot_id.slice(7)}.remit.json`);
159
+ const prior=read(metaPath);
160
+ if (prior?.state==='ok') return {state:'ok',reason:'already_acknowledged',snapshot_id:snapshot.snapshot_id,reused};
161
+ let reason=!snapshot.identity.map_id?'map_id_missing':!endpoint.ok?endpoint.reason:args['no-remit']===true?'no_remit':null;
162
+ const consent=reason?null:resolveConsent({env,...(configPath?{configPath}:{}),proxyBase:endpoint.base,warn:()=>{}});
163
+ if (!reason&&consent.state!=='on') reason='consent_off';
164
+ if (!reason&&consent.scope_bypassed) reason='scoped_consent_required';
165
+ const key=reason?null:resolveKey?.(workspace.packet,workspace.packetPath,env)?.key;
166
+ if (!reason&&!key) reason='campaign_key_missing';
167
+ if (!reason) announceDefaultOnTelemetry(`${endpoint.base}${PROGRESS_ENDPOINT}`);
168
+ const remit=reason?{state:'skipped',reason,attempts:0,http_status:null}:await remitProgressSnapshot(snapshot,{base:endpoint.base,campaignKey:key,fetchImpl});
169
+ atomic(metaPath,{...remit,snapshot_id:snapshot.snapshot_id});
170
+ if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
171
+ return {...remit,snapshot_id:snapshot.snapshot_id,reused};
172
+ } catch(error) {
173
+ if(error?.message==='progress.lock_unavailable')warn('[campaigns-os] Progress allocation lock occupied; wait for the current writer. For abandoned or interrupted recovery, stop target writers and follow the offline lock recovery in docs/progress-snapshots.md. Lifecycle result is unchanged.');
174
+ else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
175
+ return {state:'failed',reason:'capture_unavailable'};
176
+ }
177
+ }
@@ -0,0 +1,133 @@
1
+ // Portable wire contract. No filesystem, network, Node globals or lifecycle picker.
2
+ export const PROGRESS_SCHEMA_VERSION = 'campaigns-os-progress-snapshot/v0';
3
+ export const PROGRESS_STAGES = ['prepare_build', 'setup', 'assembly', 'polish', 'deploy', 'qa'];
4
+ export const PROGRESS_CONTINUATIONS = ['prepare-build', 'doctor-blocked', 'setup', 'build', 'polish', 'deploy', 'qa', 'done', 'unknown'];
5
+ export const PROGRESS_STAGE_STATUSES = ['pending', 'required', 'blocked', 'completed', 'completed_with_warnings', 'skipped', 'unknown'];
6
+ export const PROGRESS_GATE_IDS = ['doctor', 'prepare_build', 'theme_gate', 'polish_gate', 'page_kit.sdk_version', 'page_kit.store_profile', 'built_output.upsell_selector_scope', 'polish.hidden_eager_media', 'unknown'];
7
+ export const PROGRESS_ACTION_IDS = [
8
+ 'doctor_recheck', 'restore_prepare_build_binding', 'recheck', 'rerun_prepare_build',
9
+ 'divergence_inspect', 'setup_skill', 'build_skill', 'build_local_proof', 'build_production_parity',
10
+ 'polish_skill', 'deploy', 'advance', 'install_browser', 'qa_run', 'run_end', 'run_record_present',
11
+ 'run_record_remit_recovery', 'run_record_closeout', 'purchase_proof_unknown',
12
+ 'theme_gate.starter_palette_blocks_qa', 'theme_gate.brand_contract_unreadable',
13
+ 'theme_gate.theme_generate', 'theme_gate.apply_brand_layer', 'theme_gate.waive_theme', 'theme_gate.fix_load_order',
14
+ 'polish_gate.rerun_build', 'polish_gate.run_polish', 'polish_gate.repair_waiver',
15
+ 'checkpoint.page_kit.sdk_version.repair_spec', 'checkpoint.page_kit.sdk_version.refresh_spec',
16
+ 'checkpoint.page_kit.store_profile.repair_spec', 'checkpoint.built_output.upsell_selector_scope.repair_selectors',
17
+ ...['page_kit.sdk_version', 'page_kit.store_profile', 'built_output.upsell_selector_scope'].flatMap(id => ['repair_target', 'align_store_profile', 'align_sdk_version', 'repair_waiver', 'waive'].map(action => `checkpoint.${id}.${action}`)),
18
+ ...['capture', 'install_browser', 'repair', 'repair_authority', 'local_proof_rebuild', 'waive'].map(action => `checkpoint.polish.hidden_eager_media.${action}`),
19
+ 'unknown',
20
+ ];
21
+ const str = pattern => ({type:'string', pattern});
22
+ const hash = {type:['string','null'], pattern:'^sha256:[0-9a-f]{64}$'};
23
+ const opaque = {type:['string','null'], pattern:'^[A-Za-z0-9_-]{1,64}$'};
24
+ const enumeration = values => ({enum:values});
25
+ const object = properties => ({type:'object', additionalProperties:false, required:Object.keys(properties), properties});
26
+ export const PROGRESS_SNAPSHOT_SCHEMA = {
27
+ $schema:'https://json-schema.org/draft/2020-12/schema',
28
+ $id:'https://nextcommerce.com/schemas/campaigns-os-progress-snapshot.v0.schema.json',
29
+ title:'Campaigns OS minimal progress observation v0',
30
+ ...object({
31
+ schema_version:{const:PROGRESS_SCHEMA_VERSION}, snapshot_id:str('^sha256:[0-9a-f]{64}$'),
32
+ stream_id:str('^progress_[0-9a-f]{32}$'), sequence:{type:'integer',minimum:1,maximum:2147483647},
33
+ previous_snapshot_id:hash, observed_at:str('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'),
34
+ package_version:str('^\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}$'), producer:enumeration(['next','qa']),
35
+ identity:object({map_id:opaque,map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
36
+ saved_revision_alignment:enumeration(['aligned','unconfirmed']),local_spec_material_hash:hash,
37
+ local_spec_material_algorithm:{const:'campaign-spec-material-v1'},build_fingerprint:hash,
38
+ build_fingerprint_algorithm:{const:'sha256-manifest/v1'}}),
39
+ stages:{type:'array',minItems:6,maxItems:6,items:object({stage:enumeration(PROGRESS_STAGES),status:enumeration(PROGRESS_STAGE_STATUSES),
40
+ build_binding:enumeration(['matching','unconfirmed']),source_build_fingerprint:hash})},
41
+ preview:object({present:{type:'boolean'},url_hash:hash}),
42
+ continuation:object({stage:enumeration(PROGRESS_CONTINUATIONS),blocked:{type:'boolean'},divergent:{type:'boolean'},
43
+ action_ids:{type:'array',maxItems:64,uniqueItems:true,items:enumeration(PROGRESS_ACTION_IDS)},
44
+ gates:{type:'array',maxItems:16,items:object({id:enumeration(PROGRESS_GATE_IDS),state:enumeration(['pass','blocked','waived','not_applicable','unknown'])})}}),
45
+ qa:{anyOf:[{type:'null'},object({verdict_id:opaque,disposition:enumeration(['ready','ready_with_exceptions','blocked','unknown']),
46
+ binding:enumeration(['matching','unconfirmed']),publish_state:enumeration(['skipped','ok','failed','unknown'])})]},
47
+ }),
48
+ };
49
+ export function canonicalProgressJson(value) {
50
+ if (Array.isArray(value)) return `[${value.map(canonicalProgressJson).join(',')}]`;
51
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonicalProgressJson(value[key])}`).join(',')}}`;
52
+ return JSON.stringify(value);
53
+ }
54
+ export async function progressDigest(value, cryptoImpl = globalThis.crypto) {
55
+ const bytes = new TextEncoder().encode(canonicalProgressJson(value));
56
+ const digest = await cryptoImpl.subtle.digest('SHA-256',bytes);
57
+ return `sha256:${Array.from(new Uint8Array(digest),byte=>byte.toString(16).padStart(2,'0')).join('')}`;
58
+ }
59
+ export async function progressSnapshotId(snapshot, cryptoImpl) {
60
+ const {snapshot_id:ignored,...content} = snapshot;
61
+ return progressDigest(content,cryptoImpl);
62
+ }
63
+ // Small bounded validator for exactly the schema vocabulary above. Fixed error codes,
64
+ // never values or property names from an untrusted payload.
65
+ export function validateProgressSnapshot(value) {
66
+ const errors = [];
67
+ let count = 0;
68
+ function check(data,schema,depth=0) {
69
+ if (++count>2048 || depth>12) return false;
70
+ if (schema.anyOf) return schema.anyOf.some(branch=>check(data,branch,depth+1));
71
+ if (Object.hasOwn(schema,'const') && data!==schema.const) return false;
72
+ if (schema.enum && !schema.enum.includes(data)) return false;
73
+ if (schema.type) {
74
+ const type = data===null?'null':Array.isArray(data)?'array':typeof data==='number'&&Number.isInteger(data)?'integer':typeof data;
75
+ const allowed = Array.isArray(schema.type)?schema.type:[schema.type];
76
+ if (!allowed.includes(type)) return false;
77
+ }
78
+ if (typeof data==='string' && schema.pattern && !new RegExp(schema.pattern).test(data)) return false;
79
+ if (typeof data==='number' && (data<schema.minimum||data>schema.maximum)) return false;
80
+ if (Array.isArray(data)) {
81
+ if (data.length<(schema.minItems||0)||data.length>(schema.maxItems||0)) return false;
82
+ if (Object.keys(data).length!==data.length) return false;
83
+ if (schema.uniqueItems && new Set(data).size!==data.length) return false;
84
+ return data.every(item=>check(item,schema.items,depth+1));
85
+ }
86
+ if (data && typeof data==='object') {
87
+ if (!schema.properties || Object.keys(data).some(key=>!Object.hasOwn(schema.properties,key))) return false;
88
+ if (schema.required.some(key=>!Object.hasOwn(data,key))) return false;
89
+ return Object.entries(data).every(([key,item])=>check(item,schema.properties[key],depth+1));
90
+ }
91
+ return true;
92
+ }
93
+ if (!check(value,PROGRESS_SNAPSHOT_SCHEMA)) errors.push('progress.invalid_shape');
94
+ if (!errors.length && (new Set(value.stages.map(stage=>stage.stage)).size!==6 || value.stages.some((stage,index)=>stage.stage!==PROGRESS_STAGES[index]))) errors.push('progress.invalid_stages');
95
+ if (!errors.length && value.identity.saved_revision_alignment==='aligned' && (!value.identity.map_id||!value.identity.map_revision_hash||!value.identity.local_spec_material_hash)) errors.push('progress.invalid_alignment');
96
+ if (!errors.length && value.continuation.gates.some(gate=>gate.id==='unknown'&&gate.state!=='unknown')) errors.push('progress.unsupported_authority');
97
+ if (!errors.length && value.sequence===1 && value.previous_snapshot_id!==null) errors.push('progress.invalid_chain');
98
+ if (!errors.length && value.sequence>1 && value.previous_snapshot_id===null) errors.push('progress.invalid_chain');
99
+ if (!errors.length && value.stages.some(stage=>stage.build_binding==='matching'&&(!value.identity.build_fingerprint||stage.source_build_fingerprint!==value.identity.build_fingerprint))) errors.push('progress.invalid_build_binding');
100
+ if (!errors.length && value.qa?.binding==='matching'&&(!value.qa.verdict_id||!value.identity.build_fingerprint||value.stages.find(stage=>stage.stage==='qa').build_binding!=='matching')) errors.push('progress.invalid_qa_binding');
101
+ if (!errors.length && (value.continuation.stage==='unknown'||value.continuation.action_ids.includes('unknown')||value.continuation.gates.some(gate=>gate.id==='unknown'||gate.state==='unknown'))&&!value.continuation.blocked) errors.push('progress.unsupported_authority');
102
+ if (!errors.length && value.preview.present!==(value.preview.url_hash!==null)) errors.push('progress.invalid_preview');
103
+ if (!errors.length && value.continuation.divergent&&!value.continuation.blocked) errors.push('progress.invalid_divergence');
104
+ if (!errors.length && !Number.isFinite(Date.parse(value.observed_at))) errors.push('progress.invalid_time');
105
+ return {ok:errors.length===0,errors};
106
+ }
107
+ export async function verifyProgressSnapshot(snapshot,cryptoImpl) {
108
+ const checked=validateProgressSnapshot(snapshot);
109
+ if (!checked.ok) return checked;
110
+ return (await progressSnapshotId(snapshot,cryptoImpl))===snapshot.snapshot_id?checked:{ok:false,errors:['progress.digest_mismatch']};
111
+ }
112
+ // Identity groups include build, local spec and saved revision; never merge stages
113
+ // across builds. Histories contain whole observations, not a readiness decision.
114
+ export async function groupProgressHistories(snapshots,{cryptoImpl,maxSnapshots=256}={}) {
115
+ if (!Array.isArray(snapshots)||snapshots.length>Math.min(maxSnapshots,256)) return {ok:false,groups:[],errors:['progress.history_bound']};
116
+ const groups=new Map(); let rejected=0;
117
+ for (const snapshot of snapshots) {
118
+ if (!(await verifyProgressSnapshot(snapshot,cryptoImpl)).ok) {rejected++;continue;}
119
+ const key=canonicalProgressJson({identity:snapshot.identity,stream_id:snapshot.stream_id});
120
+ if (!groups.has(key)) groups.set(key,[]);
121
+ groups.get(key).push(snapshot);
122
+ }
123
+ return {ok:rejected===0,errors:rejected?['progress.invalid_history_member']:[],groups:[...groups.values()].map(items=>{
124
+ const unique=[...new Map(items.map(item=>[item.snapshot_id,item])).values()].sort((a,b)=>a.sequence-b.sequence);
125
+ const conflicted=new Set(unique.map(item=>item.sequence)).size!==unique.length;
126
+ const incomplete=unique[0].sequence!==1 || unique.some((item,index)=>index>0&&(item.sequence!==unique[index-1].sequence+1||item.previous_snapshot_id!==unique[index-1].snapshot_id));
127
+ return {identity:unique[0].identity,stream_id:unique[0].stream_id,state:conflicted?'conflicted':incomplete?'incomplete':unique[0].identity.saved_revision_alignment!=='aligned'||!unique[0].identity.build_fingerprint?'unconfirmed':'observed',snapshots:unique};
128
+ })};
129
+ }
130
+ export function progressStorageKey(snapshot,scopeHash) {
131
+ if (!validateProgressSnapshot(snapshot).ok||!/^sha256:[0-9a-f]{64}$/.test(scopeHash)||!snapshot.identity.map_id||!snapshot.identity.map_revision_hash) return null;
132
+ return `progress:v0:${scopeHash.slice(7)}:${snapshot.identity.map_id}:${snapshot.identity.map_revision_hash.slice(7)}:${snapshot.stream_id}:${snapshot.sequence}:${snapshot.snapshot_id.slice(7)}`;
133
+ }