agenttwin 0.1.0 → 0.2.0

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 (121) hide show
  1. package/README.md +85 -27
  2. package/dist/api.js +659 -0
  3. package/dist/cli.js +5165 -57
  4. package/dist/dashboard/assets/brand.css +2 -0
  5. package/dist/dashboard/assets/dashboard/dashboard.css +184 -0
  6. package/dist/dashboard/assets/dashboard/dashboard.js +421 -0
  7. package/dist/dashboard/assets/dashboard/hosted.js +45 -0
  8. package/dist/dashboard/assets/dashboard/inventory.json +1 -0
  9. package/dist/dashboard/assets/dashboard/runs.json +1 -0
  10. package/dist/dashboard/assets/fonts/Syne-OFL.txt +93 -0
  11. package/dist/dashboard/assets/fonts/inter-var.woff2 +0 -0
  12. package/dist/dashboard/assets/fonts/plexmono-400.woff2 +0 -0
  13. package/dist/dashboard/assets/fonts/plexmono-500.woff2 +0 -0
  14. package/dist/dashboard/assets/fonts/syne-400.ttf +0 -0
  15. package/dist/dashboard/assets/fonts/syne-500.ttf +0 -0
  16. package/dist/dashboard/assets/fonts/syne-600.ttf +0 -0
  17. package/dist/dashboard/assets/icons/ATTRIBUTION.md +7 -0
  18. package/dist/dashboard/assets/icons/attio.ico +0 -0
  19. package/dist/dashboard/assets/icons/aws.svg +1 -0
  20. package/dist/dashboard/assets/icons/bank.svg +5 -0
  21. package/dist/dashboard/assets/icons/box.svg +1 -0
  22. package/dist/dashboard/assets/icons/chat.svg +3 -0
  23. package/dist/dashboard/assets/icons/check.png +0 -0
  24. package/dist/dashboard/assets/icons/ci.svg +5 -0
  25. package/dist/dashboard/assets/icons/clay.png +0 -0
  26. package/dist/dashboard/assets/icons/cloudflare.svg +1 -0
  27. package/dist/dashboard/assets/icons/co-abridge.png +0 -0
  28. package/dist/dashboard/assets/icons/co-clay.png +0 -0
  29. package/dist/dashboard/assets/icons/co-cognition.png +0 -0
  30. package/dist/dashboard/assets/icons/co-intercom.svg +1 -0
  31. package/dist/dashboard/assets/icons/co-paloalto.png +0 -0
  32. package/dist/dashboard/assets/icons/co-pylon.png +0 -0
  33. package/dist/dashboard/assets/icons/co-ramp.png +0 -0
  34. package/dist/dashboard/assets/icons/co-sentry.svg +1 -0
  35. package/dist/dashboard/assets/icons/codes.svg +5 -0
  36. package/dist/dashboard/assets/icons/datadog.svg +1 -0
  37. package/dist/dashboard/assets/icons/discord.svg +1 -0
  38. package/dist/dashboard/assets/icons/docker.svg +1 -0
  39. package/dist/dashboard/assets/icons/documenso.png +0 -0
  40. package/dist/dashboard/assets/icons/dropbox.svg +1 -0
  41. package/dist/dashboard/assets/icons/edr.svg +4 -0
  42. package/dist/dashboard/assets/icons/ehr.svg +5 -0
  43. package/dist/dashboard/assets/icons/git.svg +1 -0
  44. package/dist/dashboard/assets/icons/github.svg +1 -0
  45. package/dist/dashboard/assets/icons/githubactions.svg +1 -0
  46. package/dist/dashboard/assets/icons/gitlab.svg +1 -0
  47. package/dist/dashboard/assets/icons/gmail.svg +7 -0
  48. package/dist/dashboard/assets/icons/google-calendar.svg +28 -0
  49. package/dist/dashboard/assets/icons/google.svg +1 -0
  50. package/dist/dashboard/assets/icons/googlecloud.svg +1 -0
  51. package/dist/dashboard/assets/icons/googledocs.svg +1 -0
  52. package/dist/dashboard/assets/icons/googledrive.svg +1 -0
  53. package/dist/dashboard/assets/icons/googlesheets.svg +1 -0
  54. package/dist/dashboard/assets/icons/hubspot.svg +1 -0
  55. package/dist/dashboard/assets/icons/intercom.svg +1 -0
  56. package/dist/dashboard/assets/icons/jira.svg +1 -0
  57. package/dist/dashboard/assets/icons/klaviyo.png +0 -0
  58. package/dist/dashboard/assets/icons/kubernetes.svg +1 -0
  59. package/dist/dashboard/assets/icons/linear.svg +1 -0
  60. package/dist/dashboard/assets/icons/linkedin.svg +1 -0
  61. package/dist/dashboard/assets/icons/mail.svg +4 -0
  62. package/dist/dashboard/assets/icons/mixpanel.svg +1 -0
  63. package/dist/dashboard/assets/icons/mongodb.svg +1 -0
  64. package/dist/dashboard/assets/icons/netsuite.png +0 -0
  65. package/dist/dashboard/assets/icons/nodedotjs.svg +1 -0
  66. package/dist/dashboard/assets/icons/notes.svg +5 -0
  67. package/dist/dashboard/assets/icons/notion.svg +1 -0
  68. package/dist/dashboard/assets/icons/npm.svg +1 -0
  69. package/dist/dashboard/assets/icons/okta.svg +1 -0
  70. package/dist/dashboard/assets/icons/openai.svg +1 -0
  71. package/dist/dashboard/assets/icons/outlook.ico +0 -0
  72. package/dist/dashboard/assets/icons/pagerduty.svg +1 -0
  73. package/dist/dashboard/assets/icons/policy.svg +5 -0
  74. package/dist/dashboard/assets/icons/postgres.svg +1 -0
  75. package/dist/dashboard/assets/icons/python.svg +1 -0
  76. package/dist/dashboard/assets/icons/quickbooks.svg +1 -0
  77. package/dist/dashboard/assets/icons/redis.svg +1 -0
  78. package/dist/dashboard/assets/icons/resend.svg +1 -0
  79. package/dist/dashboard/assets/icons/salesforce.svg +1 -0
  80. package/dist/dashboard/assets/icons/sentry.svg +1 -0
  81. package/dist/dashboard/assets/icons/shopify.svg +1 -0
  82. package/dist/dashboard/assets/icons/siem.svg +4 -0
  83. package/dist/dashboard/assets/icons/slack.svg +6 -0
  84. package/dist/dashboard/assets/icons/snowflake.svg +1 -0
  85. package/dist/dashboard/assets/icons/sparkle.svg +4 -0
  86. package/dist/dashboard/assets/icons/stripe.svg +1 -0
  87. package/dist/dashboard/assets/icons/supabase.svg +1 -0
  88. package/dist/dashboard/assets/icons/terraform.svg +1 -0
  89. package/dist/dashboard/assets/icons/trolley.ico +0 -0
  90. package/dist/dashboard/assets/icons/twilio.svg +1 -0
  91. package/dist/dashboard/assets/icons/ui/arrow-right.svg +1 -0
  92. package/dist/dashboard/assets/icons/ui/arrows-clockwise.svg +1 -0
  93. package/dist/dashboard/assets/icons/ui/arrows-left-right.svg +1 -0
  94. package/dist/dashboard/assets/icons/ui/caret-down.svg +1 -0
  95. package/dist/dashboard/assets/icons/ui/check.svg +1 -0
  96. package/dist/dashboard/assets/icons/ui/clock.svg +1 -0
  97. package/dist/dashboard/assets/icons/ui/cube.svg +1 -0
  98. package/dist/dashboard/assets/icons/ui/git-branch.svg +1 -0
  99. package/dist/dashboard/assets/icons/ui/github-logo.svg +1 -0
  100. package/dist/dashboard/assets/icons/ui/magnifying-glass.svg +1 -0
  101. package/dist/dashboard/assets/icons/ui/plugs-connected.svg +1 -0
  102. package/dist/dashboard/assets/icons/ui/seal-check.svg +1 -0
  103. package/dist/dashboard/assets/icons/ui/shield-check.svg +1 -0
  104. package/dist/dashboard/assets/icons/ui/stack-simple.svg +1 -0
  105. package/dist/dashboard/assets/icons/ui/terminal.svg +1 -0
  106. package/dist/dashboard/assets/icons/ui/warning-circle.svg +1 -0
  107. package/dist/dashboard/assets/icons/ui/x.svg +1 -0
  108. package/dist/dashboard/assets/icons/unified.png +0 -0
  109. package/dist/dashboard/assets/icons/unified.svg +23 -0
  110. package/dist/dashboard/assets/icons/unstructured.svg +3 -0
  111. package/dist/dashboard/assets/icons/vercel.svg +1 -0
  112. package/dist/dashboard/assets/icons/waterfall.jpg +0 -0
  113. package/dist/dashboard/assets/icons/xero.svg +1 -0
  114. package/dist/dashboard/assets/logo/favicon.svg +7 -0
  115. package/dist/dashboard/assets/logo/mark-stacked-worlds.svg +4 -0
  116. package/dist/dashboard/assets/logo/mark-twin-rings.svg +7 -0
  117. package/dist/dashboard/assets/logo/mark-worlds-overlap.svg +10 -0
  118. package/dist/dashboard/index.html +64 -0
  119. package/dist/gateway.js +666 -0
  120. package/dist/worker.js +341 -0
  121. package/package.json +8 -3
@@ -0,0 +1,421 @@
1
+ const $ = selector => document.querySelector(selector);
2
+ const escape = value => String(value ?? '—').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
3
+ const json = value => escape(JSON.stringify(value ?? null, null, 2));
4
+ const short = value => String(value ?? '').slice(0, 8);
5
+ const date = value => { const d = new Date(value); return Number.isNaN(d.getTime()) ? 'Date unavailable' : d.toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); };
6
+ let runs = [], selected = '', tab = 'overview';
7
+ let workspaceReady=false, baselineId='';
8
+ const imported = new Set();
9
+ const fetched = new Set();
10
+ const tabs = [['overview','Overview'],['state','World state'],['timeline','Event timeline'],['logs','Application logs'],['sandbox','Sandbox'],['compare','Compare runs'],['receipt','Receipt']];
11
+ function validate(r) {
12
+ if (!r || r.schema !== 'agenttwin/run-v1' || typeof r.runId !== 'string' || !/^[a-f0-9-]{36}$/.test(r.runId) || typeof r.name !== 'string' || !['passed','failed','error','incomplete'].includes(r.status) || !Array.isArray(r.assertions) || !Array.isArray(r.events) || !r.application || !Array.isArray(r.application.command) || !r.finalState || typeof r.finalState !== 'object') throw new Error('Choose an AgentTwin run.json receipt with schema agenttwin/run-v1.');
13
+ if (r.assertions.some(a => !a || typeof a.service !== 'string' || typeof a.passed !== 'boolean') || r.events.some(e => !e || typeof e !== 'object') || (r.infrastructureAssertions !== undefined && !Array.isArray(r.infrastructureAssertions))) throw new Error('The receipt contains invalid assertions or events.');
14
+ if (Object.values(r.finalState).some(paths=>!paths||typeof paths!=='object'||Array.isArray(paths)||Object.values(paths).some(rows=>!Array.isArray(rows))) || (r.infrastructureAssertions??[]).some(a=>!a||typeof a.service!=='string'||typeof a.passed!=='boolean')) throw new Error('The receipt contains invalid service state or infrastructure checks.');
15
+ return r;
16
+ }
17
+ function title(r) { if(r.dashboardSource?.startsWith('Prepared in dashboard'))return 'Dashboard-prepared workflow'; return r.name === 'sandbox_boundaries' ? 'Sandbox boundary check' : r.name === 'support_workflow' && r.infrastructureAssertions?.length ? 'Support workflow · database + cache' : r.name.replaceAll('_',' ').replace(/^./, c => c.toUpperCase()); }
18
+ function status(r) { return `<span class="status ${escape(r.status)}">${escape(r.status)}</span>`; }
19
+ function checks(r) { return [...r.assertions, ...(r.infrastructureAssertions ?? [])]; }
20
+ function facts(entries) { return `<dl class="facts">${entries.map(([label,value]) => `<div class="fact"><dt>${escape(label)}</dt><dd>${escape(value)}</dd></div>`).join('')}</dl>`; }
21
+ function renderList() {
22
+ const query = $('#search').value.toLowerCase();
23
+ const visible = runs.filter(r => `${r.name} ${title(r)} ${r.runId} ${r.status}`.toLowerCase().includes(query));
24
+ $('#run-count').textContent = runs.length;
25
+ $('#runs').innerHTML = visible.map(r => `<button class="run-row" data-run="${escape(r.runId)}" aria-pressed="${r.runId === selected}"><div class="row-top">${status(r)}</div><strong>${escape(title(r))}</strong><p class="sub">${escape(date(r.createdAt))}<br><span class="mono">${escape(short(r.runId))}</span> · ${fetched.has(r.runId) ? 'AgentTwin API receipt' : imported.has(r.runId) ? 'Imported receipt' : 'Recorded E2B run'}</p></button>`).join('') || '<p class="muted">'+(runs.length?'No matching results.':'Inspect a submitted run above or import run.json.')+'</p>';
26
+ const allChecks = runs.flatMap(checks);
27
+ $('#stats').innerHTML = [[runs.length,'Inspected results','Loaded receipts only'],[runs.filter(r=>r.status==='passed').length,'Passed','state verified'],[runs.filter(r=>r.status==='failed'||r.status==='error').length,'Failed','inspect below'],[allChecks.filter(a=>a.passed).length+'/'+allChecks.length,'Assertions passed','HTTP, database and cache']].map(([value,label,note])=>`<div class="stat"><p>${escape(label)}</p><strong>${escape(value)}</strong><small>${escape(note)}</small></div>`).join('');
28
+ }
29
+ function overview(r) {
30
+ const rows = r.assertions.map(a=>`<tr><td>${escape(a.service)}<small class="mono">${escape(a.path)}</small>${a.where ? `<small>Matching ${escape(JSON.stringify(a.where))}</small>` : ''}</td><td>${escape(a.count)}</td><td>${escape(a.actual)}</td><td class="${a.passed?'check-pass':'check-fail'}">${a.passed?'Pass':'Fail'}</td></tr>`).join('');
31
+ const infra = r.infrastructureAssertions ?? [];
32
+ return `<div class="section-title"><h3>Final-state assertions</h3><span>${checks(r).filter(a=>a.passed).length} of ${checks(r).length} passed</span></div><div class="table-scroll"><table><thead><tr><th>Service / state</th><th>Expected</th><th>Observed</th><th>Result</th></tr></thead><tbody>${rows}</tbody></table></div>${infra.length ? `<section class="section"><div class="section-title"><h3>Real infrastructure checks</h3><span>PostgreSQL / Redis</span></div>${infra.map(a=>`<details><summary>${escape(a.service)} · <span class="${a.passed?'check-pass':'check-fail'}">${a.passed?'Pass':'Fail'}</span></summary><p class="coverage mono">${escape(a.query ?? a.key)}</p><div class="state-pair"><div><h3>Expected</h3><pre>${json(a.expected)}</pre></div><div><h3>Observed</h3><pre>${json(a.actual)}</pre></div></div></details>`).join('')}</section>`:''}<section class="section"><h3>Fault injection</h3>${r.fault ? facts([['Condition',r.fault.type],['Target',r.fault.service+r.fault.path],['Response delay',r.fault.delayMs+' ms'],['Activated',r.fault.triggered?'Yes — write committed before delay':'No — requested fault did not activate']]) : '<p class="coverage">No fault configured for this run.</p>'}</section><section class="section"><h3>Supported behavior</h3><p class="coverage">${escape(r.coverage)}</p>${r.unsupported?.length ? `<pre>${json(r.unsupported)}</pre>` : '<p class="coverage">No unsupported adapter calls were recorded.</p>'}</section>`;
33
+ }
34
+ function state(r) {
35
+ const entries = Object.entries(r.finalState).flatMap(([service,paths]) => Object.entries(paths).map(([path,after])=>({service,path,after,before:r.initialState?.[service]?.[path] ?? []})));
36
+ return `<div class="section-title"><h3>Service state changes</h3><span>Seed → final state</span></div>${entries.map(e=>`<section class="section"><div class="section-title"><h3>${escape(e.service)} <span class="mono">${escape(e.path)}</span></h3><span>${Array.isArray(e.before)?e.before.length:'—'} → ${Array.isArray(e.after)?e.after.length:'—'} records</span></div><div class="state-pair"><div><h3>Before · seeded state</h3><pre>${json(e.before)}</pre></div><div><h3>After · observed state</h3><pre>${json(e.after)}</pre></div></div></section>`).join('')}<p class="coverage">Database and cache values appear in the infrastructure checks on Overview. Full database snapshots are not captured by this receipt.</p>`;
37
+ }
38
+ function timeline(r) { return `<div class="section-title"><h3>Observed service events</h3><span>${r.events.length} events · sequence order</span></div><ol class="timeline">${r.events.map(e=>`<li><span class="sequence">${escape(e.sequence)}</span><div><strong>${escape(e.operation)} · ${escape(e.service)} <span class="mono">${escape(e.path)}</span></strong><p>${escape(e.detail)}</p></div></li>`).join('')}</ol>${r.events.length ? '' : '<p class="muted">No service events recorded.</p>'}<p class="coverage">This is the adapter event sequence. It does not capture every application or database operation.</p>`; }
39
+ function logs(r) { return `<h3>Application execution</h3>${facts([['Command',r.application.command.join(' ')],['Exit code',r.application.exitCode],['Timed out',r.application.timedOut?'Yes':'No'],['Worker duration',Number.isFinite(r.durationMs)?r.durationMs+' ms':'Not recorded']])}<section class="section"><h3>Standard output</h3><pre>${escape(r.application.stdout || 'No output recorded.')}</pre><h3>Standard error</h3><pre>${escape(r.application.stderr || 'No errors recorded.')}</pre></section><p class="coverage">Application output is bounded in the worker. Generated runtime credentials are redacted by AgentTwin.</p>`; }
40
+ function sandbox(r) {
41
+ const isolation = r.isolation ?? {};
42
+ return `<h3>Execution environment</h3>${facts([['Provider',r.runtime ?? 'Not recorded'],['Sandbox ID',r.sandboxId ?? 'Not recorded'],['Template',r.image ?? 'Not recorded'],['Recorded template build',r.templateBuildId ?? 'Not recorded'],['Application user',isolation.user ?? 'Not recorded'],['Filesystem',isolation.rootFilesystem ?? 'Not recorded'],['Outbound network',isolation.network ?? 'Not recorded'],['Public traffic',isolation.publicTraffic === false?'Disabled':'Not verified'],['Production credentials inherited',isolation.productionCredentialsInherited === false?'No':'Not verified'],['API job ID',r.apiJobId ?? 'Direct execution']])}<section class="section"><h3>Uploaded source</h3><pre>${json(r.sourceDigests ?? {})}</pre><h3>World specification digest</h3><pre>${escape(r.specDigest)}</pre></section><p class="coverage">The receipt records the execution environment. It does not provide a live sandbox status, immutable build attestation or complete production parity.</p>`;
43
+ }
44
+ function renderDetail() {
45
+ const r = runs.find(r=>r.runId===selected);
46
+ if (!r) { $('#detail').innerHTML='<div class="empty"><h2>No run selected</h2><p>Inspect a submitted run above, or import a run.json receipt from the CLI.</p></div>';return; }
47
+ const failed = checks(r).filter(a=>!a.passed);
48
+ const headline = r.status==='passed' ? 'All configured state checks passed.' : failed.length ? `${failed.length} final-state ${failed.length===1?'assertion failed.':'assertions failed.'}` : `Run ${r.status}.`;
49
+ const reason = failed[0]?.path ? `Expected ${failed[0].count} record(s) in ${failed[0].service}${failed[0].path}; observed ${failed[0].actual}.` : r.status==='passed' ? 'The observed state matches the assertions declared for this workflow.' : 'Inspect application output, unsupported operations and fault activation.';
50
+ const body = {overview,state,timeline,logs,sandbox,compare,receipt: r=>`<h3>Original run receipt</h3><p class="coverage">Download or inspect the evidence used by this dashboard.</p><pre>${json(r)}</pre>`}[tab](r);
51
+ $('#detail').innerHTML=`<div class="detail-title"><div><h2>${escape(title(r))}</h2>${status(r)}</div><button id="download" type="button">Download receipt ↓</button></div><div class="metadata"><span class="mono">${escape(r.runId)}</span><span>${escape(date(r.createdAt))}</span><span>${fetched.has(r.runId)?'Fetched from AgentTwin API':imported.has(r.runId)?'Imported from your device':'Recorded hosted run'}</span></div><div class="result-banner ${r.status==='passed'?'passed':''}"><strong>${escape(headline)}</strong><p>${escape(reason)}</p></div><div class="tabs" role="tablist" aria-label="Run details">${tabs.map(([id,label])=>`<button role="tab" id="tab-${id}" aria-controls="run-panel" aria-selected="${tab===id}" tabindex="${tab===id?'0':'-1'}" data-tab="${id}">${label}</button>`).join('')}</div><div class="tab-panel" id="run-panel" role="tabpanel" aria-labelledby="tab-${tab}" tabindex="0">${body}</div>`;
52
+ }
53
+ function render(){renderList();renderDetail();renderWorkspace();}
54
+ async function load() {
55
+ $('#reload').disabled=true;
56
+ try {
57
+ if(location.protocol==='file:'&&$('#agenttwin-discovery')){runs=[];render();return;}
58
+ const response = await fetch('assets/dashboard/runs.json', {cache:'no-store',signal:AbortSignal.timeout(10000)});
59
+ if (!response.ok) throw new Error('Saved evidence is unavailable. Import a CLI run.json receipt.');
60
+ const data=await response.json();if (!Array.isArray(data.runs)) throw new Error('The evidence index is invalid.');
61
+ const saved=data.runs.map(validate);
62
+ runs=[...runs.filter(r=>imported.has(r.runId)),...saved.filter(r=>!imported.has(r.runId))];
63
+ if (!runs.some(r=>r.runId===selected)) selected=runs[0]?.runId ?? '';
64
+ $('#message').textContent='';render();
65
+ } catch(error){$('#message').textContent=error.message;render();}finally{$('#reload').disabled=false;}
66
+ }
67
+ $('#reload').addEventListener('click',load);
68
+ $('#search').addEventListener('input',renderList);
69
+ $('#runs').addEventListener('click',event=>{const button=event.target.closest('[data-run]');if(button){selected=button.dataset.run;render();}});
70
+ $('#detail').addEventListener('click',event=>{
71
+ const button=event.target.closest('[data-tab]');if(button){tab=button.dataset.tab;renderDetail();$('#tab-'+tab).focus();}
72
+ if(event.target.closest('#download')){const r=runs.find(r=>r.runId===selected);const url=URL.createObjectURL(new Blob([JSON.stringify(r,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`agenttwin-${r.runId}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
73
+ });
74
+ $('#detail').addEventListener('keydown',event=>{if(!event.target.matches('[role=tab]')||!['ArrowRight','ArrowLeft','Home','End'].includes(event.key))return;event.preventDefault();const i=tabs.findIndex(([id])=>id===tab);tab=tabs[event.key==='Home'?0:event.key==='End'?tabs.length-1:(i+(event.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length][0];renderDetail();$('#tab-'+tab).focus();});
75
+ $('#import').addEventListener('change',async event=>{
76
+ try{const files=[...event.target.files];if(files.length>20||files.reduce((n,f)=>n+f.size,0)>10*1024*1024)throw new Error('Import up to 20 receipts totaling no more than 10 MiB.');const incoming=[];for(const file of files)incoming.push(validate(JSON.parse(await file.text())));for(const r of incoming){runs=runs.filter(existing=>existing.runId!==r.runId);runs.unshift(r);imported.add(r.runId);}selected=incoming.at(-1)?.runId??selected;$('#search').value='';$('#message').textContent=`Imported ${incoming.length} receipt(s). Files stay in this browser session; nothing was uploaded.`;render();}catch(error){$('#message').textContent=error.message;}finally{event.target.value='';}
77
+ });
78
+ load();
79
+
80
+ // World authoring prepares supported fixtures and code; execution stays in the CLI/hosted runtime.
81
+ const chosen = new Set();
82
+ const runtimePreferences = new Map();
83
+ let fixtureEdited=false;
84
+ const services = [
85
+ ['crm','Custom HTTP','codes.svg'],['slack','Slack','slack.svg'],['postgres','PostgreSQL','postgres.svg'],['redis','Redis','redis.svg']
86
+ ];
87
+ function runtimeServiceFor(tool){
88
+ if(tool.category==='access'||tool.interface!=='api')return null;
89
+ const name=tool.title.trim().toLowerCase();
90
+ return ({slack:'slack',postgres:'postgres',postgresql:'postgres',redis:'redis','custom http':'crm'})[name]||null;
91
+ }
92
+ function renderRuntimeServices(){
93
+ const detected=new Set(environmentTools.map(runtimeServiceFor).filter(Boolean));
94
+ for(const [id] of services){if(!runtimePreferences.has(id)){if(detected.has(id))chosen.add(id);else chosen.delete(id);}}
95
+ $('#service-picker').innerHTML=services.map(([id,label,icon])=>`<button type="button" class="service-chip" data-service="${id}" aria-pressed="${chosen.has(id)}"><img src="assets/icons/${icon}" alt=""><span><strong>${label}</strong><small>${detected.has(id)?'In your environment':'Optional service'} · ${['postgres','redis'].includes(id)?'Real service':'Synthetic adapter'}</small></span><span aria-hidden="true">${chosen.has(id)?'✓':'+'}</span></button>`).join('');
96
+ $('#selected-count').textContent=chosen.size+(chosen.size===1?' service enabled':' services enabled');
97
+ const other=environmentTools.filter(tool=>!runtimeServiceFor(tool));
98
+ $('#planned-services').innerHTML=other.length?`<details><summary>Other environment resources <span>${other.length}</span></summary><ul>${other.map(tool=>`<li><img src="assets/icons/${resourceIcon(tool)}" alt=""><strong>${escape(tool.title)}</strong><small>${tool.category==='access'?'Configuration only':tool.title.toLowerCase()==='node.js'?'Node workflow runtime':tool.interface==='terminal'?'Tool setup required':tool.interface==='desktop'?'Desktop runtime required':tool.interface==='browser'?'Browser runtime required':'Provider adapter required'}</small></li>`).join('')}</ul></details>`:'<p class="setup-note">No other environment resources selected.</p>';
99
+ if(!fixtureEdited){const defaults={};if(chosen.has('crm'))defaults.tickets=[];if(chosen.has('slack')){defaults.channels=[{id:'C_TEST',name:'test'}];defaults.messages=[];}$('#seed-state').value=JSON.stringify(defaults,null,2);}
100
+ $('#fixture-help').textContent=chosen.has('crm')||chosen.has('slack')?'JSON arrays: '+[...(chosen.has('crm')?['tickets']:[]),...(chosen.has('slack')?['channels','messages']:[])].join(', ')+'. PostgreSQL seeds use schema.sql; Redis starts empty.':'No HTTP fixtures needed. PostgreSQL seeds use schema.sql; Redis starts empty.';
101
+ $('#seed-state').disabled=!chosen.has('crm')&&!chosen.has('slack');
102
+ $('#prepared-world').hidden=true;
103
+ }
104
+ $('#seed-state').addEventListener('input',()=>{fixtureEdited=true;$('#prepared-world').hidden=true;});
105
+ $('#world-form').addEventListener('input',()=>{if(!$('#prepared-world').hidden)$('#setup-message').textContent='Configuration changed. Prepare the run files again.';$('#prepared-world').hidden=true;});
106
+ function switchView(view){
107
+ if(view==='hosted')view='configure';
108
+ if(view==='worlds')view='environment';
109
+ if(view==='connectors'){view='environment';document.querySelector('.catalog-drawer').open=true;}
110
+ const names={environment:'Environment',configure:'Workflows',history:'Runs',settings:'Settings'};
111
+ if(!names[view])return;
112
+ $('#current-view').textContent=names[view];
113
+ for(const id of Object.keys(names))$('#'+id+'-view').hidden=id!==view;
114
+ document.querySelectorAll('[data-view]').forEach(button=>{const active=button.dataset.view===view;button.classList.toggle('active',active);if(active)button.setAttribute('aria-current','page');else button.removeAttribute('aria-current');});
115
+ if(view==='environment')layoutMap();window.scrollTo({top:0});
116
+ }
117
+ document.querySelector('.project-tabs').addEventListener('click',event=>{const button=event.target.closest('[data-view]');if(button)switchView(button.dataset.view);});
118
+ $('#service-picker').addEventListener('click',event=>{const button=event.target.closest('[data-service]');if(!button)return;const id=button.dataset.service;if(chosen.has(id))chosen.delete(id);else chosen.add(id);runtimePreferences.set(id,chosen.has(id));renderRuntimeServices();});
119
+ const downloadFile=(name,text)=>{const url=URL.createObjectURL(new Blob([text],{type:name.endsWith('.json')?'application/json':'text/plain'}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
120
+ let preparedFiles={};
121
+ $('#world-form').addEventListener('submit',event=>{
122
+ event.preventDefault();
123
+ try{
124
+ const name=$('#world-name').value;
125
+ if(!/^[a-z][a-z0-9_]{0,39}$/.test(name))throw new Error('Use a lowercase workflow name with letters, digits and underscores.');
126
+ if(!chosen.has('crm')&&!chosen.has('slack'))throw new Error('Enable Custom HTTP or Slack. This runtime requires at least one HTTP service; PostgreSQL and Redis can be added alongside it.');
127
+ const timeoutSeconds=Number($('#run-timeout').value);
128
+ if(!Number.isInteger(timeoutSeconds)||timeoutSeconds<1||timeoutSeconds>300)throw new Error('Execution limit must be an integer from 1 to 300 seconds.');
129
+ const fixtures=JSON.parse($('#seed-state').value);
130
+ if(!fixtures||typeof fixtures!=='object'||Array.isArray(fixtures)||Object.keys(fixtures).some(k=>!['tickets','channels','messages'].includes(k)))throw new Error('Starting state accepts tickets, channels and messages arrays.');
131
+ for(const key of [...(chosen.has('crm')?['tickets']:[]),...(chosen.has('slack')?['channels','messages']:[])])if(!Array.isArray(fixtures[key])||fixtures[key].length>1000||fixtures[key].some(r=>!r||typeof r!=='object'||Array.isArray(r)))throw new Error('Each fixture must be an array of up to 1,000 JSON records.');
132
+ const channel=fixtures.channels?.[0]?.id;
133
+ if(chosen.has('slack')&&(typeof channel!=='string'||!channel))throw new Error('Add a channel with a nonempty id before selecting Slack.');
134
+ const world={schema:'agenttwin/world-v1',name,files:['workflow.mjs'],command:['node','workflow.mjs'],timeoutSeconds,services:[],assertions:[]};
135
+ let source="// Editable Node workflow. Execute only with agenttwin run in your hosted sandbox.\nimport { execFileSync } from 'node:child_process';\n";
136
+ if(chosen.has('crm')){
137
+ world.services.push({name:'crm',adapter:'json-collection',collections:[{path:'/tickets',seed:fixtures.tickets}]});
138
+ world.assertions.push({service:'crm',path:'/tickets',count:fixtures.tickets.length+1});
139
+ world.fault={service:'crm',path:'/tickets',type:'timeout-after-write',delayMs:1000};
140
+ source+=`const body = JSON.stringify({ subject: 'New support request', customer: 'customer_demo' });
141
+ const headers = { authorization: 'Bearer ' + process.env.AGENTTWIN_CRM_TOKEN, 'content-type': 'application/json', 'idempotency-key': 'workflow-demo' };
142
+ let ticket;
143
+ try {
144
+ const response = await fetch(process.env.AGENTTWIN_CRM_URL + '/tickets', { method: 'POST', headers, body, signal: AbortSignal.timeout(200) });
145
+ if (!response.ok) throw new Error('Ticket request failed: ' + response.status);
146
+ ticket = await response.json();
147
+ } catch (error) {
148
+ if (!['TimeoutError', 'AbortError'].includes(error.name)) throw error;
149
+ const response = await fetch(process.env.AGENTTWIN_CRM_URL + '/tickets', { method: 'POST', headers, body });
150
+ if (!response.ok) throw new Error('Retry failed: ' + response.status);
151
+ ticket = await response.json();
152
+ }
153
+ `;
154
+ }
155
+ if(chosen.has('slack')){
156
+ world.services.push({name:'slack',adapter:'slack',collections:[{path:'/messages',seed:fixtures.messages},{path:'/channels',seed:fixtures.channels}]});
157
+ world.assertions.push({service:'slack',path:'/messages',count:fixtures.messages.length+1});
158
+ source+=`const notification = await fetch(process.env.AGENTTWIN_SLACK_URL + '/api/chat.postMessage', { method: 'POST', headers: { authorization: 'Bearer ' + process.env.AGENTTWIN_SLACK_TOKEN, 'content-type': 'application/json' }, body: JSON.stringify({channel: ${JSON.stringify(channel)}, text: ${chosen.has('crm')?"'Created ' + ticket.id":"'Workflow completed'"}}) });
159
+ const message = await notification.json();
160
+ if (!message.ok) throw new Error(message.error);
161
+ `;
162
+ }
163
+ preparedFiles={};
164
+ if(chosen.has('postgres')){
165
+ world.files.push('schema.sql');
166
+ preparedFiles['schema.sql']='CREATE TABLE workflow_runs (id text PRIMARY KEY, status text NOT NULL);\n';
167
+ world.postgres={seedFile:'schema.sql',assertions:[{query:'SELECT id, status FROM workflow_runs ORDER BY id',equals:[{id:'workflow-demo',status:'completed'}]}]};
168
+ source+=`execFileSync('psql', [process.env.DATABASE_URL, '-v', 'ON_ERROR_STOP=1', '-c', "INSERT INTO workflow_runs VALUES ('workflow-demo', 'completed')"], {stdio:'pipe'});\n`;
169
+ }
170
+ if(chosen.has('redis')){
171
+ world.redis={assertions:[{key:'workflow:demo',equals:'completed'}]};
172
+ source+="execFileSync('redis-cli', ['SET', 'workflow:demo', 'completed'], {env:process.env,stdio:'pipe'});\n";
173
+ }
174
+ source+="console.log('Workflow completed');\n";
175
+ preparedFiles['workflow.mjs']=source;
176
+ preparedFiles['agenttwin.world.json']=JSON.stringify(world,null,2)+'\n';
177
+ $('#prepared-world').hidden=false;
178
+ $('#prepared-world').innerHTML=`<h2>Run files prepared</h2><p>This is starter code for the enabled runtime services, not your discovered application workflow. Download the files into one folder and replace the starter actions with your own code. Execute in a fresh sandbox with your configured AgentTwin API or E2B account.</p><div class="downloads">${Object.keys(preparedFiles).map(name=>`<button type="button" data-file="${name}">Download ${name} ↓</button>`).join('')}</div><pre>npx agenttwin run --file agenttwin.world.json</pre>${Object.entries(preparedFiles).map(([filename,content])=>`<details><summary>Review ${filename}</summary><button type="button" data-copy="${filename}">Copy file</button><pre data-preview="${filename}">${escape(content)}</pre></details>`).join('')}<p>Preparing files does not launch a sandbox. The CLI provisions a fresh hosted world when you execute the command.</p>`;
179
+ $('#setup-message').classList.remove('error');$('#setup-message').textContent='Starter workflow and world configuration are ready below. No sandbox has been launched.';
180
+ }catch(error){$('#prepared-world').hidden=true;$('#setup-message').classList.add('error');$('#setup-message').textContent=error.message;}
181
+ });
182
+ $('#prepared-world').addEventListener('click',event=>{const button=event.target.closest('[data-file]');if(button)downloadFile(button.dataset.file,preparedFiles[button.dataset.file]);const copy=event.target.closest('[data-copy]');if(copy)navigator.clipboard.writeText(preparedFiles[copy.dataset.copy]).then(()=>{$('#setup-message').textContent='Copied '+copy.dataset.copy;}).catch(()=>{$('#setup-message').textContent='Select the file content below and copy it manually.';});});
183
+
184
+ let environmentTools=[], environmentWorkflows=[], inventoryImported=false;
185
+ let environmentProject='Your project';
186
+ function controlPath(tool){
187
+ const mode=tool.interface;
188
+ const title=tool.title.toLowerCase();
189
+ if(tool.category==='access')return ['Configuration','Connector configuration was detected. This does not establish application usage or verify account access.'];
190
+ if(mode==='desktop')return ['Desktop','Needs desktop automation and a compatible hosted OS. Native Apple apps require macOS; this E2B runtime is Linux. Desktop execution is not implemented.'];
191
+ if(mode==='browser')return ['Browser','No API is required to identify this app. Browser automation in the hosted runtime is planned, not implemented.'];
192
+ if(mode==='terminal'||tool.kind==='runtime')return ['Terminal','Discovered tooling is part of the environment. Bundled Node workflows can run in E2B; arbitrary command-line tool replication is not yet supported.'];
193
+ if(title==='slack')return ['API subset','Slack message/channel/history operations have a bounded stateful adapter. Map the actual operations before executing a workflow.'];
194
+ if(/postgres/.test(title))return ['Real service','PostgreSQL 15 is available in hosted worlds with synthetic seed SQL and state assertions. Discovery does not provision it automatically.'];
195
+ if(title==='redis')return ['Real service','Redis is available in hosted worlds with fresh credentials and string-key state assertions.'];
196
+ if(mode==='api')return ['API / connector','Define the required operations and state. A matching provider adapter is not implied; custom HTTP collections are available for explicit contracts.'];
197
+ return ['Needs review','Discovery identifies this dependency or available configuration. Review its interface, required operations and replication coverage before running it.'];
198
+ }
199
+ function renderEnvironment(){
200
+ const tools=environmentTools;
201
+ $('#resource-count').textContent=tools.length+(tools.length===1?' resource':' resources');
202
+ $('#topology-label').textContent=inventoryImported?'Imported discovery · selected resources':'Selected resources · no accounts connected';
203
+ $('#environment-project').innerHTML=`<span class="project-icon"><img src="assets/logo/mark-twin-rings.svg" alt="" width="26" height="26"></span><div><strong>${escape(environmentProject)}</strong><small>${inventoryImported?'Imported project inventory':'Selected environment'}</small></div>`;
204
+ renderMapResources();
205
+ renderRuntimeServices();
206
+ renderWorkspace();
207
+ $('#map-empty').hidden=tools.length>0;
208
+ $('#map-stage').hidden=!tools.length;
209
+ $('#inventory-context').textContent=tools.length?`${tools.length} resources in your environment. Review coverage before execution.`:'No software selected. Select software or import the inventory from your project.';
210
+ layoutMap();
211
+ saveEnvironment();
212
+ $('#environment-workflows').innerHTML=environmentWorkflows.length?environmentWorkflows.map(w=>`<article class="tool-entry"><header><strong>${escape(w.title??w.name??w.id)}</strong><span class="tool-route">Candidate</span></header><small>${escape(w.id)}</small><p>Review the invocation, required app interactions, seed state and assertions before configuring a run.</p></article>`).join(''):'<div class="workflow-empty"><img src="assets/icons/ui/git-branch.svg" alt=""><h3>No workflows yet</h3><p>Import project discovery to review candidate code paths.</p></div>';
213
+ if(!environmentTools.length)$('#terminal-output').innerHTML='<p>Waiting for your project inventory.</p><p>Select resources or import discovery to continue.</p>';
214
+ if(environmentTools.length)$('#terminal-output').innerHTML=`<p>${inventoryImported?'Imported discovery inventory.':'Added user declarations; no scan was performed.'}</p><p>${environmentTools.length} apps / tools · ${environmentWorkflows.length} workflow candidates</p><p>Next: review the environment, then configure supported code paths for a hosted run.</p>`;
215
+ }
216
+ $('#add-app-toggle').addEventListener('click',()=>{$('#add-app').hidden=!$('#add-app').hidden;if(!$('#add-app').hidden)$('#app-name').focus();});
217
+ $('#add-app').addEventListener('submit',event=>{event.preventDefault();const title=$('#app-name').value.trim();if(!title)return;environmentTools.push({title,interface:$('#app-interface').value,declared:true});renderEnvironment();$('#add-app').reset();$('#add-app').hidden=true;$('#environment-message').textContent='App added to this browser session. This declaration does not install, connect or replicate the app.';});
218
+ let discoverySignature='';
219
+ function applyDiscovery(inventory){
220
+ if(inventory.schema!=='company-inventory/v1'||!Array.isArray(inventory.tools)||inventory.tools.length>1000||inventory.tools.some(t=>!t||typeof t.title!=='string'))throw new Error('Choose discovery from agenttwin init.');
221
+ const signature=JSON.stringify(inventory);if(signature===discoverySignature)return;
222
+ discoverySignature=signature;
223
+ environmentProject=typeof inventory.name==='string'?inventory.name:'Your project';
224
+ const revision=`${environmentProject}:${inventory.generatedAt??signature}`;
225
+ if(discoveryRevision!==revision){dismissedDiscovery.clear();discoveryRevision=revision;}
226
+ const detected=inventory.tools.filter(t=>!dismissedDiscovery.has(t.id??t.title)).map(t=>({...t,declared:false,catalog:false}));
227
+ environmentTools=[...detected,...environmentTools.filter(t=>t.declared&&!detected.some(d=>d.title.toLowerCase()===t.title.toLowerCase()))];
228
+ environmentWorkflows=Array.isArray(inventory.workflows)?inventory.workflows.filter(w=>w&&typeof w.id==='string'):[];
229
+ inventoryImported=true;mapFocused=-1;renderEnvironment();renderConnectors();
230
+ $('#environment-message').textContent=`Discovery loaded for ${environmentProject}. ${detected.length} detected resources; no production accounts connected.`;
231
+ }
232
+ $('#inventory-import').addEventListener('change',async event=>{
233
+ try{const file=event.target.files[0];if(!file)return;if(file.size>10*1024*1024)throw new Error('Discovery JSON must be smaller than 10 MiB.');const data=JSON.parse(await file.text());applyDiscovery(data.inventory??data);}catch(error){$('#environment-message').textContent=error.message;}finally{event.target.value='';}
234
+ });
235
+ function loadDiscovery(){
236
+ // CLI-generated dashboards embed the user's own init inventory. Website previews
237
+ // must never silently load the repository that happens to serve the page.
238
+ const embedded=$('#agenttwin-discovery');
239
+ if(embedded)try{applyDiscovery(JSON.parse(embedded.textContent));}catch(error){$('#environment-message').textContent=error.message;}
240
+ }
241
+ $('#copy-init').addEventListener('click',()=>navigator.clipboard.writeText('npx agenttwin init').then(()=>{$('#copy-init').textContent='Copied';}).catch(()=>{$('#environment-message').textContent='Copy this command: npx agenttwin init';}));
242
+
243
+ // Catalog selections declare dependencies, without implying provider runtime support.
244
+ const connectorNames=['Google Workspace','HubSpot','Salesforce','Linear','Box','Jira','Unified','Unstructured','Waterfall','Twilio','Datadog','Discord','Dropbox','GitHub','GitLab','Google Calendar','Google Drive','Google Docs','Google Sheets','LinkedIn','NetSuite','Notion','Slack','Stripe','Gmail','Check','Documenso','QuickBooks','Xero','Resend','Trolley','Attio','Outlook','Intercom','Shopify','PagerDuty','Sentry','Mixpanel','Okta','Snowflake','Klaviyo','OpenAI','Supabase'];
245
+ const connectorIcons={'HubSpot':'hubspot.svg','Salesforce':'salesforce.svg','Linear':'linear.svg','Jira':'jira.svg','Twilio':'twilio.svg','Datadog':'datadog.svg','Discord':'discord.svg','Dropbox':'dropbox.svg','GitHub':'github.svg','GitLab':'gitlab.svg','Google Calendar':'google-calendar.svg','Google Drive':'googledrive.svg','Google Sheets':'googlesheets.svg','NetSuite':'netsuite.png','Notion':'notion.svg','Slack':'slack.svg','Stripe':'stripe.svg','Gmail':'gmail.svg','QuickBooks':'quickbooks.svg','Xero':'xero.svg','Resend':'resend.svg','Intercom':'intercom.svg','Shopify':'shopify.svg','PagerDuty':'pagerduty.svg','Sentry':'sentry.svg','Mixpanel':'mixpanel.svg','Okta':'okta.svg','Snowflake':'snowflake.svg','Klaviyo':'klaviyo.png','OpenAI':'openai.svg','Supabase':'supabase.svg'};
246
+ Object.assign(connectorIcons,{'AWS':'aws.svg','Google Cloud':'googlecloud.svg','Docker':'docker.svg','Git':'git.svg','GitHub Actions':'githubactions.svg','Node.js':'nodedotjs.svg','Python':'python.svg','Box':'box.svg','Google Workspace':'google.svg','Google Docs':'googledocs.svg','Unstructured':'unstructured.svg','Unified':'unified.png','Waterfall':'waterfall.jpg','Check':'check.png','Documenso':'documenso.png','LinkedIn':'linkedin.svg','Attio':'attio.ico','Trolley':'trolley.ico','Outlook':'outlook.ico','PostgreSQL':'postgres.svg','Redis':'redis.svg','MongoDB':'mongodb.svg','Kubernetes':'kubernetes.svg','Terraform':'terraform.svg','npm':'npm.svg','Vercel':'vercel.svg','Cloudflare':'cloudflare.svg'});
247
+ const resourceAliases={'aws profile':'AWS','docker context':'Docker','git workspace':'Git','github.com':'GitHub','node':'Node.js','nodejs':'Node.js','google-cloud':'Google Cloud','postgres':'PostgreSQL'};
248
+ function resourceIcon(tool){
249
+ const title=tool.title.trim().toLowerCase();
250
+ const name=resourceAliases[title]||Object.keys(connectorIcons).find(name=>name.toLowerCase()===title);
251
+ return connectorIcons[name]||({terminal:'ui/terminal.svg',browser:'ui/cube.svg',desktop:'ui/cube.svg',api:'ui/plugs-connected.svg'}[tool.interface]||'ui/plugs-connected.svg');
252
+ }
253
+ const selectedConnectors=new Set();
254
+ function syncConnectorSelection(){
255
+ environmentTools=environmentTools.filter(tool=>!tool.catalog);
256
+ for(const title of selectedConnectors)if(!environmentTools.some(tool=>tool.title.toLowerCase()===title.toLowerCase()))environmentTools.push({title,interface:'api',declared:true,catalog:true});
257
+ renderEnvironment();
258
+ }
259
+ function renderConnectors(){
260
+ const query=$('#connector-search').value.trim().toLowerCase();
261
+ const visible=connectorNames.filter(name=>name.toLowerCase().includes(query));
262
+ $('#connector-catalog').innerHTML=visible.map(name=>`<button type="button" class="connector-option" data-connector="${escape(name)}" aria-pressed="${selectedConnectors.has(name)||environmentTools.some(t=>!t.declared&&t.title.toLowerCase()===name.toLowerCase())}">${connectorIcons[name]?`<img src="assets/icons/${connectorIcons[name]}" alt="">`:`<span class="connector-initial" aria-hidden="true">${escape(name[0])}</span>`}<span><strong>${escape(name)}</strong><small>${name==='Slack'?'Slack API subset':'Adapter required'}</small></span><span class="connector-check" aria-hidden="true">${selectedConnectors.has(name)||environmentTools.some(t=>!t.declared&&t.title.toLowerCase()===name.toLowerCase())?'✓':'+'}</span></button>`).join('')||'<p class="muted">No connectors match your search.</p>';
263
+ $('#connector-status').textContent=`${environmentTools.filter(t=>connectorNames.some(name=>name.toLowerCase()===t.title.toLowerCase())).length} selected in your environment · ${visible.length} of ${connectorNames.length} shown`;
264
+ }
265
+ $('#connector-search').addEventListener('input',renderConnectors);
266
+ $('#connector-catalog').addEventListener('click',event=>{const button=event.target.closest('[data-connector]');if(!button)return;const name=button.dataset.connector;const detectedIndex=environmentTools.findIndex(t=>!t.declared&&t.title.toLowerCase()===name.toLowerCase());if(detectedIndex>=0){removeResource(detectedIndex);return;}if(selectedConnectors.has(name))selectedConnectors.delete(name);else selectedConnectors.add(name);syncConnectorSelection();renderConnectors();});
267
+ $('#select-connectors').addEventListener('click',()=>{connectorNames.forEach(name=>selectedConnectors.add(name));syncConnectorSelection();renderConnectors();});
268
+ $('#clear-connectors').addEventListener('click',()=>{selectedConnectors.clear();syncConnectorSelection();renderConnectors();});
269
+ $('#export-connectors').addEventListener('click',()=>{if(!selectedConnectors.size){$('#connector-status').textContent='Choose at least one connector to export.';return;}downloadFile('agenttwin.connector-requirements.json',JSON.stringify({schema:'agenttwin/connector-requirements-v1',connectors:[...selectedConnectors].map(title=>({title,interface:'api',identityPolicy:'separate-test-identity',runtimeSupport:title==='Slack'?'bounded-slack-subset':'adapter-required',requiredOperations:[]}))},null,2)+'\n');$('#connector-status').textContent='Exported dependency requirements. This file is not an executable world specification.';});
270
+ renderConnectors();
271
+
272
+
273
+ const mapPositions=new Map();
274
+ let mapCamera={x:0,y:0,zoom:1}, mapDrag=null, mapFocused=-1;
275
+ let visibleMapIndices=[];
276
+ const mapStorageKey='agenttwin.environment-layout.v2'+($('#agenttwin-discovery')?':'+location.pathname:'');
277
+ let discoveryRevision='',dismissedDiscovery=new Set();
278
+ function saveEnvironment(){try{localStorage.setItem(mapStorageKey,JSON.stringify({layoutVersion:3,discoveryRevision,dismissedDiscovery:[...dismissedDiscovery],tools:environmentTools.filter(t=>t.declared).map(({title,interface:mode,catalog})=>({title,interface:mode,declared:true,catalog:!!catalog})),selected:[...selectedConnectors],positions:[...mapPositions].filter(([key])=>key==='project'||environmentTools.some(t=>t.declared&&key===t.title.toLowerCase()+':'+t.interface)),camera:mapCamera}));}catch{/* Browser storage can be unavailable. */}}
279
+ function restoreEnvironment(){try{const data=JSON.parse(localStorage.getItem(mapStorageKey)||'null');if(!data)return;discoveryRevision=typeof data.discoveryRevision==='string'?data.discoveryRevision:'';if(Array.isArray(data.dismissedDiscovery))dismissedDiscovery=new Set(data.dismissedDiscovery.filter(value=>typeof value==='string'));if(Array.isArray(data.tools))environmentTools=data.tools.filter(t=>t&&typeof t.title==='string'&&['api','browser','desktop','terminal'].includes(t.interface)).slice(0,1000);if(Array.isArray(data.selected))data.selected.filter(name=>connectorNames.includes(name)).forEach(name=>selectedConnectors.add(name));if(data.layoutVersion===3&&Array.isArray(data.positions))for(const [key,point] of data.positions)if(typeof key==='string'&&Number.isFinite(point?.x)&&Number.isFinite(point?.y))mapPositions.set(key,point);if(data.layoutVersion===3&&Number.isFinite(data.camera?.x)&&Number.isFinite(data.camera?.y)&&Number.isFinite(data.camera?.zoom))mapCamera={x:data.camera.x,y:data.camera.y,zoom:Math.max(.1,Math.min(2,data.camera.zoom))};}catch{/* Ignore invalid saved UI state. */}}
280
+ function nodeKey(id){return id==='project'?'project':environmentTools[Number(id)]?.title.toLowerCase()+':'+environmentTools[Number(id)]?.interface;}
281
+ function mapColumns(){const width=$('#map-canvas').clientWidth||Math.max(230,$('#main').clientWidth-292);return Math.max(1,Math.min(4,Math.floor((width-32)/186)));}
282
+ function renderMapResources(){
283
+ visibleMapIndices=environmentTools.map((t,i)=>i);
284
+ $('#environment-tools').innerHTML=visibleMapIndices.map(index=>{const t=environmentTools[index],icon=resourceIcon(t),route=controlPath(t)[0],coverage=resourceCoverage(t);return `<article class="resource-card ${mapFocused===index?'map-selected':''}" data-node="${index}" tabindex="0" aria-label="Move ${escape(t.title)} card. Arrow keys move; Enter inspects coverage."><span class="resource-icon">${icon?`<img src="assets/icons/${icon}" alt="">`:`<img src="assets/icons/ui/plugs-connected.svg" alt="">`}</span><strong>${escape(t.title)}</strong><p>${escape(route)}</p><span class="resource-badge coverage-${coverage.type}">${escape(coverage.label)}</span></article>`;}).join('');
285
+ $('#map-page-label').textContent=environmentTools.length+' selected / imported resources';
286
+ $('#map-list-count').textContent=environmentTools.length;
287
+ renderMapList();
288
+ }
289
+ function renderMapList(){
290
+ const query=$('#map-resource-search').value.toLowerCase();
291
+ $('#map-resource-list').innerHTML=environmentTools.map((tool,index)=>({tool,index})).filter(({tool})=>tool.title.toLowerCase().includes(query)).map(({tool,index})=>`<div class="map-resource-row"><button type="button" data-map-resource="${index}" aria-pressed="${mapFocused===index}"><span>${escape(tool.title)}</span><small>${tool.declared?'Selected':tool.category==='access'?'Configured':'Detected'}</small></button><button type="button" class="remove-resource" data-remove-resource="${index}" aria-label="Remove ${escape(tool.title)}" title="Remove ${escape(tool.title)}"><img src="assets/icons/ui/x.svg" width="13" height="13" alt=""></button></div>`).join('')||'<p class="muted">No matching resources.</p>';
292
+ const tool=environmentTools[mapFocused];
293
+ $('#map-resource-detail').innerHTML=tool?`<strong>${escape(tool.title)}</strong><p>${escape(controlPath(tool)[1])}</p>`:'<p>Select a resource to inspect coverage.</p>';
294
+ }
295
+ function layoutMap(){
296
+ const columns=mapColumns(),width=columns*186-16;
297
+ if(mapPositions.get('project')?.columns!==columns)mapPositions.set('project',{x:Math.max(16,(width-300)/2+16),y:46,columns});
298
+ visibleMapIndices.forEach((index,slot)=>{const key=nodeKey(String(index));const point=mapPositions.get(key);if(!point||point.slot!==slot||point.columns!==columns)mapPositions.set(key,{x:16+(slot%columns)*186,y:136+Math.floor(slot/columns)*124,slot,columns});});
299
+ paintMap();
300
+ }
301
+ $('#map-resource-search').addEventListener('input',renderMapList);
302
+ function inspectMapResource(index){
303
+ mapFocused=index;mapCamera={x:0,y:0,zoom:1};renderMapResources();layoutMap();
304
+ const point=mapPositions.get(nodeKey(String(index))),canvas=$('#map-canvas');
305
+ if(point)canvas.scrollTo({left:Math.max(0,point.x-(canvas.clientWidth-170)/2),top:Math.max(0,point.y-(canvas.clientHeight-108)/2)});
306
+ saveEnvironment();
307
+ }
308
+ $('#map-resource-list').addEventListener('click',event=>{const remove=event.target.closest('[data-remove-resource]');if(remove){removeResource(Number(remove.dataset.removeResource));return;}const button=event.target.closest('[data-map-resource]');if(button)inspectMapResource(Number(button.dataset.mapResource));});
309
+ $('#map-canvas').addEventListener('keydown',event=>{if(event.key==='Enter'){const node=event.target.closest('.resource-card');if(node){event.preventDefault();inspectMapResource(Number(node.dataset.node));}}});
310
+ function paintMap(){
311
+ const stage=$('#map-stage');stage.style.transform=`translate(${mapCamera.x}px,${mapCamera.y}px) scale(${mapCamera.zoom})`;
312
+ const canvas=$('#map-canvas');canvas.style.backgroundSize=`${20*mapCamera.zoom}px ${20*mapCamera.zoom}px`;canvas.style.backgroundPosition=`${mapCamera.x}px ${mapCamera.y}px`;
313
+ $('#map-zoom-label').textContent=Math.round(mapCamera.zoom*100)+'%';
314
+ stage.querySelectorAll('[data-node]').forEach(node=>{const p=mapPositions.get(nodeKey(node.dataset.node));if(p){node.style.left=p.x+'px';node.style.top=p.y+'px';}});
315
+ const allPoints=['project',...visibleMapIndices.map(i=>nodeKey(String(i)))].map(key=>mapPositions.get(key)).filter(Boolean);
316
+ const space=$('#map-scroll-space');space.style.width=Math.max(canvas.clientWidth,...allPoints.map((p,i)=>p.x*mapCamera.zoom+(i===0?300:170)*mapCamera.zoom+32+Math.max(0,mapCamera.x)))+'px';space.style.height=Math.max(canvas.clientHeight,...allPoints.map((p,i)=>p.y*mapCamera.zoom+(i===0?65:108)*mapCamera.zoom+32+Math.max(0,mapCamera.y)))+'px';
317
+ const root=mapPositions.get('project');
318
+ const focused=mapPositions.get(nodeKey(String(mapFocused)));
319
+ $('#map-edges').innerHTML=root&&focused&&visibleMapIndices.includes(mapFocused)?`<path d="M ${root.x+150} ${root.y+65} L ${root.x+150} ${root.y+80} L ${focused.x+85} ${root.y+80} L ${focused.x+85} ${focused.y}"/>`:'';
320
+
321
+ }
322
+ function zoomMap(factor){const canvas=$('#map-canvas'),cx=canvas.clientWidth/2,cy=canvas.clientHeight/2;const next=Math.max(.1,Math.min(2,mapCamera.zoom*factor));mapCamera.x=cx-(cx-mapCamera.x)*next/mapCamera.zoom;mapCamera.y=cy-(cy-mapCamera.y)*next/mapCamera.zoom;mapCamera.zoom=next;paintMap();saveEnvironment();}
323
+ $('#map-zoom-in').addEventListener('click',()=>zoomMap(1.2));$('#map-zoom-out').addEventListener('click',()=>zoomMap(1/1.2));
324
+ $('#map-fit').addEventListener('click',()=>{mapCamera={x:0,y:0,zoom:1};paintMap();$('#map-canvas').scrollTo({top:0,left:0});saveEnvironment();});
325
+ $('#map-arrange').addEventListener('click',()=>{mapPositions.clear();mapCamera={x:0,y:0,zoom:1};renderMapResources();layoutMap();$('#map-canvas').scrollTo({top:0,left:0});saveEnvironment();});
326
+ function openSoftwarePicker(){switchView('environment');const catalog=document.querySelector('.catalog-drawer');catalog.open=true;catalog.scrollIntoView({behavior:'smooth',block:'start'});$('#connector-search').focus({preventScroll:true});}
327
+ $('#map-select-tools').addEventListener('click',openSoftwarePicker);
328
+ $('#select-software').addEventListener('click',openSoftwarePicker);
329
+ $('#map-canvas').addEventListener('pointerdown',event=>{if(event.button!==0||event.target.closest('button,summary,details'))return;const node=event.target.closest('[data-node]');mapDrag={pointer:event.pointerId,x:event.clientX,y:event.clientY,key:node?nodeKey(node.dataset.node):null,origin:node?{...mapPositions.get(nodeKey(node.dataset.node))}:{x:mapCamera.x,y:mapCamera.y}};$('#map-canvas').setPointerCapture(event.pointerId);$('#map-canvas').classList.add('dragging');event.preventDefault();});
330
+ $('#map-canvas').addEventListener('pointermove',event=>{if(!mapDrag||event.pointerId!==mapDrag.pointer)return;const dx=event.clientX-mapDrag.x,dy=event.clientY-mapDrag.y;if(mapDrag.key)mapPositions.set(mapDrag.key,{...mapDrag.origin,x:mapDrag.origin.x+dx/mapCamera.zoom,y:mapDrag.origin.y+dy/mapCamera.zoom});else{mapCamera.x=mapDrag.origin.x+dx;mapCamera.y=mapDrag.origin.y+dy;}paintMap();});
331
+ function endMapDrag(){if(!mapDrag)return;mapDrag=null;$('#map-canvas').classList.remove('dragging');saveEnvironment();}
332
+ $('#map-canvas').addEventListener('pointerup',endMapDrag);$('#map-canvas').addEventListener('pointercancel',endMapDrag);$('#map-canvas').addEventListener('lostpointercapture',endMapDrag);
333
+ $('#map-canvas').addEventListener('keydown',event=>{if(event.target.closest('summary,details,button')||!['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();const amount=event.shiftKey?50:15,dx=event.key==='ArrowLeft'?-amount:event.key==='ArrowRight'?amount:0,dy=event.key==='ArrowUp'?-amount:event.key==='ArrowDown'?amount:0;const node=event.target.closest('[data-node]');if(node){const key=nodeKey(node.dataset.node),p=mapPositions.get(key);mapPositions.set(key,{...p,x:p.x+dx,y:p.y+dy});}else{mapCamera.x+=dx;mapCamera.y+=dy;}paintMap();saveEnvironment();});
334
+
335
+ // The Connect page hosts the catalog inline; export lands in the drawer toolbar.
336
+
337
+ restoreEnvironment();
338
+ renderEnvironment();
339
+ renderConnectors();
340
+
341
+ $('#configure-run').addEventListener('click',()=>switchView('configure'));
342
+
343
+ let removedResource=null;
344
+ function removeResource(index){
345
+ const tool=environmentTools[index];if(!tool)return;
346
+ const selectedName=[...selectedConnectors].find(name=>name.toLowerCase()===tool.title.toLowerCase());
347
+ removedResource={tool,index,selectedName,point:mapPositions.get(nodeKey(String(index)))};
348
+ if(!tool.declared)dismissedDiscovery.add(tool.id??tool.title);
349
+ mapPositions.delete(nodeKey(String(index)));environmentTools.splice(index,1);
350
+ if(selectedName)selectedConnectors.delete(selectedName);
351
+ if(mapFocused===index)mapFocused=-1;else if(mapFocused>index)mapFocused--;
352
+ renderEnvironment();renderConnectors();
353
+ $('#removed-resource').hidden=false;$('#removed-resource-message').textContent=`Removed ${tool.title} from this environment.`;
354
+ }
355
+ $('#undo-remove-resource').addEventListener('click',()=>{
356
+ if(!removedResource)return;const {tool,index,selectedName,point}=removedResource;
357
+ if(!tool.declared)dismissedDiscovery.delete(tool.id??tool.title);
358
+ if(environmentTools.some(existing=>existing.title.toLowerCase()===tool.title.toLowerCase()&&existing.interface===tool.interface)){if(selectedName)selectedConnectors.add(selectedName);removedResource=null;$('#removed-resource').hidden=true;renderEnvironment();renderConnectors();return;}
359
+ const insertAt=Math.min(index,environmentTools.length);environmentTools.splice(insertAt,0,tool);
360
+ if(selectedName)selectedConnectors.add(selectedName);
361
+ if(point)mapPositions.set(nodeKey(String(insertAt)),point);
362
+ if(mapFocused>=insertAt)mapFocused++;
363
+ removedResource=null;$('#removed-resource').hidden=true;renderEnvironment();renderConnectors();
364
+ });
365
+
366
+ loadDiscovery();
367
+
368
+
369
+ // Workflow-centered workspace: discovery coverage and receipt evidence are independent.
370
+ function resourceCoverage(tool){
371
+ if(tool.category==='access')return {type:'config',label:'Configuration only'};
372
+ if(tool.interface==='desktop'||tool.interface==='browser')return {type:'gap',label:'Runtime required'};
373
+ if(runtimeServiceFor(tool))return {type:'supported',label:['postgres','redis'].includes(runtimeServiceFor(tool))?'Real service':'Synthetic API subset'};
374
+ if(tool.interface==='terminal'&&['node.js','node','nodejs'].includes(tool.title.toLowerCase()))return {type:'supported',label:'Node workflow runtime'};
375
+ return {type:'gap',label:tool.interface==='terminal'?'Tool setup required':'Adapter required'};
376
+ }
377
+ function renderWorkspace(){
378
+ if(!workspaceReady)return;
379
+ const classified=environmentTools.map(tool=>({tool,coverage:resourceCoverage(tool)}));
380
+ const supported=classified.filter(row=>row.coverage.type==='supported'),gaps=classified.filter(row=>row.coverage.type==='gap'),configs=classified.filter(row=>row.coverage.type==='config');
381
+ $('#environment-coverage').innerHTML=`<span><b>${environmentTools.length}</b> selected resources</span><span><b>${supported.length}</b> with runtime support</span><span><b>${gaps.length}</b> require an adapter or runtime</span><span><b>${configs.length}</b> configuration only</span>`;
382
+ $('#workflow-workspace').innerHTML=`<section class="workflow-register"><div class="section-title"><h2>Discovered candidates</h2><span>${environmentWorkflows.length} found</span></div>${environmentWorkflows.length?environmentWorkflows.map(w=>`<article class="candidate-row"><div><h3>${escape(w.title??w.name??w.id)}</h3><p class="mono">${escape(w.id)}</p><p class="muted">${w.supported?'Supported dependencies detected.':'Dependency coverage needs review.'} Review the entrypoint, operations and expected state before executing.</p></div><button type="button" data-prepare-candidate="${escape(w.id)}">Review & bundle code →</button></article>`).join(''):'<div class="workspace-empty"><h3>No candidate workflows discovered</h3><p>Import init output to see candidate code paths. You can also prepare an explicit workflow from your own code.</p><button type="button" data-action="environment">Review discovery →</button></div>'}</section>`;
383
+ }
384
+ $('#main').addEventListener('click',event=>{
385
+ const runButton=event.target.closest('[data-open-run]');if(runButton){selected=runButton.dataset.openRun;tab='overview';render();switchView('history');return;}
386
+ const candidateButton=event.target.closest('[data-prepare-candidate]');if(candidateButton){
387
+ const candidate=environmentWorkflows.find(w=>w.id===candidateButton.dataset.prepareCandidate);if(!candidate)return;switchView('configure');
388
+ const toolIds=[...new Set([...(candidate.tools??[]),...(candidate.steps??[]).map(s=>s.tool)])];
389
+ const mappings=toolIds.filter(id=>environmentTools.find(t=>t.id===id)?.category==='dependency').map(id=>({toolId:id,service:'CHOOSE_SERVICE',contract:'CHOOSE_SUPPORTED_CONTRACT',operations:(candidate.steps??[]).filter(s=>s.tool===id).flatMap(s=>s.operations??[]),reviewed:false}));
390
+ const preparation={schema:'agenttwin/preparation-v1',workflowId:candidate.id,world:{schema:'agenttwin/world-v1',name:'my_workflow',files:['YOUR_BUILT_ENTRYPOINT.mjs'],command:['node','YOUR_BUILT_ENTRYPOINT.mjs'],timeoutSeconds:20,services:[],assertions:[]},mappings};
391
+ let panel=document.getElementById('candidate-preparation');if(!panel){panel=document.createElement('section');panel.id='candidate-preparation';panel.className='workflow-register';$('#workflow-workspace').append(panel);}
392
+ panel.innerHTML=`<h2>Bundle ${escape(candidate.title??candidate.id)}</h2><p>Set the actual Node entrypoint and include its application files. Map every dependency to a supported contract, define fixtures and outcome assertions, then mark each mapping reviewed. Vendor SDK operations require a matching adapter; custom HTTP collections do not reproduce arbitrary vendor APIs.</p><pre>${escape(JSON.stringify(preparation,null,2))}</pre><button type="button" id="download-preparation">Download preparation.json ↓</button><pre>agenttwin prepare --file preparation.json</pre><p>Run this command in the project where init generated the inventory. Files are resolved relative to preparation.json. Upload the resulting agenttwin.bundle.json in Execute your workflow below.</p><button type="button" data-action="hosted">Upload workflow bundle →</button>`;
393
+ $('#download-preparation').onclick=()=>downloadFile('preparation.json',JSON.stringify(preparation,null,2)+'\n');panel.scrollIntoView({behavior:'smooth',block:'start'});return;
394
+ }
395
+ const action=event.target.closest('[data-action]')?.dataset.action;
396
+ if(action==='configure')switchView('configure');
397
+ else if(action)switchView(action);
398
+ });
399
+ function comparisonData(current,baseline){
400
+ const assertions=run=>new Map(checks(run).map(check=>[JSON.stringify([check.service,check.path??null,check.where??null,check.query??null,check.key??null]),check]));
401
+ const before=assertions(baseline),after=assertions(current);
402
+ const assertionChanges=[...new Set([...before.keys(),...after.keys()])].map(key=>({before:before.get(key)??null,after:after.get(key)??null}));
403
+ const collections=run=>new Map(Object.entries(run.finalState).flatMap(([service,paths])=>Object.entries(paths).map(([path,records])=>[service+path,records])));
404
+ const oldState=collections(baseline),newState=collections(current);
405
+ const stateChanges=[...new Set([...oldState.keys(),...newState.keys()])].map(path=>({path,before:oldState.get(path)??null,after:newState.get(path)??null,changed:JSON.stringify(oldState.get(path))!==JSON.stringify(newState.get(path))}));
406
+ const warnings=[];if(!current.specDigest||!baseline.specDigest||!current.sourceDigests||!baseline.sourceDigests)warnings.push('Source/spec metadata is incomplete.');if(current.name!==baseline.name)warnings.push('Workflow names differ.');if(current.specDigest!==baseline.specDigest)warnings.push('World specification digests differ.');if(JSON.stringify(current.sourceDigests)!==JSON.stringify(baseline.sourceDigests))warnings.push('Uploaded source digests differ.');if(JSON.stringify(current.initialState)!==JSON.stringify(baseline.initialState))warnings.push('Starting state differs.');if(current.image!==baseline.image||current.templateBuildId!==baseline.templateBuildId)warnings.push('Runtime templates or recorded builds differ.');if(JSON.stringify(current.fault)!==JSON.stringify(baseline.fault))warnings.push('Fault configuration or activation differs.');
407
+ return {assertionChanges,stateChanges,warnings};
408
+ }
409
+ function compare(current){
410
+ const candidates=runs.filter(run=>run.runId!==current.runId);
411
+ if(!candidates.length)return '<div class="workspace-empty"><h3>A second receipt is needed</h3><p>Import another run.json to compare checks and final service state. No replay is performed.</p></div>';
412
+ const baseline=candidates.find(run=>run.runId===baselineId)||candidates.find(run=>run.name===current.name)||candidates[0];baselineId=baseline.runId;
413
+ const diff=comparisonData(current,baseline);
414
+ return `<div class="section-title"><h3>Compare recorded executions</h3><button type="button" id="export-comparison">Export evidence bundle ↓</button></div><label class="baseline-label">Baseline run<select id="comparison-baseline">${candidates.map(run=>`<option value="${escape(run.runId)}" ${run.runId===baselineId?'selected':''}>${escape(run.name)} · ${escape(short(run.runId))} · ${escape(run.status)}</option>`).join('')}</select></label><p class="coverage">Baseline → selected run. Receipt differences do not prove causality or deterministic replay.</p>${diff.warnings.length?`<p class="comparison-warning">${escape(diff.warnings.join(' '))}</p>`:''}<div class="table-scroll"><table><thead><tr><th>Check</th><th>Baseline</th><th>Selected run</th></tr></thead><tbody>${diff.assertionChanges.map(row=>{const check=row.after??row.before;const value=c=>c?`${c.passed?'Pass':'Fail'} · ${escape(JSON.stringify(c.actual??null))}`:'Not checked';return `<tr><td>${escape(check.service)}<small>${escape(check.path??check.query??check.key??'')}</small>${check.where?`<small>Matching ${escape(JSON.stringify(check.where))}</small>`:''}</td><td>${value(row.before)}</td><td>${value(row.after)}</td></tr>`;}).join('')}</tbody></table></div><section class="section"><h3>Final collection changes</h3>${diff.stateChanges.filter(row=>row.changed).map(row=>`<details><summary>${escape(row.path)} · ${row.before?.length??'absent'} → ${row.after?.length??'absent'} records</summary><div class="state-pair"><div><h3>Baseline</h3><pre>${json(row.before)}</pre></div><div><h3>Selected run</h3><pre>${json(row.after)}</pre></div></div></details>`).join('')||'<p class="coverage">No final collection differences in these receipts.</p>'}<p class="coverage">Database and Redis results appear in the check comparison. Full infrastructure snapshots are not recorded.</p></section>`;
415
+ }
416
+ $('#detail').addEventListener('change',event=>{if(event.target.id==='comparison-baseline'){baselineId=event.target.value;renderDetail();}});
417
+ $('#detail').addEventListener('click',event=>{if(!event.target.closest('#export-comparison'))return;const current=runs.find(run=>run.runId===selected),baseline=runs.find(run=>run.runId===baselineId);if(current&&baseline)downloadFile('agenttwin.evidence-bundle.json',JSON.stringify({schema:'agenttwin/evidence-comparison-v1',current,baseline,differences:comparisonData(current,baseline),replayable:false},null,2)+'\n');});
418
+ workspaceReady=true;
419
+ renderWorkspace();
420
+
421
+ window.agentTwinDashboard={openHosted:()=>switchView('hosted'),importReceipt:receipt=>{const validated=validate(receipt);runs=runs.filter(run=>run.runId!==validated.runId);runs.unshift(validated);imported.add(validated.runId);fetched.add(validated.runId);selected=validated.runId;tab='overview';render();switchView('history');}};
@@ -0,0 +1,45 @@
1
+ (() => {
2
+ const el=id=>document.getElementById(id);
3
+ const safe=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4
+ let connection=null,bundle=null,jobs=[],loading=false,submitting=false;
5
+ const submissions=new Map();
6
+ const message=text=>{el('api-message').textContent=text;};
7
+ const digest=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),v=>v.toString(16).padStart(2,'0')).join('');
8
+ const textDigest=value=>digest(new TextEncoder().encode(value));
9
+ function state(){el('workflow-connection').innerHTML=connection?'Account connected · execution uses a fresh hosted sandbox.':'Connect your account before executing. <button type="button" data-action="settings">Open settings →</button>'; el('api-disconnect').disabled=!connection;el('refresh-api-jobs').disabled=!connection||loading;el('submit-hosted-run').disabled=!connection||!bundle||!el('bundle-consent').checked||submitting;}
10
+ async function request(path,options={}){
11
+ const current=connection;if(!current)throw new Error('Connect to the API first.');
12
+ const response=await fetch(current.base+path,{...options,headers:{authorization:'Bearer '+current.token,'content-type':'application/json'},redirect:'error',signal:AbortSignal.timeout(20000)});
13
+ if(connection!==current)throw new Error('Connection changed.');
14
+ const data=await response.json();if(!response.ok)throw new Error(data.error||'API request failed: '+response.status);return data;
15
+ }
16
+ function renderJobs(){el('api-jobs').innerHTML=jobs.length?jobs.map(job=>`<article class="candidate-row"><div><h3>${safe(job.id)}</h3><p class="muted">${safe(job.status)} · ${safe(job.createdAt)}</p></div><div class="actions">${['queued','running','cancelling'].includes(job.status)?`<button type="button" data-cancel-job="${safe(job.id)}" ${job.status==='cancelling'?'disabled':''}>${job.status==='cancelling'?'Cancelling…':'Cancel run'}</button>`:''}${job.status==='completed'?`<button type="button" data-inspect-job="${safe(job.id)}">Inspect run ↗</button>`:''}${job.status==='error'||job.status==='cancelled'?`<button type="button" data-inspect-job="${safe(job.id)}">View details</button>`:''}</div></article>`).join(''):'<p class="coverage">No jobs have been submitted by this account.</p>';}
17
+ async function refresh(){if(!connection||loading)return;loading=true;state();try{const data=await request('/v1/runs');if(!Array.isArray(data.jobs)||data.jobs.some(job=>!/^[a-f0-9-]{36}$/.test(job.id)||!['queued','running','cancelling','cancelled','completed','error'].includes(job.status)))throw new Error('Invalid API job list.');jobs=data.jobs;renderJobs();}catch(error){message('Could not load jobs: '+error.message);}finally{loading=false;state();}}
18
+ el('api-connect').addEventListener('submit',async event=>{event.preventDefault();try{
19
+ const url=new URL(el('api-url').value.trim());if(url.username||url.password||url.search||url.hash||(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))throw new Error('Use HTTPS; loopback HTTP is allowed for development.');
20
+ const token=el('api-token').value.trim();if(!/^at_user_[a-f0-9]{64}$/.test(token))throw new Error('Use an individual at_user_ account token.');
21
+ connection={base:url.href.replace(/\/$/,''),token};el('api-token').value='';jobs=[];renderJobs();state();const data=await request('/v1/runs');if(!Array.isArray(data.jobs))throw new Error('This API does not support dashboard job listing.');jobs=data.jobs;renderJobs();message('Connected. You can execute a bundle in Workflows or inspect jobs in Runs.');state();
22
+ }catch(error){connection=null;jobs=[];renderJobs();message('Connection failed: '+error.message);state();}});
23
+ el('api-disconnect').addEventListener('click',()=>{connection=null;jobs=[];renderJobs();submissions.clear();message('Disconnected. Account token cleared from memory.');state();});
24
+ el('bundle-import').addEventListener('change',async event=>{bundle=null;el('bundle-consent').checked=false;state();try{
25
+ const file=event.target.files[0];if(!file)return;if(file.size>28*1024*1024)throw new Error('Bundle exceeds 28 MiB.');const data=JSON.parse(await file.text());
26
+ if(data.schema!=='agenttwin/upload-v1'||data.spec?.schema!=='agenttwin/world-v1'||!data.files||typeof data.files!=='object'||Array.isArray(data.files)||data.manifest?.schema!=='agenttwin/prepared-v1')throw new Error('Choose a bundle generated by agenttwin prepare.');
27
+ const paths=data.spec.files;if(!Array.isArray(paths)||paths.length<1||paths.length>100||new Set(paths).size!==paths.length||Object.keys(data.files).length!==paths.length)throw new Error('Bundle file list is invalid.');
28
+ if(data.manifest.workflowId!==data.spec.workflowId||!data.spec.workflowId||JSON.stringify(data.manifest.invocation)!==JSON.stringify(data.spec.command)||Object.keys(data.manifest.sourceDigests??{}).length!==paths.length)throw new Error('Workflow provenance does not match.');
29
+ if(await textDigest(JSON.stringify(data.spec))!==data.manifest.specDigest)throw new Error('World digest does not match the reviewed bundle.');
30
+ let total=0;for(const path of paths){if(typeof path!=='string'||!path||path.startsWith('/')||path.includes('\\')||path.includes('\0')||path.split('/').some(p=>!p||p==='.'||p==='..'||/^(\.env.*|\.git|\.ssh|\.aws|\.azure|\.config|\.npmrc|\.netrc|credentials.*)$/i.test(p))||/\.(pem|key|p12|pfx)$/i.test(path))throw new Error('Unsafe bundle file path.');const encoded=data.files[path];if(typeof encoded!=='string')throw new Error('Missing file content.');const raw=atob(encoded);if(btoa(raw)!==encoded)throw new Error('File encoding is invalid.');const bytes=Uint8Array.from(raw,c=>c.charCodeAt(0));total+=bytes.length;if(total>20*1024*1024)throw new Error('Application exceeds 20 MiB.');if(await digest(bytes)!==data.manifest.sourceDigests[path])throw new Error('Source digest mismatch: '+path);}
31
+ bundle=data;el('bundle-review').innerHTML=`<h3>${safe(data.spec.name)}</h3><p class="mono">${safe(data.spec.command.join(' '))}</p><p class="coverage">${paths.length} actual files · ${total.toLocaleString()} bytes · ${safe(data.spec.timeoutSeconds)} second execution limit</p><details><summary>Review explicit upload</summary><pre>${safe(JSON.stringify({files:paths,mappings:data.manifest.mappings,world:data.spec},null,2))}</pre></details>`;message('Bundle digests verified. Review files and fixtures before upload.');
32
+ }catch(error){el('bundle-review').textContent='Bundle rejected: '+error.message;message(error.message);}finally{event.target.value='';state();}});
33
+ el('bundle-consent').addEventListener('change',state);
34
+ el('submit-hosted-run').addEventListener('click',async()=>{if(!bundle||!connection||!el('bundle-consent').checked||submitting)return;submitting=true;state();const submitted=bundle;try{message('Submitting reviewed files. A fresh E2B sandbox will be created by the API.');const job=await request('/v1/runs',{method:'POST',body:JSON.stringify({spec:submitted.spec,files:submitted.files})});if(!/^[a-f0-9-]{36}$/.test(job.id))throw new Error('Invalid API job ID.');submissions.set(job.id,submitted.manifest);el('bundle-consent').checked=false;message('Run accepted: '+job.id+'. Job status refreshes while this page is visible.');await refresh();}catch(error){message('Submission could not be confirmed: '+error.message+'. Refresh your account jobs before retrying.');}finally{submitting=false;state();}});
35
+ el('refresh-api-jobs').addEventListener('click',refresh);
36
+ el('api-jobs').addEventListener('click',async event=>{const cancel=event.target.closest('[data-cancel-job]'),inspect=event.target.closest('[data-inspect-job]');try{
37
+ if(cancel){cancel.disabled=true;const data=await request('/v1/runs/'+cancel.dataset.cancelJob,{method:'DELETE'});message('Run '+data.status+'. Cancellation does not refund the submission quota.');await refresh();}
38
+ if(inspect){const job=await request('/v1/runs/'+inspect.dataset.inspectJob);if(job.receipt){if(job.receipt.apiJobId!==job.id)throw new Error('Receipt belongs to a different job.');const manifest=submissions.get(job.id);if(manifest&&(manifest.specDigest!==job.receipt.specDigest||JSON.stringify(Object.entries(manifest.sourceDigests).sort())!==JSON.stringify(Object.entries(job.receipt.sourceDigests??{}).sort())))throw new Error('Receipt does not match the submitted source/world.');window.agentTwinDashboard.importReceipt(job.receipt);}else message(job.error||'Run '+job.status+'.');}
39
+ }catch(error){message('API action failed: '+error.message);if(cancel)cancel.disabled=false;}});
40
+ setInterval(()=>{if(!document.hidden&&connection&&jobs.some(job=>['queued','running','cancelling'].includes(job.status)))void refresh();},3000);
41
+ const configuredApi=document.querySelector('meta[name="agenttwin-api-url"]')?.content;
42
+ if(configuredApi)el('api-url').value=configuredApi;
43
+ else if(location.protocol==='https:'&&location.pathname.startsWith('/dashboard/'))el('api-url').value=location.origin;
44
+ state();
45
+ })();
@@ -0,0 +1 @@
1
+ {"schema":"company-inventory/v1","name":"Your project","tools":[],"workflows":[]}
@@ -0,0 +1 @@
1
+ {"runs":[]}