@hecer/yoke 1.12.0 → 1.14.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.
- package/.claude-plugin/plugin.json +3 -3
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +42 -30
- package/canon/loop/prd.schema.md +2 -2
- package/canon/manifest.yaml +2 -2
- package/canon/skills/authoring-prd/SKILL.md +1 -1
- package/canon/skills/yoke-retrofit/SKILL.md +2 -2
- package/canon/skills/yoke-workflow/SKILL.md +1 -1
- package/canon/tools/graphify.md +1 -1
- package/canon/tools/playwright-mcp.md +1 -1
- package/canon/tools/serena.md +1 -1
- package/dist/agents/catalog.js +7 -0
- package/dist/agents/contracts.js +3 -1
- package/dist/agents/host.js +4 -0
- package/dist/agents/process-streams.js +62 -0
- package/dist/agents/process.js +43 -3
- package/dist/agents/providers.js +45 -3
- package/dist/agents/telemetry.js +99 -2
- package/dist/canon/manifest.js +2 -1
- package/dist/change/inbox.js +1 -1
- package/dist/cli.js +22 -24
- package/dist/dashboard/analytics.js +193 -29
- package/dist/dashboard/contracts.js +23 -0
- package/dist/dashboard/page.js +39 -94
- package/dist/dashboard/panels.js +87 -33
- package/dist/dashboard/server.js +190 -15
- package/dist/goals/command.js +3 -2
- package/dist/loop/claims.js +2 -1
- package/dist/loop/decision.js +3 -2
- package/dist/loop/parallel-command.js +4 -2
- package/dist/loop/prd.js +2 -1
- package/dist/loop/reporter.js +1 -0
- package/dist/loop/run-command.js +31 -10
- package/dist/observability/events.js +1 -1
- package/dist/observability/history.js +1 -1
- package/dist/prd/command.js +3 -3
- package/dist/quality/candidate-comparison.js +6 -1
- package/dist/quality/command.js +17 -2
- package/dist/quality/types.js +6 -1
- package/dist/retrofit/apply.js +87 -1
- package/dist/retrofit/config.js +8 -0
- package/dist/retrofit/detect.js +6 -0
- package/dist/retrofit/plan.js +6 -0
- package/dist/retrofit/planners/kilo.js +44 -0
- package/dist/retrofit/planners/opencode.js +44 -0
- package/dist/retrofit/planners/pi.js +24 -0
- package/dist/retrofit/skill-actions.js +3 -0
- package/dist/retrofit/tools.js +8 -0
- package/dist/review/command.js +3 -2
- package/dist/review/verdict.js +1 -1
- package/dist/routing/capability.js +2 -2
- package/dist/routing/planning.js +2 -0
- package/dist/routing/registry.js +3 -1
- package/dist/routing/router.js +7 -3
- package/dist/setup/command.js +13 -3
- package/docs/DASHBOARD-EVOLUTION.md +16 -2
- package/docs/DASHBOARD-OVERHAUL.md +146 -0
- package/docs/HARNESSES.md +81 -0
- package/gemini-extension.json +2 -2
- package/package.json +6 -2
package/dist/dashboard/panels.js
CHANGED
|
@@ -3,12 +3,64 @@ export function dashboardPanels() {
|
|
|
3
3
|
return String.raw `
|
|
4
4
|
function panel(title){return append(el('section',undefined,'panel'),el('h2',title))}
|
|
5
5
|
function table(headers,rows){const wrap=el('div',undefined,'table-scroll'),t=el('table'),head=el('thead'),hr=el('tr');for(const title of headers)hr.append(el('th',title));head.append(hr);t.append(head);const body=el('tbody');for(const values of rows){const r=el('tr');for(const value of values)r.append(el('td',String(value)));body.append(r)}t.append(body);wrap.append(t);return wrap}
|
|
6
|
-
function
|
|
6
|
+
function metric(label,value,note){return append(el('div',undefined,'metric'),el('span',label,'label'),el('strong',value),el('p',note))}
|
|
7
|
+
function safeMetricTotal(values){return values.every(Number.isFinite)?values.reduce((sum,value)=>sum+value,0):null}
|
|
8
|
+
function chartValues(buckets,key){return buckets.map(bucket=>{if(key==='tokens')return bucket.tokens?.total??safeMetricTotal([bucket.inputTokens,bucket.outputTokens]);if(key==='calls')return bucket.calls?.total??(Number.isFinite(bucket.measuredCalls)&&Number.isFinite(bucket.unknownCalls)?bucket.measuredCalls+bucket.unknownCalls:null);if(key==='duration')return bucket.time?.attemptDurationMs??bucket.attemptDurationMs??null;if(key==='outcomes')return bucket.outcomes?.accepted??bucket.accepted??null;return bucket.cost?.reportedUsd??bucket.reportedCostUsd??null})}
|
|
9
|
+
function dimensionLabel(value){if(value===undefined||value===null||value==='')return 'Not reported';if(String(value).toLowerCase()==='unknown')return 'Unknown';return String(value)}
|
|
10
|
+
function rankRows(rows,key='tokens'){return [...rows].sort((left,right)=>{const a=Number.isFinite(left[key])?left[key]:null,b=Number.isFinite(right[key])?right[key]:null;if(a===null&&b!==null)return 1;if(a!==null&&b===null)return -1;if(a!==b)return (b??0)-(a??0);return dimensionLabel(left.name).localeCompare(dimensionLabel(right.name))})}
|
|
11
|
+
function coverageMessages(data,project={}){const messages=[];if(project.unavailable||data?.projects?.some(value=>value.errors?.length&&!value.analytics))messages.push('Unavailable telemetry: this project could not be read.');if(data?.errors?.length)messages.push('Corrupt or partial history: '+data.errors.join(' · '));const total=data?.total||{};if(total.costState==='partial'||total.cost?.state==='partial'||total.unknownCalls>0||total.unmeasuredAttempts>0||total.incompleteCosts>0||total.cost?.incompleteCalls>0)messages.push('Partial telemetry: some calls or costs are not measured.');if(project.stale)messages.push('Stale telemetry: the latest active status is unconfirmed.');return messages}
|
|
12
|
+
let lastSuccessfulSync='',composeFeedbackState=null;
|
|
13
|
+
function liveFreshness(project,now=Date.now()){
|
|
14
|
+
const status=project.status||{},times=[status.updatedAt,...(status.supervision||[]).flatMap(value=>[value.heartbeatAt,value.lastProgressAt])].map(value=>Date.parse(value||'')).filter(Number.isFinite),latest=Math.max(...times);
|
|
15
|
+
if(!Number.isFinite(latest))return 'unknown';
|
|
16
|
+
if(now-latest<=20*60000)return 'fresh';
|
|
17
|
+
return ['running','active'].includes(status.state)?'unconfirmed':'stale'
|
|
18
|
+
}
|
|
19
|
+
function workerMetadata(worker){return [worker.provider?'Agent '+worker.provider:null,worker.selectedProvider&&worker.selectedProvider!==worker.provider?'Selected provider '+worker.selectedProvider:null,'Model '+(worker.selectedModel||worker.model||'provider default'),worker.selectedVariant?'Variant '+worker.selectedVariant:null,worker.role?'Role '+worker.role:null,'Phase '+(worker.phase||worker.lifecycle||'working')].filter(Boolean).join(' · ')}
|
|
20
|
+
function timelineEvents(events,limit=100){return [...events].sort((left,right)=>String(left.timestamp).localeCompare(String(right.timestamp))).slice(-limit)}
|
|
21
|
+
function composeFeedbackStatus(result){if(result?.status==='pending')return 'pending';if(['note-added','applied'].includes(result?.status))return 'applied';if(result?.status==='validation'||/invalid|validation|required|empty/iu.test(String(result?.error||'')))return 'validation';return 'failure'}
|
|
22
|
+
function localAndUtc(timestamp){const date=new Date(timestamp);return 'Local time: '+date.toLocaleString()+' · UTC: '+date.toISOString()}
|
|
23
|
+
function timelineDetail(title,value){const detail=el('details'),summary=el('summary',title);detail.append(summary,el('p',value||'Unknown'));return detail}
|
|
24
|
+
function timelineItem(event){
|
|
25
|
+
const item=el('details'),data=event.data||{},input=data.inputTokens??event.inputTokens,output=data.outputTokens??event.outputTokens,usage=data.usageAvailable===false||event.usageAvailable===false?'unavailable':input!==undefined||output!==undefined?'reported':'not recorded';
|
|
26
|
+
item.append(el('summary',event.type+' · '+localAndUtc(event.timestamp)),timelineDetail('Run details',event.runId),timelineDetail('Story details',event.storyId),timelineDetail('Phase details',event.phase),timelineDetail('Agent details',[event.agent,event.provider,event.model,event.variant,event.role].filter(Boolean).join(' · ')),timelineDetail('Token details','Input '+(input===undefined?'Unknown':input)+' · output '+(output===undefined?'Unknown':output)+' · '+usage));
|
|
27
|
+
return item
|
|
28
|
+
}
|
|
29
|
+
function feedbackText(state){return state.status+': '+state.message}
|
|
30
|
+
function feedbackNode(state){return el('p',feedbackText(state),'compose-feedback '+state.status)}
|
|
31
|
+
function renderTimeline(project){
|
|
32
|
+
const view=panel('Event timeline');view.append(el('p','Bounded to the latest 100 events in chronological order. Each event expands run, story, phase, agent, and token details.','muted'));
|
|
33
|
+
const events=timelineEvents(project.events||[]);if(!events.length)view.append(el('p','No recorded events.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}content.append(view)
|
|
34
|
+
}
|
|
35
|
+
function renderOperatorInput(project){
|
|
36
|
+
const view=panel('Operator input');if(composeFeedbackState)view.append(feedbackNode(composeFeedbackState));
|
|
37
|
+
const noteLabel=el('label','Add a note','field'),note=el('textarea');note.rows=3;note.placeholder='Record context for the next worker or handoff';note.setAttribute('aria-label','Operator note');noteLabel.append(note);view.append(noteLabel,button('Add note',()=>submitOperatorInput(project,'note',note.value,view)));
|
|
38
|
+
const changeLabel=el('label','Queue a change','field'),change=el('textarea');change.rows=3;change.placeholder='Describe a change for the pending inbox';change.setAttribute('aria-label','Change request');changeLabel.append(change);view.append(changeLabel,button('Queue change',()=>submitOperatorInput(project,'change',change.value,view)));content.append(view)
|
|
39
|
+
}
|
|
40
|
+
async function submitOperatorInput(project,kind,value,view){
|
|
41
|
+
const text=value.trim();if(!text){composeFeedbackState={status:'validation',message:kind==='note'?'Enter a note before adding it.':'Describe a change before queueing it.'};view.append(feedbackNode(composeFeedbackState));return}
|
|
42
|
+
try{
|
|
43
|
+
const data=await api('/api/projects/'+project.id+'/'+(kind==='note'?'notes':'changes'),{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify(kind==='note'?{note:text}:{request:text})});
|
|
44
|
+
const status=composeFeedbackStatus(data),message=status==='pending'?'Change queued as '+data.requestId+'.': 'Note applied to the live timeline.';composeFeedbackState={status,message};await showProject(project.id)
|
|
45
|
+
}catch(error){composeFeedbackState={status:composeFeedbackStatus({error:error.message}),message:error.message};view.append(feedbackNode(composeFeedbackState))}
|
|
46
|
+
}
|
|
47
|
+
async function safeLiveControl(project,action){
|
|
48
|
+
try{const data=await api('/api/projects/'+project.id+'/'+action,{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify({action})});composeFeedbackState={status:'pending',message:data.status==='pause-requested'?'Pause requested at the next safe boundary.':'Resume requested at the next safe boundary.'};await showProject(project.id)}
|
|
49
|
+
catch(error){composeFeedbackState={status:composeFeedbackStatus({error:error.message}),message:error.message};await showProject(project.id)}
|
|
50
|
+
}
|
|
51
|
+
function renderLive(project){
|
|
52
|
+
const status=project.status||{},goal=project.goal||{},workers=status.parallel?.workers||[],state=projectStatus(project),freshness=liveFreshness(project),tasks=project.stories||[],progress=status.progress||{passed:tasks.filter(task=>task.passes).length,total:tasks.length};
|
|
53
|
+
const view=panel('Live operations');view.append(row('Goal state',goal.status||'not reported',state.goal||'unknown'),row('Loop state',status.state||'not reported',state.loop||'unknown'),row('Freshness',freshness,'Last status '+(status.updatedAt||'unknown')),row('Objective progress',progress.total?progress.passed+'/'+progress.total+' tasks accepted':'Unknown','Saved acceptance state'),row('Last successful sync',lastSuccessfulSync?localAndUtc(lastSuccessfulSync):'Not synced yet','Dashboard refresh'));
|
|
54
|
+
const metadata=panel('Worker metadata');if(!workers.length)metadata.append(el('p','No current workers reported.','empty'));else for(const worker of workers)metadata.append(row(worker.storyTitle||worker.story,workerMetadata(worker),state.stale?'unconfirmed':worker.lifecycle||'running'));view.append(metadata);
|
|
55
|
+
const controls=panel('Safe controls');if(state.active)controls.append(button('Request pause',()=>safeLiveControl(project,'pause')));if(state.goal!=='complete'&&(!state.active||state.loop==='paused'||state.goal==='paused'))controls.append(button('Resume',()=>safeLiveControl(project,'resume'),true));if(!state.active&&!['paused','complete'].includes(state.goal||''))controls.append(el('p','Resume starts through the existing goal or loop boundary.','muted'));view.append(controls);content.append(view)
|
|
56
|
+
renderOperatorInput(project);renderTimeline(project)
|
|
57
|
+
}
|
|
58
|
+
function costText(t){const state=t.costState||t.cost?.state||'unknown',value=t.reportedCostUsd??t.cost?.reportedUsd??(Number.isFinite(t.cost)?t.cost:null);return !Number.isFinite(value)||state==='unknown'?'Unknown':'$'+value.toFixed(3)+(state==='partial'?' reported (partial)':' reported')}
|
|
7
59
|
function historyCost(total,errors){return errors.length&&total.costState==='measured'?{...total,costState:'partial'}:total}
|
|
8
60
|
function rate(value){return Number.isFinite(value)?value.toLocaleString('en-US',{maximumFractionDigits:1}):'Unknown'}
|
|
9
|
-
function comparisonText(current,previous){if(previous===0)return current===0?'No change; both periods recorded 0':'New recorded activity; previous period recorded 0';const change=(current-previous)/previous*100;return (change>0?'+':'')+change.toFixed(1)+'% vs previous period'}
|
|
61
|
+
function comparisonText(current,previous){if(!Number.isFinite(current)||!Number.isFinite(previous))return 'Comparison unavailable; one or both periods are not fully measured';if(previous===0)return current===0?'No change; both periods recorded 0':'New recorded activity; previous period recorded 0';const change=(current-previous)/previous*100;return (change>0?'+':'')+change.toFixed(1)+'% vs previous period'}
|
|
10
62
|
function costComparisonText(current,previous){if(current.costState!=='measured'||previous.costState!=='measured')return 'Percentage unavailable because cost coverage is incomplete in one or both periods';return comparisonText(current.reportedCostUsd,previous.reportedCostUsd)}
|
|
11
|
-
function usageCoverage(total){const missing=total.unknownCalls+total.unmeasuredAttempts;return missing?missing+' call'+(missing===1?'':'s')+' or attempt'+(missing===1?'':'s')+' missing usage; totals are recorded portions':'No missing usage recorded in this period'}
|
|
63
|
+
function usageCoverage(total){const missing=(Number.isFinite(total.unknownCalls)?total.unknownCalls:0)+(Number.isFinite(total.unmeasuredAttempts)?total.unmeasuredAttempts:0);return missing?missing+' call'+(missing===1?'':'s')+' or attempt'+(missing===1?'':'s')+' missing usage; totals are recorded portions':'No missing usage recorded in this period'}
|
|
12
64
|
function periodWindows(now=Date.now(),days=periodDays,from=customFrom,to=customTo){const end=to?Date.parse(to+'T00:00:00Z')+86400000:now,start=from?Date.parse(from+'T00:00:00Z'):end-days*86400000,duration=end-start;return {current:{from:start,to:end},previous:{from:start-duration,to:start}}}
|
|
13
65
|
function queryPeriod(range){return new URLSearchParams({from:new Date(range.from).toISOString(),to:new Date(range.to).toISOString(),bucket}).toString()}
|
|
14
66
|
async function mapLimited(items,limit,shouldContinue,worker){
|
|
@@ -17,12 +69,20 @@ async function mapLimited(items,limit,shouldContinue,worker){
|
|
|
17
69
|
await Promise.all(Array.from({length:Math.min(limit,items.length)},run));
|
|
18
70
|
return results.filter(value=>value!==undefined);
|
|
19
71
|
}
|
|
20
|
-
function
|
|
72
|
+
function numericChart(buckets,key,title){const data=(buckets||[]).slice(-31),values=chartValues(data,key),finiteValues=values.filter(Number.isFinite),max=Math.max(1,...finiteValues),svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 800 220');svg.setAttribute('role','img');svg.setAttribute('aria-label',title+' over the selected UTC period; unknown measurements are omitted, not plotted as zero');const step=760/Math.max(1,data.length);data.forEach((d,i)=>{const value=values[i];if(Number.isFinite(value)){const h=value/max*160,r=document.createElementNS(svg.namespaceURI,'rect');for(const [k,v] of Object.entries({x:20+i*step,y:180-h,width:Math.max(1,step-4),height:h,fill:'#29444b'}))r.setAttribute(k,String(v));const tip=document.createElementNS(svg.namespaceURI,'title');tip.textContent=d.label+': '+value;r.append(tip);svg.append(r)}if(i===0||i===data.length-1){const label=document.createElementNS(svg.namespaceURI,'text');label.setAttribute('x',String(i===0?20:780));label.setAttribute('y','205');label.setAttribute('text-anchor',i===0?'start':'end');label.setAttribute('font-size','12');label.textContent=d.label;svg.append(label)}});return svg}
|
|
73
|
+
function usageChart(buckets){const data=buckets.slice(-31),svg=numericChart(data,'tokens','Recorded tokens');svg.setAttribute('aria-label','Recorded input and output tokens over the latest '+data.length+' non-empty periods; omitted periods mean no recorded activity, not measured zero');return svg}
|
|
74
|
+
function renderAnalyticsCharts(data){const buckets=data.buckets||[],view=panel('Analytics over time'),grid=el('div',undefined,'analytics-chart-grid');for(const [key,title] of [['tokens','Tokens over time'],['calls','Calls over time'],['duration','Duration over time']]){const chart=append(el('div',undefined,'analytics-chart'),el('h3',title),numericChart(buckets,key,title));grid.append(chart)}const outcomes=append(el('div',undefined,'analytics-chart'),el('h3','Outcomes over time'),numericChart(buckets,'outcomes','Outcomes'),table(['UTC '+(data.bucket||'period'),'Attempts','Successful','Accepted'],buckets.map(value=>[value.label,rankingText(value.outcomes?.attempts??value.attempts),rankingText(value.outcomes?.successfulAttempts??value.successfulAttempts),rankingText(value.outcomes?.accepted??value.accepted)])));grid.append(outcomes);const cost=append(el('div',undefined,'analytics-chart'),el('h3','Cost state over time'),table(['UTC '+(data.bucket||'period'),'Cost state','Reported cost'],buckets.map(value=>[value.label,value.cost?.state||value.costState||'Unknown',costText(value)])));grid.append(cost);view.append(el('p','Each series uses recorded measurements for the selected UTC window. Unknown values are left blank and are never treated as zero.','muted'),grid);return view}
|
|
75
|
+
function rowTotal(row){return row.total||row}
|
|
76
|
+
function normalizedRankingRows(rows){return rows.map(row=>{const total=rowTotal(row),reportedCost=row.reportedCostUsd??(Number.isFinite(row.cost)?row.cost:total.cost?.reportedUsd??null);return {...row,name:dimensionLabel(row.name),reportedCostUsd:reportedCost,tokens:row.tokens??total.tokens?.total??null,calls:row.calls?.total??total.calls?.total??null,cost:row.cost??total.cost?.reportedUsd??null,outcomes:row.outcomes?.accepted??total.outcomes?.accepted??null,duration:row.duration??total.time?.attemptDurationMs??null,costState:row.costState||total.costState||total.cost?.state||'unknown'}})}
|
|
77
|
+
function rankingText(value){return Number.isFinite(value)?String(value):'Unknown'}
|
|
78
|
+
function renderDimensionTables(data){const view=panel('Analytics dimensions'),rankings=data.rankings||{};for(const [key,title] of [['agents','Agents'],['providers','Providers'],['models','Models'],['roles','Roles'],['phases','Phases'],['projects','Projects'],['runs','Runs']]){if(!rankings[key])continue;const rows=rankRows(normalizedRankingRows(rankings[key].tokens||rankings[key]),'tokens');view.append(el('h3',title),table(['Rank','Name','Tokens','Calls','Cost','Cost state','Accepted','Duration'],rows.map((row,index)=>[index+1,dimensionLabel(row.name),rankingText(row.tokens),rankingText(row.calls),costText(row),dimensionLabel(row.costState),rankingText(row.outcomes),duration(row.duration)])))}return view}
|
|
79
|
+
function renderCoverage(data,project={}){const view=panel('Measurement coverage');view.append(el('p',data.coverage||'Recorded measurements only; missing telemetry is not estimated.'));for(const message of coverageMessages(data,project))view.append(el('p',message,'notice'));if(!coverageMessages(data,project).length)view.append(el('p','No telemetry gaps reported for this selected window.','muted'));return view}
|
|
80
|
+
function renderAnalytics(data,project={}){const view=el('div',undefined,'analytics-view'),total=data.total||{};view.append(append(el('div',undefined,'metric-strip'),metric('Recorded tokens',tokenText(total.tokens?.total),'Input '+tokenText(total.tokens?.input)+' · output '+tokenText(total.tokens?.output)),metric('Calls',rankingText(total.calls?.total),'Measured '+rankingText(total.calls?.measured)+' · unknown '+rankingText(total.calls?.unknown)),metric('Cost state',dimensionLabel(total.cost?.state||total.costState),'Reported charges only'),metric('Duration',duration(total.time?.attemptDurationMs),'Summed recorded attempt time')),renderCoverage(data,project),renderAnalyticsCharts(data),renderDimensionTables(data));return view}
|
|
21
81
|
async function showWorkspaceUsage(){
|
|
22
|
-
const request=beginRequest();selected=null;nav();heading('
|
|
23
|
-
const bounds=periodWindows(Date.now()),query=queryPeriod(bounds.current),valid=()=>currentRequest(request.version)&&navigationState.screen==='workspace'&&!request.signal.aborted;
|
|
24
|
-
|
|
25
|
-
if(valid())p.replaceChildren(el('h2','
|
|
82
|
+
const request=beginRequest();selected=null;nav();heading('Workspace analytics','Time-bucketed recorded usage, outcomes, and coverage for the selected UTC window.');periodControls(null);const p=panel('Workspace analytics');p.append(el('p','Loading…','muted'));content.append(p);
|
|
83
|
+
const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),query=queryPeriod(bounds.current)+'&sort='+encodeURIComponent(overviewSort),valid=()=>currentRequest(request.version)&&navigationState.screen==='workspace'&&!request.signal.aborted;
|
|
84
|
+
try{const data=await api('/api/workspace/analytics?'+query,{},request.signal);if(valid())p.replaceChildren(el('h2','Workspace analytics'),renderAnalytics(data,{stale:projects.some(project=>projectStatus(project).stale)}));}
|
|
85
|
+
catch(error){if(valid()&&!isAbort(error))p.replaceChildren(el('h2','Workspace analytics'),el('p','Offline or unavailable: '+error.message,'notice'),el('p','Retry when the local Yoke process is available.','muted'))}
|
|
26
86
|
}
|
|
27
87
|
function periodControls(id){
|
|
28
88
|
const p=panel('Period · UTC'),select=el('select');select.setAttribute('aria-label','Period');
|
|
@@ -45,50 +105,44 @@ function renderNow(p){
|
|
|
45
105
|
if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,state.stale?'unconfirmed':'running'));
|
|
46
106
|
if(status.execution&&status.state==='running')summary.append(el('p',state.stale?'Execution was reported but is now unconfirmed.':status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));
|
|
47
107
|
if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',reportedState));
|
|
48
|
-
for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.selectedProvider
|
|
108
|
+
for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.provider+(worker.selectedProvider&&worker.selectedProvider!==worker.provider?' ('+worker.selectedProvider+')':''),'requested model '+(worker.selectedModel||worker.model||'provider default'),worker.selectedVariant?'variant '+worker.selectedVariant:null,worker.phase||worker.lifecycle||'working',worker.startedAt?(state.stale?'last started '+worker.startedAt:'elapsed '+duration(Date.now()-Date.parse(worker.startedAt))):null,worker.quality?'repair '+worker.quality.usedRepairs:null].filter(Boolean).join(' · '),state.stale?'unconfirmed':worker.lifecycle||'running'));
|
|
49
109
|
if(status.parallel?.integrator){const worker=status.parallel.integrator;summary.append(row('Integration: '+(worker.storyTitle||worker.story),worker.phase||'checking',state.stale?'unconfirmed':'integrating'))}
|
|
50
110
|
if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));
|
|
51
111
|
summary.append(el('p','Status refreshes every 5 seconds. Requested models are not proof of the model actually used; reported model identities appear under Usage & time.','muted'));content.append(summary);
|
|
52
112
|
const tasks=panel('Tasks');for(const task of p.stories||[])tasks.append(row(task.title,task.id+(task.needs?.length?' · after '+task.needs.join(', '):'')+' · '+taskTiming(task,p.estimate),task.passes?'passed':workers.some(worker=>worker.story===task.id)?state.stale?'unconfirmed':'running':'open'));content.append(tasks);
|
|
53
|
-
const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
|
|
54
|
-
|
|
113
|
+
const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+(decision.providerModel?' ('+decision.providerModel+')':'')+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:'')+(decision.variant?' · variant '+decision.variant:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
|
|
114
|
+
renderLive(p);
|
|
55
115
|
}
|
|
56
|
-
function renderUsageHistory(current,previous){
|
|
57
|
-
const total=current.total,prior=previous.total,currentTokens=total.inputTokens
|
|
58
|
-
const currentCoverage=usageCoverage(total)+(current.errors.length?' · measurement history is incomplete':''),previousCoverage=usageCoverage(prior)+(previous.errors.length?' · measurement history is incomplete':'');
|
|
59
|
-
const
|
|
60
|
-
const comparison=panel('Compared with the immediately preceding equal period');
|
|
61
|
-
const boundary=value=>new Date(value).toISOString().slice(0,16).replace('T',' ');
|
|
116
|
+
function renderUsageHistory(current,previous,project={}){
|
|
117
|
+
const total=current.total,prior=previous.total,currentTokens=total.tokens?.total??safeMetricTotal([total.inputTokens,total.outputTokens]),previousTokens=prior.tokens?.total??safeMetricTotal([prior.inputTokens,prior.outputTokens]);
|
|
118
|
+
const currentCoverage=usageCoverage(total)+(current.errors.length?' · measurement history is incomplete':''),previousCoverage=usageCoverage(prior)+(previous.errors.length?' · measurement history is incomplete':''),currentCost=historyCost(total,current.errors),previousCost=historyCost(prior,previous.errors);
|
|
119
|
+
const comparison=panel('Compared with the immediately preceding equal period'),boundary=value=>new Date(value).toISOString().slice(0,16).replace('T',' ');
|
|
62
120
|
comparison.append(el('p','Selected: '+boundary(current.from)+' → '+boundary(current.to)+' UTC','muted'),el('p','Previous: '+boundary(previous.from)+' → '+boundary(previous.to)+' UTC · end boundaries excluded','muted'));
|
|
63
|
-
comparison.append(append(el('div',undefined,'metrics'),metric('Recorded tokens',tokenText(currentTokens),'Previous: '+tokenText(previousTokens)+' · '+comparisonText(currentTokens,previousTokens)+' · '+currentCoverage),metric('Accepted tasks',tokenText(total.accepted),'Previous: '+
|
|
64
|
-
comparison.append(el('p','Current coverage: '+currentCoverage+'. Previous coverage: '+previousCoverage+'. Missing measurements are not estimated and recorded totals are not treated as complete totals.','muted'));for(const error of previous.errors)comparison.append(el('p','Previous period: '+error,'notice'));content.append(comparison);
|
|
65
|
-
content.append(append(el('div',undefined,'metrics'),metric('Recorded input',tokenText(total.inputTokens),'Output: '+tokenText(total.outputTokens)),metric('Reported cost',costText(currentCost),'Missing charges are not estimated'),metric('Tokens / elapsed minute',rate(total.tokensPerElapsedMinute),'Input + output / entire selected period')));
|
|
66
|
-
const notes=panel('Measurement coverage');notes.append(el('p',current.coverage));notes.append(el('p','First record in period: '+(current.earliest||'none')+' · latest: '+(current.latest||'none'),'muted'));notes.append(el('p','Measured calls: '+total.measuredCalls+' · calls with unknown usage: '+total.unknownCalls+' · unmeasured attempts: '+total.unmeasuredAttempts));notes.append(el('p','Tokens / recorded call minute: '+rate(total.tokensPerCallMinute)+' · summed call time: '+duration(total.callDurationMs)+'. Parallel calls overlap; this is consumption intensity, not generation speed.'));notes.append(el('p','Cache reads: '+tokenText(total.cachedInputTokens)+' · cache writes: '+tokenText(total.cacheWriteInputTokens)+'. These are reported categories and are not added again to input totals.','muted'));for(const error of current.errors)notes.append(el('p',error,'notice'));content.append(notes);
|
|
67
|
-
const series=panel('Consumption over time');series.append(usageChart(current.buckets));series.append(el('p','Dark: input · orange: output. Chart shows up to 31 latest non-empty periods; the table includes every recorded period. Periods with no recorded events are omitted, so calendar gaps mean no recorded activity and do not assert a measured zero.','muted chart-gap-note'));series.append(table(['UTC '+current.bucket,'Input','Output','Cache reads','Reported cost'],current.buckets.map(value=>[value.label,tokenText(value.inputTokens),tokenText(value.outputTokens),tokenText(value.cachedInputTokens),costText(value)])));if(!current.buckets.length)series.append(el('p','No recorded activity in this period; measurement coverage may still be incomplete.','empty'));content.append(series);
|
|
68
|
-
const models=panel('Reported models in this period');models.append(table(['Provider','Actual model','Role','Input','Output','Call time','Cost'],current.models.map(value=>[value.provider,value.model,value.role,tokenText(value.inputTokens),tokenText(value.outputTokens),duration(value.callDurationMs),costText(value)])));content.append(models);
|
|
69
|
-
const modelTimeline=panel('Models over time');modelTimeline.append(table(['UTC '+current.bucket,'Provider','Actual model','Role','Input','Output'],current.modelBuckets.map(value=>[value.label,value.provider,value.model,value.role,tokenText(value.inputTokens),tokenText(value.outputTokens)])));content.append(modelTimeline);
|
|
70
|
-
const tasks=panel('Consumption by task');tasks.append(table(['Task','Input','Output','Attempt time','Cost'],current.tasks.map(value=>[value.storyId,tokenText(value.inputTokens),tokenText(value.outputTokens),duration(value.attemptDurationMs),costText(value)])));content.append(tasks);
|
|
71
|
-
const phases=panel('Recorded phase time');phases.append(table(['Phase','Summed duration'],current.phases.map(value=>[value.phase,duration(value.durationMs)])));phases.append(el('p','Overlapping worker time is summed. Unrecorded queue time and human waiting time remain unknown.','muted'));content.append(phases);
|
|
121
|
+
comparison.append(append(el('div',undefined,'metrics'),metric('Recorded tokens',tokenText(currentTokens),'Previous: '+tokenText(previousTokens)+' · '+comparisonText(currentTokens,previousTokens)+' · '+currentCoverage),metric('Accepted tasks',tokenText(total.accepted),'Previous: '+comparisonText(total.accepted,prior.accepted)+' · recorded acceptance events'),metric('Reported cost',costText(currentCost),'Previous: '+costText(previousCost)+' · '+costComparisonText(currentCost,previousCost))));
|
|
122
|
+
comparison.append(el('p','Current coverage: '+currentCoverage+'. Previous coverage: '+previousCoverage+'. Missing measurements are not estimated and recorded totals are not treated as complete totals.','muted'));for(const error of previous.errors)comparison.append(el('p','Previous period: '+error,'notice'));content.append(comparison,renderAnalytics(current,project));
|
|
72
123
|
}
|
|
73
124
|
function renderResults(p,a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Accepted in period',t.accepted,'Recorded acceptance events'),metric('Repair phases',t.repairs,'Routing escalations: '+t.escalations),metric('Tokens / acceptance',rate(t.tokensPerAccepted),'Reported input + output, including failed work')));const attempts=panel('Measured outcomes');attempts.append(el('p','Ended attempts: '+t.attempts+' · explicitly successful attempts: '+t.successfulAttempts+' · average summed attempt time per acceptance: '+duration(t.timePerAcceptedMs)));attempts.append(el('p','Worker termination alone is not counted as successful acceptance. Rates only describe recorded activity in the selected period.','muted'));attempts.append(table(['Task','Attempts','Accepted','Repairs','Escalations','Tokens / acceptance'],a.tasks.map(v=>[v.storyId,v.attempts,v.accepted,v.repairs,v.escalations,rate(v.tokensPerAccepted)])));content.append(attempts);const checks=panel('Latest saved acceptance evidence');if(p.check){checks.append(el('p',p.check.summary));for(const c of p.check.criteria){const item=row(c.text,c.id,c.status);item.firstChild.append(append(el('details'),el('summary','View evidence'),el('p',c.summary)));checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence; independent of the selected statistics period.','muted'))}else checks.append(el('p','No saved independent check.','empty'));content.append(checks);const goals=panel('Saved goal attempts');for(const attempt of p.goal?.attempts||[])goals.append(row(attempt.provider||'Agent',(attempt.summary||'')+' · '+duration(attempt.durationMs)+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens),attempt.success?'finished':'failed'));content.append(goals)}
|
|
125
|
+
function renderHistoryExplorer(project,history){const state=projectStatus(project),view=panel('History explorer');view.append(el('p','Recorded events and completed runs in the selected UTC window. The list is bounded to the latest 100 events.','muted'));const events=timelineEvents(history.events||[]);if(!events.length)view.append(el('p','No recorded history in this period.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}const runs=panel('Run history');if(!(history.runs||[]).length)runs.append(el('p','No recorded runs in this period.','empty'));else runs.append(table(['Run','Started','Ended','Agent','Provider','Model','Tokens','Accepted','Outcome'],history.runs.map(run=>[dimensionLabel(run.runId),run.startedAt||'Not reported',run.endedAt||'Not reported',dimensionLabel(run.agent),dimensionLabel(run.provider),dimensionLabel(run.model),rankingText(run.total?.tokens?.total),rankingText(run.total?.outcomes?.accepted),rankingText(run.total?.outcomes?.successfulAttempts)])));content.append(renderCoverage(history,{stale:state.stale,unavailable:false}),runs,view)}
|
|
74
126
|
async function showProject(id){
|
|
75
127
|
const request=beginRequest();selected=id;nav();
|
|
76
128
|
try{
|
|
77
|
-
const bounds=periodWindows(Date.now()),detailRequest=api('/api/projects/'+id,{},request.signal);
|
|
78
|
-
let project,current=null,previous=null;
|
|
129
|
+
const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),detailRequest=api('/api/projects/'+id,{},request.signal);
|
|
130
|
+
let project,current=null,previous=null,history=null;
|
|
79
131
|
if(projectView==='usage'){
|
|
80
132
|
[project,current,previous]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal),api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.previous),{},request.signal)]);
|
|
81
133
|
}else if(projectView==='results'){
|
|
82
134
|
[project,current]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal)]);
|
|
135
|
+
}else if(projectView==='history'){
|
|
136
|
+
[project,history]=await Promise.all([detailRequest,api('/api/projects/'+id+'/history?'+queryPeriod(bounds.current)+'&limit=100',{},request.signal)]);
|
|
83
137
|
}else project=await detailRequest;
|
|
84
138
|
if(!currentRequest(request.version)||navigationState.screen!=='project'||selected!==id)return;
|
|
85
|
-
heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
|
|
86
|
-
const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
|
|
87
|
-
|
|
139
|
+
lastSuccessfulSync=new Date().toISOString();heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
|
|
140
|
+
const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
|
|
141
|
+
content.append(tabs);
|
|
88
142
|
if(projectView==='now'){
|
|
89
143
|
const tasks=project.stories||[],state=projectStatus(project);content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(task=>task.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',project.estimate?.available?duration(project.estimate.lowerMs)+' – '+duration(project.estimate.upperMs):'Unknown',project.estimate?.available?project.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',state.primary,state.goal&&state.loop&&state.goal!==state.loop?'Goal '+state.goal+' · loop '+(state.stale?'unconfirmed':state.loop):'Latest reported state')));renderNow(project);
|
|
90
|
-
}else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous);else renderResults(project,current)}
|
|
91
|
-
}catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error))heading('Project unavailable',error.message)}
|
|
144
|
+
}else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous,projectStatus(project));else if(projectView==='history')renderHistoryExplorer(project,history);else renderResults(project,current)}
|
|
145
|
+
}catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error)){heading('Project unavailable','Offline or unavailable: '+error.message);content.append(renderCoverage({errors:[],coverage:'Historical analytics are unavailable for this project.'},{unavailable:true}))}}
|
|
92
146
|
}
|
|
93
147
|
if(typeof setInterval==='function')setInterval(()=>{if(selected&&projectView==='now'&&typeof document.hidden!=='undefined'&&!document.hidden&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)&&!document.activeElement?.matches?.(':focus-visible'))showProject(selected)},5000);
|
|
94
148
|
`;
|
package/dist/dashboard/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
-
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { z } from 'zod';
|
|
@@ -8,8 +8,14 @@ import { listProjects } from './registry.js';
|
|
|
8
8
|
import { dashboardPage } from './page.js';
|
|
9
9
|
import { readEvents } from '../observability/events.js';
|
|
10
10
|
import { estimateSchedule } from '../estimation/schedule.js';
|
|
11
|
-
import { pauseProjectGoal } from '../goals/command.js';
|
|
12
|
-
import {
|
|
11
|
+
import { pauseProjectGoal, runProjectGoal } from '../goals/command.js';
|
|
12
|
+
import { runLoopCommand } from '../loop/run-command.js';
|
|
13
|
+
import { isPidAlive, readLock } from '../loop/lock.js';
|
|
14
|
+
import { appendEvent } from '../observability/events.js';
|
|
15
|
+
import { queueChange } from '../change/inbox.js';
|
|
16
|
+
import { statePath } from '../workspace/state.js';
|
|
17
|
+
import { parsePeriod, parseRanking, projectAnalytics, projectHistory, workspaceAnalytics } from './analytics.js';
|
|
18
|
+
import { DASHBOARD_LIMITS, DashboardChangePayloadSchema, DashboardControlPayloadSchema, DashboardNotePayloadSchema, DashboardResumePayloadSchema, parseDashboardLimit, } from './contracts.js';
|
|
13
19
|
const text = z.string().max(16000);
|
|
14
20
|
const number = z.number().finite().nonnegative();
|
|
15
21
|
const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), pendingAttempt: z.object({ provider: text, model: text.optional(), startedAt: text }).optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
|
|
@@ -17,8 +23,11 @@ const Status = z.object({ state: text, phase: text.optional(), reason: text.opti
|
|
|
17
23
|
const Stories = z.array(z.object({ id: text, title: text, passes: z.boolean(), priority: number.optional(), area: text.optional(), writes: z.array(text).optional(), needs: z.array(text).optional() })).max(2000);
|
|
18
24
|
const Check = z.object({ id: text, status: z.enum(['passed', 'failed', 'unverified']), generatedAt: text, summary: text, criteria: z.array(z.object({ id: text, text, status: z.enum(['passed', 'failed', 'unverified']), summary: text })).max(500) });
|
|
19
25
|
const Durations = z.array(z.object({ storyId: text, ms: number.positive() })).max(5000);
|
|
20
|
-
const MAX_FILE =
|
|
26
|
+
const MAX_FILE = DASHBOARD_LIMITS.fileBytes;
|
|
27
|
+
const activeResumes = new Set();
|
|
21
28
|
function safeFile(root, relative) {
|
|
29
|
+
if (!relative || relative.startsWith('/') || relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..'))
|
|
30
|
+
throw new Error('Unsafe project path');
|
|
22
31
|
let file = root;
|
|
23
32
|
try {
|
|
24
33
|
for (const part of relative.split('/')) {
|
|
@@ -39,6 +48,62 @@ function safeFile(root, relative) {
|
|
|
39
48
|
throw error;
|
|
40
49
|
}
|
|
41
50
|
}
|
|
51
|
+
function requestBody(req, empty = { action: 'pause' }) {
|
|
52
|
+
const length = req.headers['content-length'];
|
|
53
|
+
if (typeof length === 'string' && Number.isFinite(Number(length)) && Number(length) > DASHBOARD_LIMITS.requestBytes) {
|
|
54
|
+
req.resume();
|
|
55
|
+
return Promise.reject(new Error('Dashboard request body too large'));
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
let rejected = false;
|
|
61
|
+
req.on('data', chunk => {
|
|
62
|
+
size += Buffer.byteLength(chunk);
|
|
63
|
+
if (size > DASHBOARD_LIMITS.requestBytes) {
|
|
64
|
+
rejected = true;
|
|
65
|
+
req.resume();
|
|
66
|
+
reject(new Error('Dashboard request body too large'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(Buffer.from(chunk));
|
|
70
|
+
});
|
|
71
|
+
req.on('end', () => {
|
|
72
|
+
if (rejected)
|
|
73
|
+
return;
|
|
74
|
+
const body = Buffer.concat(chunks).toString('utf8').trim();
|
|
75
|
+
if (!body) {
|
|
76
|
+
resolve(empty);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
resolve(JSON.parse(body));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
reject(new Error('Invalid dashboard request body'));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
req.on('error', reject);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function writePauseSignal(root) {
|
|
90
|
+
const directory = statePath(root);
|
|
91
|
+
const path = statePath(root, 'loop.pause');
|
|
92
|
+
mkdirSync(directory, { recursive: true });
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(path, 'pause\n', { flag: 'wx', mode: 0o600 });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== 'EEXIST')
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function projectIsRunning(root) {
|
|
102
|
+
if (activeResumes.has(root))
|
|
103
|
+
return true;
|
|
104
|
+
const lock = readLock(root);
|
|
105
|
+
return Boolean(lock && isPidAlive(lock.pid));
|
|
106
|
+
}
|
|
42
107
|
function snapshot(project, detail) {
|
|
43
108
|
const errors = project.error ? [project.error] : [];
|
|
44
109
|
const read = (file, schema, yaml = false) => {
|
|
@@ -75,13 +140,13 @@ function snapshot(project, detail) {
|
|
|
75
140
|
errors.push(error.message);
|
|
76
141
|
}
|
|
77
142
|
}
|
|
78
|
-
return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root,
|
|
143
|
+
return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root, DASHBOARD_LIMITS.events), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
|
|
79
144
|
}
|
|
80
145
|
export async function startDashboard(options = {}) {
|
|
81
146
|
const token = randomBytes(32).toString('hex');
|
|
82
147
|
const nonce = randomBytes(24).toString('base64');
|
|
83
148
|
let origin = '';
|
|
84
|
-
const server = createServer((req, res) => {
|
|
149
|
+
const server = createServer(async (req, res) => {
|
|
85
150
|
res.setHeader('Cache-Control', 'no-store');
|
|
86
151
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
87
152
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
@@ -91,14 +156,19 @@ export async function startDashboard(options = {}) {
|
|
|
91
156
|
send(403, { error: 'Untrusted request origin' });
|
|
92
157
|
return;
|
|
93
158
|
}
|
|
94
|
-
let
|
|
159
|
+
let requested;
|
|
95
160
|
try {
|
|
96
|
-
|
|
161
|
+
requested = new URL(req.url ?? '/', origin);
|
|
97
162
|
}
|
|
98
163
|
catch {
|
|
99
164
|
send(400, { error: 'Invalid URL' });
|
|
100
165
|
return;
|
|
101
166
|
}
|
|
167
|
+
if (requested.origin !== origin) {
|
|
168
|
+
send(403, { error: 'Untrusted request origin' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const path = requested.pathname;
|
|
102
172
|
if (req.method === 'POST') {
|
|
103
173
|
const supplied = req.headers['x-yoke-token'];
|
|
104
174
|
if (req.headers.origin !== origin || typeof supplied !== 'string' || Buffer.byteLength(supplied) !== token.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) {
|
|
@@ -121,7 +191,20 @@ export async function startDashboard(options = {}) {
|
|
|
121
191
|
send(200, listProjects().map(project => snapshot(project, false)));
|
|
122
192
|
return;
|
|
123
193
|
}
|
|
124
|
-
|
|
194
|
+
if (req.method === 'GET' && path === '/api/workspace/analytics') {
|
|
195
|
+
let period, sort;
|
|
196
|
+
try {
|
|
197
|
+
period = parsePeriod(requested.searchParams);
|
|
198
|
+
sort = parseRanking(requested.searchParams);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
send(400, { error: error.message });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
send(200, workspaceAnalytics(listProjects(), period, sort));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/resume|\/notes|\/changes|\/analytics|\/history|\/events)?$/u.exec(path);
|
|
125
208
|
if (match) {
|
|
126
209
|
const project = listProjects().find(project => project.id === match[1]);
|
|
127
210
|
if (!project) {
|
|
@@ -135,7 +218,7 @@ export async function startDashboard(options = {}) {
|
|
|
135
218
|
}
|
|
136
219
|
let period;
|
|
137
220
|
try {
|
|
138
|
-
period = parsePeriod(
|
|
221
|
+
period = parsePeriod(requested.searchParams);
|
|
139
222
|
}
|
|
140
223
|
catch (error) {
|
|
141
224
|
send(400, { error: error.message });
|
|
@@ -144,18 +227,110 @@ export async function startDashboard(options = {}) {
|
|
|
144
227
|
send(200, projectAnalytics(project.root, period));
|
|
145
228
|
return;
|
|
146
229
|
}
|
|
230
|
+
if (req.method === 'GET' && (match[2] === '/history' || match[2] === '/events')) {
|
|
231
|
+
if (project.error) {
|
|
232
|
+
send(409, { error: project.error });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let period, limit;
|
|
236
|
+
try {
|
|
237
|
+
period = parsePeriod(requested.searchParams);
|
|
238
|
+
limit = parseDashboardLimit(requested.searchParams.get('limit'));
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
send(400, { error: error.message });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
send(200, projectHistory(project.root, period, limit));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
147
247
|
if (req.method === 'GET' && !match[2]) {
|
|
148
248
|
send(200, snapshot(project, true));
|
|
149
249
|
return;
|
|
150
250
|
}
|
|
151
251
|
if (req.method === 'POST' && match[2] === '/pause') {
|
|
152
|
-
|
|
153
|
-
|
|
252
|
+
try {
|
|
253
|
+
DashboardControlPayloadSchema.parse(await requestBody(req, { action: 'pause' }));
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard control payload' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const current = snapshot(project, false);
|
|
260
|
+
if (project.error) {
|
|
261
|
+
send(409, { error: project.error });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (current.goal)
|
|
265
|
+
pauseProjectGoal(project.root);
|
|
266
|
+
writePauseSignal(project.root);
|
|
267
|
+
const response = { status: 'pause-requested' };
|
|
268
|
+
send(200, response);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (req.method === 'POST' && match[2] === '/resume') {
|
|
272
|
+
try {
|
|
273
|
+
DashboardResumePayloadSchema.parse(await requestBody(req, { action: 'resume' }));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard resume payload' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const current = snapshot(project, false);
|
|
280
|
+
if (project.error) {
|
|
281
|
+
send(409, { error: project.error });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (projectIsRunning(project.root)) {
|
|
285
|
+
send(409, { status: 'already-running', error: 'Project is already running' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const goal = current.goal !== null && current.goal.status !== 'complete';
|
|
289
|
+
activeResumes.add(project.root);
|
|
290
|
+
void Promise.resolve().then(async () => {
|
|
291
|
+
if (goal)
|
|
292
|
+
await runProjectGoal(project.root);
|
|
293
|
+
else
|
|
294
|
+
await runLoopCommand(project.root, {});
|
|
295
|
+
}).catch(() => undefined).finally(() => { activeResumes.delete(project.root); });
|
|
296
|
+
const response = { status: 'resume-requested' };
|
|
297
|
+
send(202, response);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (req.method === 'POST' && match[2] === '/notes') {
|
|
301
|
+
let payload;
|
|
302
|
+
try {
|
|
303
|
+
payload = DashboardNotePayloadSchema.parse(await requestBody(req, {}));
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard note payload' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (project.error) {
|
|
310
|
+
send(409, { error: project.error });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
appendEvent(project.root, { runId: 'dashboard', timestamp: new Date().toISOString(), type: 'operator-note', data: { note: payload.note } });
|
|
314
|
+
const response = { status: 'note-added' };
|
|
315
|
+
send(200, response);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (req.method === 'POST' && match[2] === '/changes') {
|
|
319
|
+
let payload;
|
|
320
|
+
try {
|
|
321
|
+
payload = DashboardChangePayloadSchema.parse(await requestBody(req, {}));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard change payload' });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (project.error) {
|
|
328
|
+
send(409, { error: project.error });
|
|
154
329
|
return;
|
|
155
330
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
send(200,
|
|
331
|
+
const change = queueChange(project.root, payload.request);
|
|
332
|
+
const response = { status: 'pending', requestId: change.id };
|
|
333
|
+
send(200, response);
|
|
159
334
|
return;
|
|
160
335
|
}
|
|
161
336
|
}
|
package/dist/goals/command.js
CHANGED
|
@@ -10,10 +10,11 @@ import { acquireLock, releaseLock, isPidAlive } from '../loop/lock.js';
|
|
|
10
10
|
import { reapProviderProcesses, isProviderTreeAlive } from '../loop/cleanup.js';
|
|
11
11
|
import { killProcessTreeForCleanup } from '../loop/watchdog.js';
|
|
12
12
|
import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
|
|
13
|
+
import { AgentSchema } from '../agents/contracts.js';
|
|
13
14
|
import { appendEvent } from '../observability/events.js';
|
|
14
15
|
import { statePath } from '../workspace/state.js';
|
|
15
|
-
const Attempt = z.object({ provider:
|
|
16
|
-
const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider:
|
|
16
|
+
const Attempt = z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
|
|
17
|
+
const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
|
|
17
18
|
const goalPath = (root) => statePath(root, 'goal.json');
|
|
18
19
|
function save(root, goal) {
|
|
19
20
|
const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
|
package/dist/loop/claims.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { isPidAlive } from './lock.js';
|
|
5
5
|
import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
|
|
6
6
|
import { storyPathSegment } from './prd.js';
|
|
7
|
+
import { AgentSchema } from '../agents/contracts.js';
|
|
7
8
|
export const CLAIM_SCHEMA_VERSION = 2;
|
|
8
9
|
const LegacyClaimSchema = z.object({
|
|
9
10
|
storyId: z.string().min(1),
|
|
@@ -18,7 +19,7 @@ const RichClaimSchema = LegacyClaimSchema.extend({
|
|
|
18
19
|
heartbeatAt: z.string().min(1),
|
|
19
20
|
baseCommit: z.string().min(1).optional(),
|
|
20
21
|
worktree: z.string().min(1).optional(),
|
|
21
|
-
provider:
|
|
22
|
+
provider: AgentSchema.optional(),
|
|
22
23
|
model: z.string().min(1).optional(),
|
|
23
24
|
role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
|
|
24
25
|
cancellation: z.object({
|
package/dist/loop/decision.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveCommitIdentity } from './identity.js';
|
|
|
10
10
|
import { commitPaths, realGitOps } from './git.js';
|
|
11
11
|
import { acquireLock, releaseLock } from './lock.js';
|
|
12
12
|
import { loadPrd, selectNextStory } from './prd.js';
|
|
13
|
+
import { AgentSchema } from '../agents/contracts.js';
|
|
13
14
|
const singleLine = (max) => z.string().trim().min(1).max(max).refine(value => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value), 'must be a single line without control or bidi-formatting characters');
|
|
14
15
|
const MAX_STATE_BYTES = 256 * 1024;
|
|
15
16
|
const DecisionOptionSchema = z.object({
|
|
@@ -60,9 +61,9 @@ export const DecisionResumeSchema = z.object({
|
|
|
60
61
|
answered: z.boolean().optional(),
|
|
61
62
|
/** Absent preserves the default unlimited run across a critical-decision resume. */
|
|
62
63
|
maxIterations: z.number().int().positive().optional(),
|
|
63
|
-
agent:
|
|
64
|
+
agent: AgentSchema.optional(),
|
|
64
65
|
isolate: z.boolean().optional(),
|
|
65
|
-
reviewer:
|
|
66
|
+
reviewer: AgentSchema.optional(),
|
|
66
67
|
review: z.boolean().optional(),
|
|
67
68
|
allowSelfReview: z.boolean().optional(),
|
|
68
69
|
timeoutMinutes: z.number().nonnegative().optional(),
|
|
@@ -255,7 +255,7 @@ function asyncRunner(input, provider, signal, workerId) {
|
|
|
255
255
|
if (input.routing) {
|
|
256
256
|
const routed = makeAsyncAdaptiveRunner({
|
|
257
257
|
parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
|
|
258
|
-
parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
|
|
258
|
+
parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, provider: provider.providerModel, model: provider.model, reasoningEffort: provider.reasoningEffort, variant: provider.variant, nativeMultiAgent: false },
|
|
259
259
|
projectRoot: input.targetDir,
|
|
260
260
|
workers: input.routing.workers,
|
|
261
261
|
rules: input.routing.rules,
|
|
@@ -276,7 +276,7 @@ function asyncRunner(input, provider, signal, workerId) {
|
|
|
276
276
|
makeWorker: (agent, selection) => async (context) => {
|
|
277
277
|
if (signal?.aborted)
|
|
278
278
|
return { success: false, summary: 'Routing cancelled before worker start' };
|
|
279
|
-
input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
|
|
279
|
+
input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: selection.provider ?? agent, selectedModel: selection.model, selectedVariant: selection.variant, phase: 'implementing' });
|
|
280
280
|
const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
|
|
281
281
|
process: { idleTimeoutMs: input.idleMs, signal, workerId } });
|
|
282
282
|
return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
|
|
@@ -292,10 +292,12 @@ function asyncRunner(input, provider, signal, workerId) {
|
|
|
292
292
|
permissions: input.permissions,
|
|
293
293
|
selection: {
|
|
294
294
|
...selection,
|
|
295
|
+
...(provider.providerModel ? { provider: provider.providerModel } : {}),
|
|
295
296
|
...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
|
|
296
297
|
nativeMultiAgent: false,
|
|
297
298
|
...(provider.model ? { model: provider.model } : {}),
|
|
298
299
|
...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
|
|
300
|
+
...(provider.variant ? { variant: provider.variant } : {}),
|
|
299
301
|
},
|
|
300
302
|
process: { idleTimeoutMs: input.idleMs, signal, workerId },
|
|
301
303
|
});
|