@atmem/architect 0.2.0 → 0.2.1

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.
@@ -1,43 +1,57 @@
1
- import React,{useEffect,useMemo,useRef,useState}from'react';
1
+ import React,{useEffect,useMemo,useRef,useState,useId}from'react';
2
+ import{postLargeJson}from'./large-request.mjs';
2
3
  import{BrainActivity}from'./BrainActivity.jsx';
3
4
  import{engineeringCall}from'./engineering-client.mjs';
4
5
  import{terraformSketch}from'./terraform.mjs';
5
6
  import{verifiedReceipt}from'./validation-receipt.mjs';
6
- export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt}){
7
- const [report,setReport]=useState(null),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
7
+ export function ProjectChecks({project,environment,session,onReceipt,onBusy,receipt,generationPending=false}){
8
+ const [report,setReport]=useState(null),[catalogReady,setCatalogReady]=useState(false),[catalog,setCatalog]=useState([]),[checks,setChecks]=useState([]),[result,setResult]=useState(null),[working,setWorking]=useState(false),[error,setError]=useState('');
9
+ const hintId=useId();
10
+ const [uploadProgress,setUploadProgress]=useState(null);
8
11
  const active=useRef(null),locked=useRef(false),files=useMemo(()=>terraformSketch(project,environment),[project,environment]),key=JSON.stringify(files);
9
12
  useEffect(()=>{let stopped=false;setResult(null);active.current?.abort();let previous=receipt;try{previous=previous||JSON.parse(localStorage.getItem('atmem-tf-receipt:'+project.id)||'null');}catch{}void verifiedReceipt(files,previous).then(value=>{if(!stopped){setResult(value);onReceipt?.(value);}});return()=>{stopped=true;active.current?.abort();};},[key]);
10
13
  useEffect(()=>{
14
+ setReport(null);setCatalog([]);setChecks([]);setCatalogReady(false);
11
15
  if(!session?.engineeringEnabled)return;
12
16
  let stopped=false,timer;const controller=new AbortController();
13
17
  async function poll(){try{
14
18
  const value=await engineeringCall(session,'delivery',{projectId:project.id},{signal:controller.signal});if(stopped)return;setReport(value);
15
- if(value.artifact){const payload={projectId:project.id,sessionId:value.session.id};const [definitions,results]=await Promise.all([engineeringCall(session,'validators',payload,{signal:controller.signal}),engineeringCall(session,'validation/list',{...payload,artifactId:value.artifact.id},{signal:controller.signal})]);if(!stopped){setCatalog(definitions.validators||[]);setChecks(results.checks||[]);}}
19
+ if(value.artifact){const payload={projectId:project.id,sessionId:value.session.id};const [definitions,results]=await Promise.all([engineeringCall(session,'validators',payload,{signal:controller.signal}),engineeringCall(session,'validation/list',{...payload,artifactId:value.artifact.id},{signal:controller.signal})]);if(!stopped){setCatalog(definitions.validators||[]);setChecks(results.checks||[]);setCatalogReady(true);}}else{setCatalog([]);setChecks([]);setCatalogReady(false);}
16
20
  }catch(e){if(!stopped&&e.name!=='AbortError'&&e.status!==404)setError(e.message);}finally{if(!stopped)timer=setTimeout(poll,2000);}}
17
21
  void poll();return()=>{stopped=true;clearTimeout(timer);controller.abort();};
18
22
  },[project.id,session?.token,session?.engineeringEnabled]);
19
23
  const running=checks.some(c=>c.state!=='finished'&&c.state!=='recovery_required');
20
- useEffect(()=>{onBusy?.(working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
21
- async function run(){
22
- if(locked.current)return;locked.current=true;setWorking(true);setError('');const controller=new AbortController();active.current=controller;
24
+ useEffect(()=>{onBusy?.(!!working||running);return()=>onBusy?.(false);},[working,running,onBusy]);
25
+ const available=catalog.filter(v=>v.available!==false);
26
+ async function run(kind){
27
+ if(generationPending||locked.current||!session?.token||kind==='application'&&(!report?.artifact||!available.length))return;
28
+ locked.current=true;setWorking(kind);setError('');const controller=new AbortController();active.current=controller;
23
29
  try{
24
- const jobs=[];
25
- if(report?.artifact)for(const validator of catalog.filter(v=>v.available!==false)){
26
- const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
27
- if(previous&&previous.state!=='finished')continue;
28
- jobs.push(engineeringCall(session,'validation/start',{projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id,validatorId:validator.id,retry:!!previous},{signal:controller.signal}));
30
+ if(kind==='application'){
31
+ const payload={projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id};
32
+ await Promise.all(available.map(validator=>{
33
+ const previous=checks.filter(c=>c.validatorId===validator.id&&c.contractSha256===validator.contractSha256).at(-1);
34
+ if(previous&&previous.state!=='finished')return;
35
+ return engineeringCall(session,'validation/start',{...payload,validatorId:validator.id,retry:!!previous},{signal:controller.signal});
36
+ }));
37
+ const results=await engineeringCall(session,'validation/list',payload,{signal:controller.signal});
38
+ if(!controller.signal.aborted)setChecks(results.checks||[]);
39
+ }else{
40
+ const value=await postLargeJson('/api/terraform/validate',{project,environment},{token:session.token,signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)]),onProgress:setUploadProgress});
41
+ const verified=await verifiedReceipt(files,value);if(!controller.signal.aborted){setResult(value);onReceipt?.(verified);try{if(verified)localStorage.setItem('atmem-tf-receipt:'+project.id,JSON.stringify(verified));else localStorage.removeItem('atmem-tf-receipt:'+project.id);}catch{}}
29
42
  }
30
- const outcomesPending=Promise.allSettled(jobs);
31
- const response=await fetch('/api/terraform/validate',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({project,environment}),signal:AbortSignal.any([controller.signal,AbortSignal.timeout(500000)])});const value=await response.json();
32
- if(!response.ok)throw Error(value.detail||'Terraform checks could not start.');
33
- const verified=await verifiedReceipt(files,value);if(!controller.signal.aborted){setResult(value);onReceipt?.(verified);try{if(verified)localStorage.setItem('atmem-tf-receipt:'+project.id,JSON.stringify(verified));else localStorage.removeItem('atmem-tf-receipt:'+project.id);}catch{}}
34
- const outcomes=await outcomesPending;const failed=outcomes.find(o=>o.status==='rejected');if(failed)throw failed.reason;
35
- }catch(e){if(!controller.signal.aborted){setResult({status:'failed',message:e.message});setError(e.message);}}
36
- finally{locked.current=false;setWorking(false);}
43
+ }catch(e){if(!controller.signal.aborted){setError(e.message);}}
44
+ finally{locked.current=false;setWorking(false);setUploadProgress(null);}
37
45
  }
38
46
  const label=status=>({passed:'Passed',failed:'Failed',incomplete:'Incomplete',unavailable:'Not run'}[status]||'Not run');
39
- return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3><button className="btn" disabled={!session?.token||working||running} onClick={run}>{working||running?<><BrainActivity size={22}/> Checking…</>:'Run checks'}</button></div>
40
- <ul className="check-rows"><li><span>Terraform</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working?'Running…':label(result?.status)}</strong></li>{checks.map(c=><li key={c.id}><span>{c.label}</span><strong className={c.result?.status==='passed'?'check-complete':'check-pending'}>{c.state==='finished'?label(c.result?.status):c.state==='recovery_required'?'Needs recovery':'Running…'}</strong></li>)}{!checks.length&&<li><span>Application checks</span><strong className="check-pending">{report?.artifact?'Not configured':'Generate files first'}</strong></li>}</ul>
47
+ const terraformHint=generationPending?'Project files are being generated. Wait until generation finishes.':'Validate the Terraform draft from your saved design.';
48
+ const applicationHint=generationPending?'Project files are being generated. Wait until generation finishes.':!report?.artifact?'Generate project files first.':!catalogReady?'Loading available application tests.':!available.length?'No application tests are configured.':'Run the generated application tests.';
49
+ const applicationStatus=generationPending?'Generating files…':!report?.artifact?'Generate files first':working==='application'||running?'Running…':checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':!catalogReady?'Loading…':available.length?'Not run':'Not configured';
50
+ return <section className="project-checks" aria-label="Project checks"><div className="section-action"><h3>Checks</h3></div>
51
+ <ul className="check-rows">
52
+ <li><span>Terraform validation</span><strong className={result?.status==='passed'?'check-complete':'check-pending'}>{working==='terraform'?uploadProgress!==null?`Uploading ${uploadProgress}%`:'Running…':label(result?.status)}</strong><span className="check-run-action" title={terraformHint} tabIndex={generationPending?0:undefined} aria-label={terraformHint}><button className="btn" aria-label="Run Terraform validation" title={terraformHint} disabled={generationPending||!session?.token||!!working||running} onClick={()=>run('terraform')}>{working==='terraform'?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
53
+ <li><span>Application tests</span><strong id={hintId} className={applicationStatus==='Passed'?'check-complete':'check-pending'}>{applicationStatus}</strong><span className="check-run-action" title={applicationHint} tabIndex={generationPending||!report?.artifact||!available.length?0:undefined} aria-label={applicationHint}><button className="btn" aria-label="Run application tests" aria-describedby={hintId} title={applicationHint} disabled={generationPending||!session?.token||!!working||running||!report?.artifact||!available.length} onClick={()=>run('application')}>{working==='application'||running?<><BrainActivity size={18}/> Running…</>:'Run'}</button></span></li>
54
+ </ul>
41
55
  {(result||checks.length>0)&&<details><summary>Check details</summary>{result&&<><p>{result.message}</p>{result.checks?.map((c,i)=><details key={i}><summary>{c.name} · {c.passed?'Passed':'Failed'}</summary><pre>{c.output}</pre></details>)}</>}{checks.map(c=><details key={c.id}><summary>{c.label}</summary><pre>{c.result?.output||c.result?.reason||c.state}</pre></details>)}</details>}
42
56
  {error&&<p role="alert">{error}</p>}
43
57
  </section>;
@@ -0,0 +1,26 @@
1
+ import React,{useEffect,useRef}from'react';
2
+ import{Star,X}from'lucide-react';
3
+ export const STAR_URL='https://github.com/aetna000/atmem-ai-architect';
4
+ const KEY='atmem-architect-star-prompt-v1',SNOOZE=30*24*60*60*1000;
5
+ export function shouldOfferStar(value,now=Date.now()){
6
+ if(!value)return true;
7
+ try{const record=JSON.parse(value);return record.choice!=='opened'&&(!Number.isFinite(record.after)||record.after<=now);}catch{return true;}
8
+ }
9
+ export function StarPrompt({offer=0}){
10
+ const dialog=useRef(null),choice=useRef('later');
11
+ function dismiss(value='later'){
12
+ choice.current=value;
13
+ try{localStorage.setItem(KEY,JSON.stringify({choice:value,after:Date.now()+SNOOZE}));}catch{}
14
+ dialog.current?.close();
15
+ }
16
+ useEffect(()=>{
17
+ if(!offer)return;
18
+ let allowed=true;try{allowed=shouldOfferStar(localStorage.getItem(KEY));}catch{}
19
+ if(allowed&&!dialog.current.open){choice.current='later';dialog.current.showModal();}
20
+ },[offer]);
21
+ return <dialog ref={dialog} className="star-prompt" aria-labelledby="star-prompt-title" onCancel={event=>{event.preventDefault();dismiss();}}>
22
+ <header><h2 id="star-prompt-title"><Star size={22} aria-hidden="true"/> Enjoying Architect?</h2><button type="button" className="icon" aria-label="Close star invitation" onClick={()=>dismiss()}><X size={20}/></button></header>
23
+ <p>If Architect helped, a GitHub star helps others find it.</p>
24
+ <div className="star-prompt-actions"><a className="btn primary" href={STAR_URL} target="_blank" rel="noopener noreferrer" onClick={()=>dismiss('opened')}><Star size={18} aria-hidden="true"/> Star on GitHub</a><button type="button" className="btn" onClick={()=>dismiss()}>Later</button></div>
25
+ </dialog>;
26
+ }
@@ -29,3 +29,13 @@
29
29
  .architecture-work > ul { max-height: 24rem; overflow: auto; padding-left: 1.3rem; }
30
30
  .architecture-work pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8rem; }
31
31
  .architecture-work small { display: block; }
32
+
33
+ .generation-progress { height: 8px; overflow: hidden; border-radius: 999px; background: var(--line, #e1e6ee); margin: 1rem 0 .5rem; }
34
+ .generation-progress > span { display: block; height: 100%; width: 35%; border-radius: inherit; background: var(--action, #f3a2b4); animation: generation-sweep 1.8s ease-in-out infinite; }
35
+ @keyframes generation-sweep { from { transform: translateX(-80%); } to { transform: translateX(266%); } }
36
+ @media (prefers-reduced-motion: reduce) { .generation-progress > span { animation: none; width: 100%; opacity: .65; } }
37
+
38
+ .engineering-progress-count { display: inline-block; font-variant-numeric: tabular-nums; transform-origin: center; }
39
+ .engineering-progress-count.updated { animation: progress-count-pulse 360ms ease-out; }
40
+ @keyframes progress-count-pulse { 0% { opacity: .55; transform: scale(.92); } 45% { opacity: 1; transform: scale(1.1); } 100% { transform: scale(1); } }
41
+ @media (prefers-reduced-motion: reduce) { .engineering-progress-count.updated { animation: none; } }
@@ -104,3 +104,8 @@
104
104
  .memory-modal{height:auto!important;max-height:88vh!important}.memory-profile-bar input,.memory-toolbar input,.memory-toolbar select,.memory-preference-form input,.memory-preference-form select{font:inherit;font-size:.85rem;min-height:36px;box-sizing:border-box;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}.memory-table th:last-child{width:84px;white-space:nowrap}.memory-table td:last-child{white-space:nowrap}.memory-table th:nth-child(4){width:90px}.memory-table th:nth-child(5){width:130px}.memory-table th:nth-child(3){width:84px}.memory-toolbar>strong{color:var(--muted);font-weight:500}.memory-pagination .btn{min-height:32px}.memory-record-detail .memory-table{table-layout:auto}.memory-table .memory-content:hover{text-decoration:underline;text-underline-offset:3px}
105
105
 
106
106
  .location-region-form{display:flex;align-items:flex-end;gap:12px}.location-region-form label{display:flex;flex-direction:column;gap:6px}.location-region-form input{font:inherit;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}
107
+
108
+ .final-checklist li>button:not(:disabled),.final-checklist li>button:not(:disabled) *{cursor:pointer}.final-checklist[aria-busy="true"] li>button,.final-checklist[aria-busy="true"] li>button *{cursor:wait}.final-checklist li>button:disabled{opacity:1}.checklist-label{display:flex;flex-direction:column;gap:4px;min-width:0}.checklist-label small{font-size:.75rem;color:var(--muted);font-weight:400;overflow-wrap:anywhere}.checklist-loading{font-size:.85rem;color:var(--muted)}
109
+ .star-prompt{width:min(460px,calc(100vw - 32px));max-width:460px;border:1px solid var(--line);border-radius:16px;padding:22px;background:var(--paper);color:var(--ink);box-shadow:0 18px 60px #0003}.star-prompt::backdrop{background:#0005}.star-prompt header,.star-prompt h2,.star-prompt-actions{display:flex;align-items:center;gap:10px}.star-prompt header{justify-content:space-between}.star-prompt h2{font-size:1.2rem}.star-prompt h2>svg{color:var(--accent);fill:var(--accent-soft)}.star-prompt p{margin:18px 0;line-height:1.5}.star-prompt-actions>a{flex:1;text-decoration:none}.star-prompt-actions>button{min-width:90px}
110
+
111
+ .check-run-action{display:inline-flex;margin-left:12px;flex-shrink:0}.check-run-action>button:disabled{pointer-events:none}.check-run-action:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
@@ -0,0 +1,25 @@
1
+ // Preserve the entire JSON payload; chunk transport changes no project content.
2
+ export async function postLargeJson(url,payload,{token,signal,onProgress,fetcher=fetch}={}){
3
+ const data=new TextEncoder().encode(JSON.stringify(payload));
4
+ if(data.length>64*1024*1024)throw Error('Project exceeds the 64 MiB validation upload limit.');
5
+ const post=async(path,value,requestSignal=signal)=>{
6
+ const response=await fetcher(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify(value),signal:requestSignal});
7
+ const result=await response.json();if(!response.ok)throw Error(result.detail||'Validation request could not start.');return result;
8
+ };
9
+ if(data.length<=192000)return post(url,payload);
10
+ const sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',data))].map(x=>x.toString(16).padStart(2,'0')).join('');
11
+ const {uploadId,chunkBytes}=await post('/api/terraform/uploads/start',{bytes:data.length,sha256});
12
+ try{
13
+ if(chunkBytes!==131072)throw Error('The upload service returned an unsupported chunk size.');
14
+ for(let offset=0,index=0;offset<data.length;offset+=chunkBytes,index++){
15
+ const chunk=data.subarray(offset,offset+chunkBytes);let text='';
16
+ for(let i=0;i<chunk.length;i+=8192)text+=String.fromCharCode(...chunk.subarray(i,i+8192));
17
+ await post('/api/terraform/uploads/chunk',{uploadId,index,data:btoa(text)});
18
+ onProgress?.(Math.round(Math.min(offset+chunkBytes,data.length)/data.length*100));
19
+ }
20
+ onProgress?.(null);
21
+ return await post(url+'-upload',{uploadId});
22
+ }finally{
23
+ await post('/api/terraform/uploads/cancel',{uploadId},AbortSignal.timeout(5000)).catch(()=>{});
24
+ }
25
+ }
package/src/main.jsx CHANGED
@@ -1,3 +1,5 @@
1
+ import {StarPrompt,STAR_URL} from './StarPrompt.jsx';
2
+ import {prefillAcceptedSettings} from './engineering-defaults.mjs';
1
3
  import {LocationChoices} from './LocationChoices.jsx';
2
4
  import {InlineNote} from './HelpNote.jsx';
3
5
  import {ProjectChecks} from './ProjectChecks.jsx';
@@ -663,6 +665,7 @@ document.documentElement.dataset.theme = savedTheme();
663
665
  function readChatView(id){try{const value=JSON.parse(localStorage.getItem('atmem-chat-view:'+id)||'null');return value&&Number.isSafeInteger(value.start)&&value.start>=0&&Array.isArray(value.turnIds)?value:{start:0,turnIds:[]};}catch{return {start:0,turnIds:[]};}}
664
666
  const pricingSignature=p=>JSON.stringify([p.id,p.estimate?.projectSize||'small',p.estimate?.usage,p.facts,p.decisions?.filter(d=>d.status==='Accepted'&&['cloud.region','data.country'].includes(d.subject)),[...storageNodes(p),...lambdaNodes(p),...googleNodes(p)].map(n=>[n.id,n.data.provider,n.data.service,n.data.config])]);
665
667
  function App() {
668
+ const [starOffer,setStarOffer]=useState(0);
666
669
  const [project, setProject] = useState(readSaved);
667
670
  const projectRef = useRef(project);
668
671
  projectRef.current = project;
@@ -749,7 +752,8 @@ function App() {
749
752
  const pricingKey=pricingSignature(project);
750
753
  async function refreshEstimate(selected){
751
754
  if(!session?.token)return;
752
- const requested=selected.estimate?.projectSize?selected:setProjectSize(selected,'small');
755
+ const resolved=prefillAcceptedSettings(selected);
756
+ const requested=resolved.estimate?.projectSize?resolved:setProjectSize(resolved,'small');
753
757
  if(requested!==selected)updateEngagement(requested);
754
758
  const request=++pricingRequest.current;pricingAttempt.current.add(pricingSignature(requested));
755
759
  const baseline=JSON.stringify([requested.estimate?.prices,requested.estimate?.details]);
@@ -1578,15 +1582,17 @@ function App() {
1578
1582
  const exportFiles = (files, suffix) => {
1579
1583
  download(zipSync(Object.fromEntries(Object.entries(files).map(([name, content]) => [name, typeof content === "string" ? strToU8(content) : content]))), `${slug}-r${project.revision}-${suffix}.zip`, "application/zip");
1580
1584
  };
1581
- const downloadBundle = bundle => {
1585
+ const downloadBundle = async bundle => {
1586
+ bundle=await(bundle.prepare?.()||bundle);
1587
+ if(bundle.project!==projectRef.current)throw Error('Design changed while preparing files. Download again.');
1582
1588
  download(zipSync(Object.fromEntries(Object.entries(bundle.files).map(([name,content])=>[name,typeof content === "string"?strToU8(content):content]))),bundle.filename,"application/zip");
1583
1589
  if(bundle.project?.id===projectRef.current.id)updateEngagement(acknowledgeStage(projectRef.current,'delivery'));
1584
- notify("ZIP downloaded.");
1590
+ notify("ZIP downloaded.");setStarOffer(value=>value+1);
1585
1591
  };
1586
1592
  const exportPack = async () => {
1587
1593
  try {
1588
1594
  notify("Preparing documents and diagram exports…");
1589
- downloadBundle(await outputBundle(project,run,environment));
1595
+ await downloadBundle(await outputBundle(project,run,environment));
1590
1596
  } catch(e) { notify(e.message); }
1591
1597
  };
1592
1598
  const exportTerraform = async (validation = null) => {
@@ -1887,6 +1893,7 @@ function App() {
1887
1893
  <hr />
1888
1894
  <button role="menuitem" onClick={() => setModal("connections")}><Waypoints size={16}/>AI providers</button>
1889
1895
  <button role="menuitem" onClick={() => setModal("licenses")}><ShieldCheck size={16}/>Licences</button>
1896
+ <a role="menuitem" href={STAR_URL} target="_blank" rel="noopener noreferrer">Star on GitHub ↗</a>
1890
1897
  </div>
1891
1898
  )}
1892
1899
  </header>
@@ -2164,12 +2171,12 @@ function App() {
2164
2171
  <header className="review-intro"><h2>Review</h2></header>
2165
2172
  <div className="review-design-status"><ShieldCheck size={18}/><span>{findings.filter(f=>f.status==='open').length} design items</span><button className="icon" aria-label="Open design checks in specification" title="Specification" onClick={()=>visitJourney('scope')}><Code2 size={16}/></button></div>
2166
2173
  <section className="review-costs" aria-label="Project costs">
2167
- {["review","estimate"].includes(mode) && <EstimateView project={project} result={costs} onChange={updateEngagement} busy={busy||agentBusy||!!pending} onEstimate={refreshEstimate} pricing={pricingState} onNext={()=>{const section=document.querySelector('.review-generation');const target=section?.querySelector('button')||section;if(target){target.setAttribute('tabindex','-1');target.focus({preventScroll:true});target.scrollIntoView({block:'center'});}}} />}
2174
+ {["review","estimate"].includes(mode) && <EstimateView project={project} result={costs} onChange={updateEngagement} busy={busy||agentBusy||!!pending} onEstimate={refreshEstimate} onChooseRegion={()=>{visitJourney('cloud');requestAnimationFrame(()=>{const input=document.querySelector('.location-region-form input');input?.focus();input?.scrollIntoView({block:'center',behavior:'instant'});});}} pricing={pricingState} onNext={()=>{const section=document.querySelector('.review-generation');const target=section?.querySelector('button')||section;if(target){target.setAttribute('tabindex','-1');target.focus({preventScroll:true});target.scrollIntoView({block:'center'});}}} />}
2168
2175
  </section>
2169
2176
  {mode==='review'&&session?.engineeringEnabled&&<section className="review-generation" aria-label="Generation and verification"><EngineeringPanel key={project.id} project={project} onBusyChange={setGenerationPending} onWorkModelChange={changeArchitectureWork} onIntentContext={syncEngineeringIntent} projectId={project.id} session={session} connection={connection} contextBusy={busy||agentBusy||!!pending} saveProject={()=>saveEngineeringProject(project.id)} buildRequest={buildRequest} onBuildRequestHandled={id=>setBuildRequest(current=>current?.id===id?null:current)} onConnections={()=>setModal('connections')}/></section>}
2170
2177
  {mode==='review'&&!session?.engineeringEnabled&&<p>Generation and local checks require the engineering service. Your design is retained; Delivery can package the design and remaining tasks.</p>}
2171
- {mode==='review'&&<ProjectChecks receipt={checkReceipt} project={project} environment={environment} session={session} onReceipt={setCheckReceipt} onBusy={setChecking}/>}
2172
- {mode==='review'&&<footer className="review-finish"><div><strong>3. Package and download</strong><p>Explore all files and download one project ZIP in Delivery.</p></div><button className="btn primary" onClick={()=>{const next=acknowledgeStage(projectRef.current,'review');updateEngagement(costs.complete?{...next,extensions:{...next.extensions,journey:{...next.extensions.journey,estimate:journey(next).keys.estimate}}}:next);visitJourney('delivery');}}>Complete →</button></footer>}
2178
+ {mode==='review'&&<ProjectChecks generationPending={generationPending} receipt={checkReceipt} project={project} environment={environment} session={session} onReceipt={setCheckReceipt} onBusy={setChecking}/>}
2179
+ {mode==='review'&&<footer className="review-finish"><div><strong>3. Package and download</strong><p>Explore all files and download one project ZIP in Delivery.</p></div><button className="btn primary" onClick={()=>{const next=acknowledgeStage(projectRef.current,'review');updateEngagement(costs.expected!==null&&!costs.mixedCurrencies?{...next,extensions:{...next.extensions,journey:{...next.extensions.journey,estimate:journey(next).keys.estimate}}}:next);visitJourney('delivery');}}>Complete →</button></footer>}
2173
2180
  </div>
2174
2181
 
2175
2182
 
@@ -2177,7 +2184,7 @@ function App() {
2177
2184
 
2178
2185
  {onBoard&&emulationOpen&&<div className="emulation-dock"><EmulationPanel project={project} session={session} emulation={emulation} onDiscuss={discussEmulation} onOpenComponent={id=>{reviewBlock(id);setEmulationOpen(false);}} discussionBusy={busy||agentBusy||!!pending} onClose={()=>setEmulationOpen(false)} onBuild={session?.engineeringEnabled?()=>{setEmulationOpen(false);buildCurrentDesign('test');}:undefined}/></div>}
2179
2186
 
2180
- {mode === "design" && designView === "outputs" && <OutputsView onNavigate={target=>{const [stage,selector]={requirements:['scope','.requirements-document'],location:['cloud','section[aria-label="Country and region"]'],cost:['review','.review-costs'],implementation:['review','.review-generation'],checks:['review','.project-checks']}[target];visitJourney(stage);requestAnimationFrame(()=>requestAnimationFrame(()=>{const element=document.querySelector(selector);if(element){element.setAttribute('tabindex','-1');element.focus({preventScroll:true});element.scrollIntoView({block:'start'});}}));}} checkReceipt={checkReceipt} onDownloaded={()=>updateEngagement(acknowledgeStage(projectRef.current,'delivery'))} onReview={()=>visitJourney('review')} onSave={expected=>saveEngineeringProject(project.id,expected)} project={project} run={run} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDownload={downloadBundle} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2187
+ {mode === "design" && designView === "outputs" && <OutputsView onNavigate={async target=>{const [stage,selector]={requirements:['scope','.requirements-document'],location:['cloud','section[aria-label="Country and region"]'],cost:['review','.review-costs'],implementation:['review','.review-generation'],checks:['review','.project-checks']}[target];visitJourney(stage);await new Promise((resolve,reject)=>{const started=performance.now();function focus(){const element=document.querySelector(selector);if(element&&element.getClientRects().length){element.setAttribute('tabindex','-1');element.focus({preventScroll:true});element.scrollIntoView({block:'start',behavior:'instant'});resolve();}else if(performance.now()-started<5000)requestAnimationFrame(focus);else reject(Error('Section is unavailable. Use the stage tabs to continue.'));}requestAnimationFrame(focus);});}} checkReceipt={checkReceipt} onDownloaded={()=>{updateEngagement(acknowledgeStage(projectRef.current,'delivery'));setStarOffer(value=>value+1);}} onReview={()=>visitJourney('review')} onSave={expected=>saveEngineeringProject(project.id,expected)} project={project} run={run} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDownload={downloadBundle} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2181
2188
  {mode === "design" && designView === "terraform" && <TerraformView project={project} environment={environment} session={session} emulation={emulation} onDiscuss={discussEmulation} discussionBusy={busy||agentBusy||!!pending} onDelivery={()=>visitJourney("delivery")} onBuild={session?.engineeringEnabled?()=>visitJourney('review'):undefined} onBack={()=>setDesignView("board")} />}
2182
2189
  <div className="choices-workspace" hidden={!(mode==="design"&&["options","decisions","location"].includes(designView))}>
2183
2190
  {mode === "design" && ["options","decisions"].includes(designView) && (
@@ -2324,7 +2331,7 @@ function App() {
2324
2331
  </button>
2325
2332
  ))}
2326
2333
  </div>
2327
- {(agentBusy||generationPending||['starting','running'].includes(engineeringStatus?.state)||pricingState.busy||checking)&&<div className="chat-working-status" role="status" aria-live="polite"><BrainActivity size={26}/><span>{agentBusy?architectAction:generationPending?'Starting project generation…':['starting','running'].includes(engineeringStatus?.state)?'Generating project files…':checking?'Running checks…':'Calculating costs…'}</span></div>}
2334
+ {(agentBusy||generationPending||['starting','running'].includes(engineeringStatus?.state)||pricingState.busy||checking)&&<div className="chat-working-status" role="status" aria-live="polite"><BrainActivity size={26}/><span>{agentBusy?architectAction:generationPending?'Generating project files…':['starting','running'].includes(engineeringStatus?.state)?'Generating project files…':checking?'Running checks…':'Calculating costs…'}</span></div>}
2328
2335
  {readingAttachments&&<p className="attachment-status" role="status"><BrainActivity/> Reading files…</p>}
2329
2336
  {attachmentError&&<p className="attachment-status" role="alert">{attachmentError}</p>}
2330
2337
  {!!attachments.length&&<div className="attachment-tray" aria-label="Attachments ready to send">{attachments.map(a=><div className="attachment-chip" key={a.id}>{a.data&&a.mime.startsWith('image/')?<img src={`data:${a.mime};base64,${a.data}`} alt=""/>:<Paperclip size={16}/>}<span>{a.name}<small>{!a.data?'Original missing · attach again':a.mime.startsWith('image/')?'Image':a.mime==='application/pdf'||a.mime.includes('wordprocessingml')?'Text will be extracted; embedded images omitted':'Document text'}</small></span><button className="icon" aria-label={`Remove ${a.name}`} onClick={()=>setAttachments(list=>list.filter(item=>item.id!==a.id))}><X size={14}/></button></div>)}<p>Sent to your selected architect with this message. Original files are not stored in project files.</p></div>}
@@ -2387,6 +2394,7 @@ function App() {
2387
2394
  {toast === "Preparing documents and diagram exports…"&&<BrainActivity/>}{toast}
2388
2395
  </div>
2389
2396
  )}
2397
+ <StarPrompt offer={starOffer}/>
2390
2398
  {modal && (
2391
2399
  <div className="backdrop" onMouseDown={(e) => e.target === e.currentTarget && !(modal === "welcome" && !connection) && setModal(null)}>
2392
2400
  <section className={`modal ${modal==="memory"?"memory-modal":""}`} role="dialog" aria-modal="true" aria-label={modal}>
@@ -1,13 +1,25 @@
1
1
  import {verifiedReceipt} from './validation-receipt.mjs';
2
2
  import {deliveryPack} from './delivery.mjs';
3
- export async function outputBundle(project,run,environment,checks){
3
+ export const VISUAL_PATHS=['spec-kit/implementation-spec.pdf','specification.pdf','diagram.svg','diagram.png','diagram.pdf'];
4
+ export async function outputBundle(project,run,environment,checks,{deferVisuals=false}={}){
4
5
  const snapshot=structuredClone(project);
5
6
  const files=deliveryPack(snapshot,run,environment);
6
- const {visualFiles}=await import('./visual-export.mjs');
7
- Object.assign(files,await visualFiles(snapshot,files['specification.md'],files['spec-kit/implementation-spec.md']));
8
7
  if(checks){const terraform=Object.fromEntries(Object.entries(files).filter(([name])=>name.startsWith('terraform/')).map(([name,value])=>[name.slice(10),value]));const receipt=await verifiedReceipt(terraform,checks);if(receipt)files['terraform/validation-receipt.json']=JSON.stringify(receipt,null,2);}
9
8
  const manifest=JSON.parse(files['manifest.json']);manifest.files=Object.keys(files);
10
9
  files['manifest.json']=JSON.stringify(manifest,null,2);
11
10
  const slug=snapshot.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'atflow';
12
- return {project,files,manifest,filename:`${slug}-r${snapshot.revision}-delivery.zip`};
11
+ const base={project,files,manifest,filename:`${slug}-r${snapshot.revision}-delivery.zip`,visualPaths:VISUAL_PATHS};
12
+ let pending;
13
+ async function prepare(){
14
+ if(!pending)pending=(async()=>{
15
+ const {visualFiles}=await import('./visual-export.mjs');
16
+ const fullFiles={...files,...await visualFiles(snapshot,files['specification.md'],files['spec-kit/implementation-spec.md'])};
17
+ const fullManifest={...manifest,files:Object.keys(fullFiles)};
18
+ fullFiles['manifest.json']=JSON.stringify(fullManifest,null,2);
19
+ const full={...base,files:fullFiles,manifest:fullManifest,visualPaths:[]};
20
+ return {...full,prepare:()=>Promise.resolve(full)};
21
+ })().catch(error=>{pending=null;throw error;});
22
+ return pending;
23
+ }
24
+ return deferVisuals?{...base,prepare}:prepare();
13
25
  }
package/src/pricing.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import {prefillAcceptedSettings} from './engineering-defaults.mjs';
1
2
  import { setPrice, usage } from './estimate.mjs';
2
3
  const patterns = {aws:/\bs3\b/i,azure:/blob|data lake|azure storage/i,gcp:/cloud storage|storage bucket/i,ibm:/object storage|\bcos\b/i};
3
4
  export const storageNodes = p => p.nodes.filter(n=>patterns[n.data.provider]?.test(`${n.data.label} ${n.data.service || ''}`));
4
- const sources = {'AWS public Price List':'https://pricing.us-east-1.amazonaws.com/','Azure Retail Prices API':'https://prices.azure.com/','Google Cloud Billing Catalog':'https://cloud.google.com/'};
5
+ const sources = {'AWS public Price List':'https://pricing.us-east-1.amazonaws.com/','Azure Retail Prices API':'https://prices.azure.com/','Google Cloud Billing Catalog':'https://cloud.google.com/','Google Cloud public pricing':'https://cloud.google.com/storage/pricing'};
5
6
  export function applyPriceQuotes(project, quotes) {
6
7
  let p=project;
7
8
  for(const quote of quotes||[]){
@@ -11,7 +12,7 @@ export function applyPriceQuotes(project, quotes) {
11
12
  if(n.data.provider!==quote.provider || n.data.config?.region!==quote.region || existing?.source && !sources[existing.source])continue;
12
13
  // Shared usage is decimal GB; preserve the conversion in the quote note.
13
14
  const factor=quote.unit==='GiB-month'?1e9/(1024**3):1;
14
- p=setPrice(p,n.id,{price:quote.price*factor,currency:quote.currency,source:quote.source,url:quote.url,date:quote.date,region:quote.region,service:quote.service,tierLimit:quote.tierLimit,coverageIncomplete:true,note:quote.coverage+(factor!==1?`; provider GiB converted from decimal GB using ${factor}`:'')});
15
+ p=setPrice(p,n.id,{price:quote.price*factor,currency:quote.currency,source:quote.source,url:quote.url,date:quote.date,region:quote.region,service:quote.service,tierLimit:quote.tierLimit,coverageIncomplete:true,sourceSha256:quote.sourceSha256,hourlyPrice:quote.hourlyPrice,hoursPerMonth:quote.hoursPerMonth,note:quote.coverage+(factor!==1?`; provider GiB converted from decimal GB using ${factor}`:'')});
15
16
  }
16
17
  }
17
18
  return p;
@@ -49,9 +50,10 @@ export function applyLambdaQuote(project,node,quote){
49
50
  return {...project,estimate:{...project.estimate,details:[...current.filter(line=>!line.id.startsWith(prefix)),...generated]}};
50
51
  }
51
52
  export async function fetchPlanningPrices(session,project){
53
+ project=prefillAcceptedSettings(project);
52
54
  const storage=await fetchStoragePrices(session,project);let next=applyPriceQuotes(project,storage.quotes);const errors=[...storage.errors];
53
55
  for(const node of lambdaNodes(project)){
54
- const region=node.data.config?.region;if(!region||!/^[a-z]{2}-(?:gov-)?[a-z]+-\d$/.test(region)){errors.push({component:node.data.label,reason:'Choose the AWS region in Choices to price this component.'});continue;}
56
+ const region=node.data.config?.region;if(!region||!/^[a-z]{2}-(?:gov-)?[a-z]+-\d$/.test(region)){errors.push({provider:'aws',action:'choose-region',reason:'Choose an AWS region in Choices.'});continue;}
55
57
  try{
56
58
  const architecture=node.data.config?.architecture==='arm64'?'arm64':'x86_64';
57
59
  const response=await fetch('/api/pricing/lambda',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({projectId:project.id,region,architecture}),signal:AbortSignal.timeout(20000)}),quote=await response.json();
@@ -63,7 +65,7 @@ export async function fetchPlanningPrices(session,project){
63
65
  const region=node.data.config?.region||'',service=/cloud run/i.test(`${node.data.service||''} ${node.data.label}`)?'cloud-run':'cloud-sql';
64
66
  try{const response=await fetch('/api/pricing/google',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${session.token}`},body:JSON.stringify({projectId:project.id,region,service,planningReference:!region||/select|unknown|tbd/i.test(region)}),signal:AbortSignal.timeout(20000)}),quote=await response.json();if(!response.ok)throw Error(quote.detail||'Google pricing could not be verified.');next=applyGoogleQuote(next,node,quote);}catch(error){errors.push({component:node.data.label,reason:error.message});}
65
67
  }
66
- return {project:next,errors};
68
+ return {project:next,errors:[...new Map(errors.map(error=>[`${error.provider||''}:${error.reason}:${error.action||''}`,error])).values()]};
67
69
  }
68
70
 
69
71
  export const googleNodes=p=>p.nodes.filter(n=>n.data.provider==='gcp'&&n.data.origin!=='existing'&&/cloud run|cloud sql/i.test(`${n.data.service||''} ${n.data.label}`));
@@ -10,7 +10,7 @@ export const FORMAT_VERSION = "2.3.0";
10
10
  export const APPROVAL_FORMAT_VERSION = "3.0.0";
11
11
  const approvalFormat=nodes=>Array.isArray(nodes)&&nodes.some(n=>n.data?.implementation==='approval-workflow'||n.data?.approvalProfile!=null);
12
12
  export const MAX_FILE_BYTES = 2_000_000;
13
- const GENERATOR = "atmem-ai-architect 0.2.0";
13
+ const GENERATOR = "atmem-ai-architect 0.2.1";
14
14
 
15
15
  // Section keys the 2.0 reader understands; anything else is kept in extensions.
16
16
  const KNOWN_TOP = ["format", "formatVersion", "meta", "design", "discovery", "requirements", "assumptions", "openQuestions", "decisions", "options", "estimate", "review", "extensions"];