@hueyexe/opencode-ensemble 0.14.2 → 0.15.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/README.md +7 -6
- package/dist/index.js +2128 -884
- package/package.json +1 -1
- package/src/dashboard-html.ts +11 -3
- package/src/{dashboard-js-part1.ts → dashboard-js-core.ts} +11 -3
- package/src/{dashboard-js-part3.ts → dashboard-js-events.ts} +25 -4
- package/src/{dashboard-js-part2.ts → dashboard-js-render.ts} +13 -8
- package/src/dashboard.ts +62 -21
- package/src/db.ts +2 -3
- package/src/index.ts +10 -11
- package/src/recovery.ts +26 -22
- package/src/schema.ts +122 -2
- package/src/tools/merge-helper.ts +46 -2
- package/src/tools/team-cleanup.ts +29 -14
- package/src/tools/team-create.ts +17 -5
- package/src/tools/team-shutdown.ts +8 -7
- package/src/tools/team-spawn.ts +3 -1
- package/src/util.ts +17 -0
- package/src/watchdog.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/dashboard-html.ts
CHANGED
|
@@ -27,6 +27,11 @@ export const DASHBOARD_HEAD = `<!DOCTYPE html>
|
|
|
27
27
|
details summary::-webkit-details-marker{display:none}details summary{list-style:none}
|
|
28
28
|
:focus-visible{outline:2px solid rgba(96,165,250,.85);outline-offset:2px}
|
|
29
29
|
select{-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%235e6a82' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:22px}
|
|
30
|
+
.project-link[aria-current="true"]{color:#e2e8f0}
|
|
31
|
+
.team-link[aria-current="true"]{color:#e2e8f0;border-left-color:#22c55e;background:rgba(34,197,94,.06)}
|
|
32
|
+
#content.nav-collapsed{grid-template-columns:2rem minmax(0,1fr)}
|
|
33
|
+
#projects[hidden]{display:none!important}
|
|
34
|
+
#project-rail[hidden]{display:none!important}
|
|
30
35
|
.xp{max-height:0;overflow:hidden;transition:max-height .3s ease-out}.xp-open{max-height:3000px;transition:max-height .5s ease-in}
|
|
31
36
|
.card-sel{outline:2px solid rgba(59,130,246,.4);outline-offset:1px}
|
|
32
37
|
.md pre{background:#1a1f2e;border:1px solid #1e2433;border-radius:6px;padding:8px 12px;overflow-x:auto;margin:6px 0;font-size:12px;line-height:1.5}
|
|
@@ -47,8 +52,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
|
|
|
47
52
|
<header class="fixed top-0 inset-x-0 h-11 bg-base-950/95 backdrop-blur border-b border-base-800 flex items-center justify-between px-3 sm:px-4 z-50">
|
|
48
53
|
<div class="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
|
49
54
|
<span class="font-mono font-semibold text-[13px] tracking-[.08em] text-txt-200">ensemble</span>
|
|
50
|
-
<span class="text-
|
|
51
|
-
<select id="sel" class="bg-base-900 border border-base-700 rounded-md px-2 py-[3px] text-[11px] text-txt-200 font-mono outline-none cursor-pointer hover:border-base-600 transition-colors max-w-[180px] sm:max-w-[320px] min-w-0"></select>
|
|
55
|
+
<span id="crumb" class="text-[11px] text-txt-500 font-mono truncate"></span>
|
|
52
56
|
</div>
|
|
53
57
|
<div class="flex items-center gap-2 sm:gap-4 shrink-0">
|
|
54
58
|
<div id="hring" class="w-6 h-6 rounded-full" title="Team health"></div>
|
|
@@ -67,7 +71,10 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
|
|
|
67
71
|
<div class="text-txt-400 text-sm">Waiting for a team</div>
|
|
68
72
|
<div class="text-txt-500 text-[11px]">Run <code class="px-1.5 py-0.5 bg-base-900 rounded text-txt-300 font-mono text-[11px]">team_create</code> in OpenCode to get started</div>
|
|
69
73
|
</div>
|
|
70
|
-
<div id="content" class="hidden max-w-[
|
|
74
|
+
<div id="content" class="hidden max-w-[1720px] mx-auto grid grid-cols-1 lg:grid-cols-[260px_minmax(0,1fr)] gap-4 items-start">
|
|
75
|
+
<div id="project-rail" hidden class="lg:sticky lg:top-[88px]"><button id="nav-expand" type="button" aria-label="Show project navigation" aria-controls="projects" aria-expanded="false" class="h-8 w-8 rounded border border-base-800 text-txt-500 hover:text-txt-200 hover:border-base-700 transition-colors">></button></div>
|
|
76
|
+
<aside id="projects" aria-label="Project navigation" class="bg-base-950/60 border-r border-base-800/70 pr-3 lg:sticky lg:top-[88px]"></aside>
|
|
77
|
+
<div class="min-w-0">
|
|
71
78
|
<section id="attention" aria-label="Team attention" class="mb-3"></section>
|
|
72
79
|
<div class="grid grid-cols-1 xl:grid-cols-[minmax(360px,1.35fr)_minmax(320px,.8fr)] gap-4 items-start">
|
|
73
80
|
<section aria-label="Agent roster"><div id="agents" class="grid gap-2" style="grid-template-columns:repeat(auto-fill,minmax(300px,1fr))"></div></section>
|
|
@@ -77,6 +84,7 @@ select{-webkit-appearance:none;appearance:none;background-image:url("data:image/
|
|
|
77
84
|
</div>
|
|
78
85
|
</div>
|
|
79
86
|
</div>
|
|
87
|
+
</div>
|
|
80
88
|
</main>
|
|
81
89
|
<div id="tl" aria-label="Event timeline" class="fixed bottom-0 inset-x-0 h-10 bg-base-900/90 backdrop-blur border-t border-base-800 px-4 flex items-center z-40 overflow-x-auto scroll hidden"></div>
|
|
82
90
|
<div id="sco" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="shortcuts-title" tabindex="-1" onclick="closeShortcuts()">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Dashboard JS — utilities, data helpers, and state management. */
|
|
2
|
-
export const
|
|
3
|
-
let S=null,selId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1;
|
|
2
|
+
export const DASHBOARD_JS_CORE = `
|
|
3
|
+
let S=null,selId=null,selProjectId=null,fails=0,pollT=Date.now(),prevMC=0,selCard=-1,navCollapsed=false;
|
|
4
4
|
const expCards=new Set(),expMsgs=new Set();
|
|
5
5
|
const E=s=>s?String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'):'';
|
|
6
6
|
const D=ms=>{const s=Math.floor(Math.abs(ms)/1000);return s<60?s+'s':s<3600?Math.floor(s/60)+'m':Math.floor(s/3600)+'h'};
|
|
@@ -63,13 +63,21 @@ function allTeams(){
|
|
|
63
63
|
const archived=[...S.teams.filter(t=>t.status!=='active')].sort((a,b)=>b.timeUpdated-a.timeUpdated);
|
|
64
64
|
return{active,archived};
|
|
65
65
|
}
|
|
66
|
-
function
|
|
66
|
+
function allProjects(){return S?.projects?[...S.projects].sort((a,b)=>b.timeUpdated-a.timeUpdated):[]}
|
|
67
|
+
function projectLabel(p){return p?(p.name||p.path||p.id):''}
|
|
68
|
+
function curProject(){const ps=allProjects();if(!ps.length)return null;if(selProjectId){const p=ps.find(p=>p.id===selProjectId);if(p)return p}var t=cur();return t?ps.find(p=>p.id===t.projectId)||ps[0]:ps[0]}
|
|
69
|
+
function cur(){const{active,archived}=allTeams(),all=[...active,...archived];if(!all.length)return null;if(selId){const t=all.find(t=>t.id===selId);if(t){selProjectId=t.projectId;return t}}const p=selProjectId&&S?.projects?.find(p=>p.id===selProjectId);const pt=p?[...(p.teams||[])].filter(t=>t.status==='active'):[ ];const t=pt.sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||active[0]||all[0];if(t){selId=t.id;selProjectId=t.projectId}return t}
|
|
67
70
|
|
|
68
71
|
function deriveHealth(t){
|
|
69
72
|
const mm=t.members||[];if(!mm.length)return{w:0,i:0,e:0,d:0,total:0};
|
|
70
73
|
return{w:mm.filter(m=>m.status==='busy').length,i:mm.filter(m=>m.status==='ready').length,e:mm.filter(m=>m.status==='error').length,d:mm.filter(m=>m.status==='shutdown'||m.status==='shutdown_requested').length,total:mm.length};
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
function coarseTeamStatus(t){const h=deriveHealth(t),blocked=(t.tasks||[]).filter(x=>x.status==='blocked').length;if(h.e)return{label:'error',color:'red',dot:'bg-red-500'};if(blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500'};if(h.w)return{label:'working',color:'blue',dot:'bg-blue-500'};if(h.i)return{label:'idle',color:'muted',dot:'bg-txt-500'};return{label:t.status==='active'?'empty':t.status,color:'muted',dot:'bg-base-600'}}
|
|
77
|
+
function projectStatus(p){const teams=p.teams||[],counts={working:0,blocked:0,error:0,idle:0,done:0};teams.forEach(t=>{const s=coarseTeamStatus(t).label;if(s==='working')counts.working++;else if(s==='blocked')counts.blocked++;else if(s==='error')counts.error++;else if(s==='idle'||s==='empty')counts.idle++;else counts.done++});if(counts.error)return{label:'error',color:'red',dot:'bg-red-500',counts};if(counts.blocked)return{label:'blocked',color:'amber',dot:'bg-amber-500',counts};if(counts.working)return{label:'working',color:'blue',dot:'bg-blue-500',counts};return{label:'idle',color:'muted',dot:'bg-txt-500',counts}}
|
|
78
|
+
function statusTitleProject(p){const s=projectStatus(p),teams=p.teams||[];return projectLabel(p)+'\\nStatus: '+s.label+'\\nTeams: '+teams.length+'\\nWorking: '+s.counts.working+' · Blocked: '+s.counts.blocked+' · Error: '+s.counts.error+' · Idle: '+s.counts.idle}
|
|
79
|
+
function statusTitleTeam(t){const h=deriveHealth(t),tasks=t.tasks||[],blocked=tasks.filter(x=>x.status==='blocked').length,active=tasks.filter(x=>x.status==='in_progress').length,pending=tasks.filter(x=>x.status==='pending').length,done=tasks.filter(x=>x.status==='completed').length;return t.name+'\\nStatus: '+coarseTeamStatus(t).label+'\\nAgents: '+h.total+' total, '+h.w+' working, '+h.i+' idle, '+h.e+' error\\nTasks: '+active+' active, '+blocked+' blocked, '+pending+' pending, '+done+' done'}
|
|
80
|
+
|
|
73
81
|
function lastMessageFor(name,msgs){return msgs.filter(m=>m.fromName===name||m.toName===name).sort((a,b)=>b.timeCreated-a.timeCreated)[0]||null}
|
|
74
82
|
function activeTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='in_progress')||tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
|
|
75
83
|
function blockedTaskFor(name,tasks){return tasks.find(x=>x.assignee===name&&x.status==='blocked')||null}
|
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
/** Dashboard JS — interaction handlers, keyboard, polling. */
|
|
2
|
-
export const
|
|
2
|
+
export const DASHBOARD_JS_EVENTS = `
|
|
3
3
|
function toggleMsg(id){if(expMsgs.has(id))expMsgs.delete(id);else expMsgs.add(id);render()}
|
|
4
4
|
|
|
5
|
+
function applyNavCollapse(){
|
|
6
|
+
const content=document.getElementById('content'),projects=document.getElementById('projects'),rail=document.getElementById('project-rail'),toggle=document.getElementById('nav-toggle'),expand=document.getElementById('nav-expand');
|
|
7
|
+
content.classList.toggle('nav-collapsed',navCollapsed);
|
|
8
|
+
projects.hidden=navCollapsed;
|
|
9
|
+
projects.setAttribute('aria-hidden',String(navCollapsed));
|
|
10
|
+
rail.hidden=!navCollapsed;
|
|
11
|
+
if(toggle)toggle.setAttribute('aria-expanded',String(!navCollapsed));
|
|
12
|
+
expand.setAttribute('aria-expanded',String(!navCollapsed));
|
|
13
|
+
if(document.activeElement===toggle&&navCollapsed)expand.focus();
|
|
14
|
+
if(document.activeElement===expand&&!navCollapsed&&toggle)toggle.focus();
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
function render(){
|
|
6
18
|
rSel();const t=cur();
|
|
7
19
|
const empty=document.getElementById('empty'),content=document.getElementById('content');
|
|
8
20
|
if(!t){empty.classList.remove('hidden');empty.classList.add('flex');content.classList.add('hidden');document.getElementById('tl').classList.add('hidden');return}
|
|
9
21
|
empty.classList.add('hidden');empty.classList.remove('flex');content.classList.remove('hidden');
|
|
22
|
+
applyNavCollapse();
|
|
23
|
+
const p=curProject();document.getElementById('crumb').textContent=p?' / '+projectLabel(p)+' / '+t.name:'';
|
|
10
24
|
rHealth(t);rSum(t);rAttention(t);rAgents(t);rTasks(t);rActivity(t);rTimeline(t);
|
|
11
25
|
}
|
|
12
26
|
|
|
27
|
+
function selectProject(id){selProjectId=id;const p=S?.projects?.find(p=>p.id===id);const t=(p?.teams||[]).filter(t=>t.status==='active').sort((a,b)=>b.timeUpdated-a.timeUpdated)[0]||(p?.teams||[])[0];if(t)selId=t.id;selCard=-1;render()}
|
|
28
|
+
function selectTeam(id){selId=id;selCard=-1;render()}
|
|
29
|
+
|
|
13
30
|
function conn(ok){
|
|
14
31
|
document.getElementById('cd').className='w-[7px] h-[7px] rounded-full '+(ok?'bg-emerald-500 pulse':'bg-red-500');
|
|
15
32
|
document.getElementById('ct').textContent=ok?D(Date.now()-pollT)+' ago':'reconnecting to dashboard state';
|
|
@@ -56,6 +73,13 @@ setInterval(function(){var t=cur();if(t)rClock(t);if(fails<3)conn(true)},1000);
|
|
|
56
73
|
// Poll every 2.5s
|
|
57
74
|
setInterval(poll,2500);
|
|
58
75
|
|
|
76
|
+
document.addEventListener('click',function(e){
|
|
77
|
+
const id=e.target&&e.target.id;
|
|
78
|
+
if(id!=='nav-toggle'&&id!=='nav-expand')return;
|
|
79
|
+
navCollapsed=id==='nav-toggle';
|
|
80
|
+
applyNavCollapse();
|
|
81
|
+
});
|
|
82
|
+
|
|
59
83
|
// Keyboard shortcuts
|
|
60
84
|
document.addEventListener('keydown',function(e){
|
|
61
85
|
const shortcutsOpen=document.getElementById('sco').classList.contains('show');
|
|
@@ -80,9 +104,6 @@ document.addEventListener('keydown',function(e){
|
|
|
80
104
|
}
|
|
81
105
|
});
|
|
82
106
|
|
|
83
|
-
// Select handler
|
|
84
|
-
document.getElementById('sel').addEventListener('change',function(){selId=this.value;render()});
|
|
85
|
-
|
|
86
107
|
// Initial poll
|
|
87
108
|
poll();
|
|
88
109
|
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/** Dashboard JS — render functions. */
|
|
2
|
-
export const
|
|
2
|
+
export const DASHBOARD_JS_RENDER = `
|
|
3
3
|
function rSel(){
|
|
4
|
-
const el=document.getElementById('
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
const el=document.getElementById('projects'),ps=allProjects(),c=cur(),cp=curProject();
|
|
5
|
+
const head=renderProjectNavHeader();
|
|
6
|
+
if(!ps.length){patch(el,head+'<div class="text-center text-txt-500 text-[12px] py-6">No projects yet</div>');return}
|
|
7
|
+
let h='<nav class="text-[12px]" aria-label="Projects">'+head+'<div class="space-y-4">';
|
|
8
|
+
ps.forEach(function(p){h+=renderProjectSection(p,cp,c)});
|
|
9
|
+
h+='</div></nav>';
|
|
10
|
+
patch(el,h);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function renderProjectNavHeader(){return '<div class="flex items-center justify-between gap-2 mb-3"><div class="text-[10px] uppercase tracking-[.18em] text-txt-500">Projects</div><button id="nav-toggle" type="button" aria-label="Hide project navigation" aria-controls="projects" aria-expanded="true" class="text-[10px] text-txt-500 border border-base-800 rounded px-1.5 py-[2px] hover:text-txt-200 hover:border-base-700 transition-colors">hide</button></div>'}
|
|
14
|
+
function renderProjectSection(p,cp,c){const teams=p.teams||[];return '<section>'+renderProjectButton(p,cp)+'<div class="mt-2 ml-[7px] border-l border-base-800/80">'+[...teams].sort((a,b)=>b.timeUpdated-a.timeUpdated).map(t=>renderTeamLink(t,c)).join('')+'</div></section>'}
|
|
15
|
+
function renderProjectButton(p,cp){const teams=p.teams||[],active=teams.filter(t=>t.status==='active').length,sel=cp&&cp.id===p.id,pss=projectStatus(p);return '<button type="button" aria-current="'+(sel?'true':'false')+'" title="'+E(statusTitleProject(p))+'" class="project-link group w-full text-left text-txt-300 hover:text-txt-100 transition-colors" data-project="'+E(p.id)+'" onclick="selectProject(this.dataset.project)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+pss.dot+(pss.label==='working'?' pulse':'')+' shrink-0"></span><span class="font-mono font-semibold truncate">'+E(projectLabel(p))+'</span>'+chip(pss.label,pss.color)+'</div><div class="mt-1 ml-3 text-[10px] text-txt-500">'+active+' team'+(active!==1?'s':'')+'</div></button>'}
|
|
16
|
+
function renderTeamLink(t,c){const ss=coarseTeamStatus(t),tsel=c&&c.id===t.id;return '<button type="button" title="'+E(statusTitleTeam(t))+'" class="team-link block w-full text-left border-l-2 border-transparent -ml-px py-1.5 pl-3 pr-2 text-[11px] text-txt-500 hover:text-txt-200 hover:bg-base-900/70 transition-colors" aria-current="'+(tsel?'true':'false')+'" data-team="'+E(t.id)+'" onclick="selectTeam(this.dataset.team)"><div class="flex items-center gap-2 min-w-0"><span class="w-[5px] h-[5px] rounded-full '+ss.dot+(ss.label==='working'?' pulse':'')+' shrink-0"></span><span class="truncate font-mono">'+E(t.name)+'</span><span class="ml-auto text-[9px] uppercase tracking-wide text-txt-500">'+E(ss.label)+'</span></div></button>'}
|
|
17
|
+
|
|
13
18
|
function rHealth(t){
|
|
14
19
|
const el=document.getElementById('hring'),h=deriveHealth(t);
|
|
15
20
|
if(!h.total){el.style.background='conic-gradient(#2a3144 0deg,#2a3144 360deg)';return}
|
package/src/dashboard.ts
CHANGED
|
@@ -2,23 +2,33 @@ import { createServer } from "node:http"
|
|
|
2
2
|
import type { IncomingMessage, Server, ServerResponse } from "node:http"
|
|
3
3
|
import type { Database } from "./db"
|
|
4
4
|
import { DASHBOARD_HEAD } from "./dashboard-html"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { DASHBOARD_JS_CORE } from "./dashboard-js-core"
|
|
6
|
+
import { DASHBOARD_JS_EVENTS } from "./dashboard-js-events"
|
|
7
|
+
import { DASHBOARD_JS_RENDER } from "./dashboard-js-render"
|
|
8
8
|
import { log } from "./log"
|
|
9
9
|
|
|
10
10
|
/** Assemble the full dashboard HTML from parts. */
|
|
11
|
-
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" +
|
|
11
|
+
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_CORE + DASHBOARD_JS_RENDER + DASHBOARD_JS_EVENTS + "<\/script>\n</body></html>"
|
|
12
12
|
|
|
13
13
|
interface TeamRow {
|
|
14
14
|
id: string
|
|
15
15
|
name: string
|
|
16
|
+
project_id: string
|
|
16
17
|
status: string
|
|
17
18
|
lead_agent: string | null
|
|
18
19
|
time_created: number
|
|
19
20
|
time_updated: number
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
interface ProjectRow {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
path: string
|
|
27
|
+
status: string
|
|
28
|
+
time_created: number
|
|
29
|
+
time_updated: number
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
interface MemberRow {
|
|
23
33
|
name: string
|
|
24
34
|
agent: string
|
|
@@ -68,32 +78,35 @@ function parseDependsOn(value: string | null): string[] {
|
|
|
68
78
|
return []
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
function buildState(db: Database): { teams: unknown[] } {
|
|
72
|
-
const
|
|
81
|
+
function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
|
|
82
|
+
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
|
|
83
|
+
const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
|
|
73
84
|
const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
|
|
74
85
|
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
|
|
75
86
|
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
const mappedTeams = teams.map((t) => {
|
|
89
|
+
const members = (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
|
|
90
|
+
name: m.name,
|
|
91
|
+
agent: m.agent,
|
|
92
|
+
status: m.status,
|
|
93
|
+
executionStatus: m.execution_status,
|
|
94
|
+
worktreeBranch: m.worktree_branch,
|
|
95
|
+
prompt: m.prompt,
|
|
96
|
+
model: m.model,
|
|
97
|
+
planApproval: m.plan_approval,
|
|
98
|
+
timeCreated: m.time_created,
|
|
99
|
+
timeUpdated: m.time_updated,
|
|
100
|
+
}))
|
|
101
|
+
return {
|
|
79
102
|
id: t.id,
|
|
80
103
|
name: t.name,
|
|
104
|
+
projectId: t.project_id,
|
|
81
105
|
status: t.status,
|
|
82
106
|
leadAgent: t.lead_agent,
|
|
83
107
|
timeCreated: t.time_created,
|
|
84
108
|
timeUpdated: t.time_updated,
|
|
85
|
-
members
|
|
86
|
-
name: m.name,
|
|
87
|
-
agent: m.agent,
|
|
88
|
-
status: m.status,
|
|
89
|
-
executionStatus: m.execution_status,
|
|
90
|
-
worktreeBranch: m.worktree_branch,
|
|
91
|
-
prompt: m.prompt,
|
|
92
|
-
model: m.model,
|
|
93
|
-
planApproval: m.plan_approval,
|
|
94
|
-
timeCreated: m.time_created,
|
|
95
|
-
timeUpdated: m.time_updated,
|
|
96
|
-
})),
|
|
109
|
+
members,
|
|
97
110
|
tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
|
|
98
111
|
id: tk.id,
|
|
99
112
|
content: tk.content,
|
|
@@ -113,7 +126,35 @@ function buildState(db: Database): { teams: unknown[] } {
|
|
|
113
126
|
read: msg.read === 1,
|
|
114
127
|
timeCreated: msg.time_created,
|
|
115
128
|
})),
|
|
116
|
-
}
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const teamsByProject = new Map<string, unknown[]>()
|
|
133
|
+
mappedTeams.forEach(team => {
|
|
134
|
+
const projectId = (team as { projectId: string }).projectId
|
|
135
|
+
teamsByProject.set(projectId, [...(teamsByProject.get(projectId) ?? []), team])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
projects: projects.flatMap(project => {
|
|
140
|
+
const projectTeams = teamsByProject.get(project.id) ?? []
|
|
141
|
+
if (projectTeams.length === 0) return []
|
|
142
|
+
return {
|
|
143
|
+
id: project.id,
|
|
144
|
+
name: project.name,
|
|
145
|
+
path: project.path,
|
|
146
|
+
status: project.status,
|
|
147
|
+
timeCreated: project.time_created,
|
|
148
|
+
timeUpdated: project.time_updated,
|
|
149
|
+
activeTeams: projectTeams.filter(team => (team as { status: string }).status === "active").length,
|
|
150
|
+
workingAgents: projectTeams.reduce<number>((count, team) => {
|
|
151
|
+
const members = (team as { members: Array<{ status: string }> }).members
|
|
152
|
+
return count + members.filter(member => member.status === "busy").length
|
|
153
|
+
}, 0),
|
|
154
|
+
teams: projectTeams,
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
teams: mappedTeams,
|
|
117
158
|
}
|
|
118
159
|
}
|
|
119
160
|
|
package/src/db.ts
CHANGED
|
@@ -89,9 +89,8 @@ function openDatabase(filename: string): Database {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Resolve the path for the ensemble SQLite database.
|
|
93
|
-
*
|
|
94
|
-
* Accepts an env override for testability.
|
|
92
|
+
* Resolve the path for the global ensemble SQLite database.
|
|
93
|
+
* Project data is logically isolated by project_id inside this DB.
|
|
95
94
|
*/
|
|
96
95
|
export function getDbPath(env: Record<string, string | undefined> = process.env): string {
|
|
97
96
|
const home = env.HOME ?? env.USERPROFILE ?? "~"
|
package/src/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
|
|
|
45
45
|
* peer-to-peer communication, shared task management, and coordinated execution.
|
|
46
46
|
*/
|
|
47
47
|
const plugin: Plugin = async (input) => {
|
|
48
|
-
// Initialize SQLite database in the global OpenCode config directory
|
|
48
|
+
// Initialize SQLite database in the global OpenCode config directory.
|
|
49
49
|
const dbPath = getDbPath()
|
|
50
50
|
mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
51
51
|
const db = createDb(dbPath)
|
|
@@ -212,16 +212,14 @@ const plugin: Plugin = async (input) => {
|
|
|
212
212
|
"SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
|
|
213
213
|
).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
|
|
214
214
|
if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
|
|
224
|
-
}
|
|
215
|
+
const { getTeamResourceParts, preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
|
|
216
|
+
const resource = getTeamResourceParts(deps.db, member.team_id)
|
|
217
|
+
const safeBranch = branchName(resource.projectName, resource.teamName, resource.teamId, member.name)
|
|
218
|
+
const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
|
|
219
|
+
if (ok) {
|
|
220
|
+
deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|
|
221
|
+
[safeBranch, member.team_id, member.name])
|
|
222
|
+
log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
|
|
225
223
|
}
|
|
226
224
|
}
|
|
227
225
|
try {
|
|
@@ -372,6 +370,7 @@ const plugin: Plugin = async (input) => {
|
|
|
372
370
|
description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
|
|
373
371
|
args: {
|
|
374
372
|
name: tool.schema.string().describe("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
|
|
373
|
+
project_name: tool.schema.string().optional().describe("Project display name for first use of this working directory. If omitted, a short random name is generated."),
|
|
375
374
|
},
|
|
376
375
|
async execute(args, ctx) {
|
|
377
376
|
const result = await executeTeamCreate(deps, args, ctx.sessionID)
|
package/src/recovery.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Database } from "./db"
|
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
3
|
import type { MemberRegistry } from "./state"
|
|
4
4
|
import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
|
|
5
|
-
import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
|
|
5
|
+
import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
|
|
6
6
|
import { log } from "./log"
|
|
7
7
|
import { runCommand } from "./process"
|
|
8
8
|
|
|
@@ -16,17 +16,19 @@ import { runCommand } from "./process"
|
|
|
16
16
|
export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
|
|
17
17
|
// Find stale members with branch info so we can preserve before aborting
|
|
18
18
|
const stale = db.query(
|
|
19
|
-
`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
|
|
20
|
+
FROM team_member tm
|
|
21
|
+
JOIN team t ON tm.team_id = t.id
|
|
22
|
+
JOIN project p ON t.project_id = p.id
|
|
23
|
+
WHERE tm.status = 'busy' AND t.status = 'active'
|
|
24
|
+
AND (? IS NULL OR t.project_id = ? OR t.project_id = 'default')`
|
|
25
|
+
).all(cwd ?? null, cwd ?? null) as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string; project_name: string }>
|
|
24
26
|
|
|
25
27
|
const result = db.run(
|
|
26
28
|
`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
[Date.now()]
|
|
29
|
+
WHERE status = 'busy'
|
|
30
|
+
AND team_id IN (SELECT id FROM team WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default'))`,
|
|
31
|
+
[Date.now(), cwd ?? null, cwd ?? null]
|
|
30
32
|
)
|
|
31
33
|
|
|
32
34
|
// Preserve branches then abort orphaned sessions
|
|
@@ -34,7 +36,7 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
|
|
|
34
36
|
for (const member of stale) {
|
|
35
37
|
// Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
|
|
36
38
|
if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
37
|
-
const safeBranch = preservedBranchName(member.team_name, member.name)
|
|
39
|
+
const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
|
|
38
40
|
const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd)
|
|
39
41
|
if (ok) {
|
|
40
42
|
db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|
|
@@ -178,19 +180,25 @@ export function rehydrateRegistry(db: Database, registry: MemberRegistry): numbe
|
|
|
178
180
|
export async function recoverOrphanedBranches(db: Database, cwd: string): Promise<{ removed: number }> {
|
|
179
181
|
let removed = 0
|
|
180
182
|
|
|
181
|
-
// Get archived team
|
|
183
|
+
// Get archived team namespaces for this project that have NO active members.
|
|
184
|
+
// The team id namespace is current; team names are kept for legacy preserved branches.
|
|
182
185
|
const archivedTeams = db.query(
|
|
183
|
-
`SELECT t.name FROM team t
|
|
186
|
+
`SELECT t.id, t.name, p.name as project_name FROM team t
|
|
187
|
+
JOIN project p ON t.project_id = p.id
|
|
184
188
|
WHERE t.status = 'archived'
|
|
189
|
+
AND t.project_id = ?
|
|
185
190
|
AND NOT EXISTS (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
).all() as Array<{ name: string }>
|
|
191
|
+
SELECT 1 FROM team_member tm
|
|
192
|
+
WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
|
|
193
|
+
)`
|
|
194
|
+
).all(cwd) as Array<{ id: string; name: string; project_name: string }>
|
|
190
195
|
|
|
191
196
|
if (archivedTeams.length === 0) return { removed: 0 }
|
|
192
197
|
|
|
193
|
-
const
|
|
198
|
+
const archivedPrefixes = archivedTeams.flatMap(t => [
|
|
199
|
+
`ensemble/preserved/${t.project_name}/${teamResourceSegment(t.name, t.id)}/`,
|
|
200
|
+
`ensemble/preserved/${t.name}/`,
|
|
201
|
+
])
|
|
194
202
|
|
|
195
203
|
// List all local branches matching ensemble/preserved/*
|
|
196
204
|
const result = await runCommand(["git", "branch", "--list", "ensemble/preserved/*"], { cwd })
|
|
@@ -198,11 +206,7 @@ export async function recoverOrphanedBranches(db: Database, cwd: string): Promis
|
|
|
198
206
|
const branches = result.stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
|
|
199
207
|
|
|
200
208
|
for (const branch of branches) {
|
|
201
|
-
|
|
202
|
-
const parts = branch.split("/")
|
|
203
|
-
if (parts.length < 4) continue
|
|
204
|
-
const teamName = parts[2]
|
|
205
|
-
if (!teamName || !archivedNames.has(teamName)) continue
|
|
209
|
+
if (!archivedPrefixes.some(prefix => branch.startsWith(prefix))) continue
|
|
206
210
|
|
|
207
211
|
try {
|
|
208
212
|
const deleteResult = await runCommand(["git", "branch", "-D", branch], { cwd })
|
package/src/schema.ts
CHANGED
|
@@ -82,6 +82,110 @@ export const MIGRATIONS: string[] = [
|
|
|
82
82
|
`ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
|
|
83
83
|
// Migration 7: Track whether teammate has reported to lead (completion loop prevention, issue #3)
|
|
84
84
|
`ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`,
|
|
85
|
+
// Migration 8: Add project as the dashboard grouping level. For this initial
|
|
86
|
+
// project-first step, project_id is the team lead's working directory.
|
|
87
|
+
`PRAGMA foreign_keys=OFF;
|
|
88
|
+
ALTER TABLE team RENAME TO team_old_m8;
|
|
89
|
+
CREATE TABLE IF NOT EXISTS project (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
name TEXT NOT NULL,
|
|
92
|
+
path TEXT NOT NULL,
|
|
93
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
|
|
94
|
+
time_created INTEGER NOT NULL,
|
|
95
|
+
time_updated INTEGER NOT NULL
|
|
96
|
+
);
|
|
97
|
+
INSERT OR IGNORE INTO project (id, name, path, status, time_created, time_updated)
|
|
98
|
+
VALUES ('default', 'Default Project', '', 'active', strftime('%s','now') * 1000, strftime('%s','now') * 1000);
|
|
99
|
+
CREATE TABLE team (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
name TEXT NOT NULL,
|
|
102
|
+
project_id TEXT NOT NULL DEFAULT 'default' REFERENCES project(id) ON DELETE CASCADE,
|
|
103
|
+
lead_session_id TEXT NOT NULL,
|
|
104
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
|
|
105
|
+
delegate INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
time_created INTEGER NOT NULL,
|
|
107
|
+
time_updated INTEGER NOT NULL,
|
|
108
|
+
lead_agent TEXT
|
|
109
|
+
);
|
|
110
|
+
INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated, lead_agent)
|
|
111
|
+
SELECT id, name, 'default', lead_session_id, status, delegate, time_created, time_updated, lead_agent FROM team_old_m8;
|
|
112
|
+
DROP TABLE team_old_m8;
|
|
113
|
+
|
|
114
|
+
ALTER TABLE team_member RENAME TO team_member_old_m8;
|
|
115
|
+
CREATE TABLE team_member (
|
|
116
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
117
|
+
name TEXT NOT NULL,
|
|
118
|
+
session_id TEXT NOT NULL,
|
|
119
|
+
agent TEXT NOT NULL,
|
|
120
|
+
status TEXT NOT NULL DEFAULT 'ready'
|
|
121
|
+
CHECK(status IN ('ready', 'busy', 'shutdown_requested', 'shutdown', 'error')),
|
|
122
|
+
execution_status TEXT NOT NULL DEFAULT 'idle'
|
|
123
|
+
CHECK(execution_status IN ('idle', 'starting', 'running',
|
|
124
|
+
'cancel_requested', 'cancelling', 'cancelled',
|
|
125
|
+
'completing', 'completed', 'failed', 'timed_out')),
|
|
126
|
+
model TEXT,
|
|
127
|
+
prompt TEXT,
|
|
128
|
+
time_created INTEGER NOT NULL,
|
|
129
|
+
time_updated INTEGER NOT NULL,
|
|
130
|
+
worktree_dir TEXT,
|
|
131
|
+
worktree_branch TEXT,
|
|
132
|
+
plan_approval TEXT NOT NULL DEFAULT 'none'
|
|
133
|
+
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected')),
|
|
134
|
+
workspace_id TEXT,
|
|
135
|
+
reported_to_lead INTEGER NOT NULL DEFAULT 0,
|
|
136
|
+
PRIMARY KEY (team_id, name)
|
|
137
|
+
);
|
|
138
|
+
INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead)
|
|
139
|
+
SELECT team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead FROM team_member_old_m8;
|
|
140
|
+
DROP TABLE team_member_old_m8;
|
|
141
|
+
|
|
142
|
+
ALTER TABLE team_task RENAME TO team_task_old_m8;
|
|
143
|
+
CREATE TABLE team_task (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
146
|
+
content TEXT NOT NULL,
|
|
147
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
148
|
+
CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled', 'blocked')),
|
|
149
|
+
priority TEXT NOT NULL DEFAULT 'medium'
|
|
150
|
+
CHECK(priority IN ('high', 'medium', 'low')),
|
|
151
|
+
assignee TEXT,
|
|
152
|
+
depends_on TEXT,
|
|
153
|
+
time_created INTEGER NOT NULL,
|
|
154
|
+
time_updated INTEGER NOT NULL
|
|
155
|
+
);
|
|
156
|
+
INSERT INTO team_task (id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated)
|
|
157
|
+
SELECT id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task_old_m8;
|
|
158
|
+
DROP TABLE team_task_old_m8;
|
|
159
|
+
|
|
160
|
+
ALTER TABLE team_message RENAME TO team_message_old_m8;
|
|
161
|
+
CREATE TABLE team_message (
|
|
162
|
+
id TEXT PRIMARY KEY,
|
|
163
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
164
|
+
from_name TEXT NOT NULL,
|
|
165
|
+
to_name TEXT,
|
|
166
|
+
content TEXT NOT NULL,
|
|
167
|
+
delivered INTEGER NOT NULL DEFAULT 0,
|
|
168
|
+
time_created INTEGER NOT NULL,
|
|
169
|
+
read INTEGER NOT NULL DEFAULT 0
|
|
170
|
+
);
|
|
171
|
+
INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created, read)
|
|
172
|
+
SELECT id, team_id, from_name, to_name, content, delivered, time_created, read FROM team_message_old_m8;
|
|
173
|
+
DROP TABLE team_message_old_m8;
|
|
174
|
+
|
|
175
|
+
CREATE INDEX IF NOT EXISTS team_lead_idx ON team(lead_session_id);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS team_status_idx ON team(status);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS team_project_idx ON team(project_id, status);
|
|
178
|
+
CREATE UNIQUE INDEX IF NOT EXISTS team_active_project_name_idx ON team(project_id, name) WHERE status = 'active';
|
|
179
|
+
CREATE INDEX IF NOT EXISTS team_member_session_idx ON team_member(session_id);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS team_member_status_idx ON team_member(team_id, status);
|
|
181
|
+
CREATE INDEX IF NOT EXISTS team_task_team_idx ON team_task(team_id);
|
|
182
|
+
CREATE INDEX IF NOT EXISTS team_task_assignee_idx ON team_task(assignee);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS team_task_status_idx ON team_task(team_id, status);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS team_message_team_idx ON team_message(team_id);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
|
|
187
|
+
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
|
|
188
|
+
PRAGMA foreign_keys=ON;`,
|
|
85
189
|
]
|
|
86
190
|
|
|
87
191
|
/**
|
|
@@ -91,9 +195,25 @@ export const MIGRATIONS: string[] = [
|
|
|
91
195
|
export function applyMigrations(db: Database): void {
|
|
92
196
|
const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
|
|
93
197
|
|
|
198
|
+
if (current > MIGRATIONS.length) {
|
|
199
|
+
throw new Error(`Database schema version ${current} is newer than this plugin supports (${MIGRATIONS.length}). Upgrade opencode-ensemble before continuing.`)
|
|
200
|
+
}
|
|
201
|
+
|
|
94
202
|
for (let i = current; i < MIGRATIONS.length; i++) {
|
|
95
203
|
const migration = MIGRATIONS[i]
|
|
96
|
-
|
|
97
|
-
|
|
204
|
+
const { foreign_keys: foreignKeys } = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number }
|
|
205
|
+
|
|
206
|
+
db.exec("PRAGMA foreign_keys = OFF")
|
|
207
|
+
db.exec("BEGIN IMMEDIATE")
|
|
208
|
+
try {
|
|
209
|
+
if (migration) db.exec(migration)
|
|
210
|
+
db.exec(`PRAGMA user_version = ${i + 1}`)
|
|
211
|
+
db.exec("COMMIT")
|
|
212
|
+
} catch (err) {
|
|
213
|
+
db.exec("ROLLBACK")
|
|
214
|
+
throw err
|
|
215
|
+
} finally {
|
|
216
|
+
db.exec(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`)
|
|
217
|
+
}
|
|
98
218
|
}
|
|
99
219
|
}
|