nexus-prime 7.9.29 → 7.9.30
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/dist/dashboard/app/styles/workforce.css +51 -1
- package/dist/dashboard/app/views/board.js +5 -2
- package/dist/dashboard/app/views/workforce.js +51 -8
- package/dist/dashboard/routes/governance.js +16 -3
- package/dist/dashboard/routes/workforce.js +47 -1
- package/dist/workforce/db/schema.d.ts +1 -1
- package/dist/workforce/db/schema.js +4 -2
- package/dist/workforce/index.d.ts +3 -2
- package/dist/workforce/index.js +3 -2
- package/dist/workforce/jobs.d.ts +1 -0
- package/dist/workforce/jobs.js +7 -0
- package/dist/workforce/kanban-view.js +15 -0
- package/dist/workforce/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
.kanban-meta { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 8px; padding: 0 4px; }
|
|
135
135
|
.kanban-scroll { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; }
|
|
136
136
|
.kanban-lane {
|
|
137
|
-
min-width:
|
|
137
|
+
min-width: 220px; max-width: 280px; flex-shrink: 0;
|
|
138
138
|
background: var(--bg-panel); border: 1px solid var(--border);
|
|
139
139
|
border-radius: var(--radius); display: flex; flex-direction: column;
|
|
140
140
|
}
|
|
@@ -170,6 +170,56 @@
|
|
|
170
170
|
}
|
|
171
171
|
.kc-id { font-size: 0.65rem; color: var(--text-dim); font-family: var(--font-mono); }
|
|
172
172
|
.kc-title { font-size: 0.74rem; color: var(--text-main); line-height: 1.35; word-break: break-word; }
|
|
173
|
+
.kc-goal {
|
|
174
|
+
margin-top: 5px;
|
|
175
|
+
color: var(--text-main);
|
|
176
|
+
font-size: 0.72rem;
|
|
177
|
+
line-height: 1.35;
|
|
178
|
+
word-break: break-word;
|
|
179
|
+
}
|
|
180
|
+
.kc-flow {
|
|
181
|
+
margin-top: 7px;
|
|
182
|
+
display: grid;
|
|
183
|
+
gap: 5px;
|
|
184
|
+
}
|
|
185
|
+
.kc-flow div {
|
|
186
|
+
border-left: 2px solid rgba(255,255,255,0.08);
|
|
187
|
+
padding-left: 7px;
|
|
188
|
+
}
|
|
189
|
+
.kc-flow span {
|
|
190
|
+
display: block;
|
|
191
|
+
margin-bottom: 1px;
|
|
192
|
+
color: var(--text-dim);
|
|
193
|
+
font-size: 0.58rem;
|
|
194
|
+
font-family: var(--font-mono);
|
|
195
|
+
text-transform: uppercase;
|
|
196
|
+
}
|
|
197
|
+
.kc-flow p {
|
|
198
|
+
margin: 0;
|
|
199
|
+
color: var(--text-muted);
|
|
200
|
+
font-size: 0.66rem;
|
|
201
|
+
line-height: 1.3;
|
|
202
|
+
word-break: break-word;
|
|
203
|
+
}
|
|
204
|
+
.kc-meta {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
gap: 4px;
|
|
208
|
+
margin-top: 7px;
|
|
209
|
+
}
|
|
210
|
+
.kc-meta span {
|
|
211
|
+
max-width: 100%;
|
|
212
|
+
overflow: hidden;
|
|
213
|
+
text-overflow: ellipsis;
|
|
214
|
+
white-space: nowrap;
|
|
215
|
+
color: var(--text-dim);
|
|
216
|
+
background: var(--bg-panel);
|
|
217
|
+
border: 1px solid rgba(255,255,255,0.06);
|
|
218
|
+
border-radius: 3px;
|
|
219
|
+
padding: 1px 5px;
|
|
220
|
+
font-size: 0.6rem;
|
|
221
|
+
font-family: var(--font-mono);
|
|
222
|
+
}
|
|
173
223
|
.kc-worker { font-size: 0.65rem; color: var(--text-dim); font-family: var(--font-mono); margin-top: 3px; display: block; }
|
|
174
224
|
.kanban-overflow { font-size: 0.7rem; color: var(--text-dim); text-align: center; padding: 4px; }
|
|
175
225
|
.kanban-empty { color: var(--text-dim); font-size: 0.8rem; padding: 20px; text-align: center; }
|
|
@@ -524,16 +524,19 @@ function renderFirstRunHero() {
|
|
|
524
524
|
}
|
|
525
525
|
btn.disabled = true; btn.textContent = 'Hiring…';
|
|
526
526
|
setFirstRunStatus('Submitting hire request…');
|
|
527
|
+
const input = card.querySelector('#frh-goal-input');
|
|
528
|
+
const goal = (input?.value || `Use ${btn.dataset.specname || 'this specialist'} to inspect ${S.workspace?.repoName || 'this repo'} and report the next useful task`).trim();
|
|
527
529
|
const result = await post('/api/synapse/hire', {
|
|
528
530
|
specialistId: btn.dataset.specid,
|
|
529
531
|
name: btn.dataset.specname,
|
|
530
532
|
budgetCapUsd: 2,
|
|
531
533
|
fireFirstSortie: true,
|
|
534
|
+
goal,
|
|
532
535
|
});
|
|
533
536
|
if (result.ok) {
|
|
534
537
|
setFirstRunStatus(readiness.notes.some(note => note.tone === 'warn')
|
|
535
|
-
? 'Hired
|
|
536
|
-
: 'Hired
|
|
538
|
+
? 'Hired and first goal queued. Fallback storage warning is still active.'
|
|
539
|
+
: 'Hired and first goal queued.');
|
|
537
540
|
try { localStorage.setItem(FIRST_RUN_KEY, '1'); } catch { /* ignore */ }
|
|
538
541
|
bustCache('/api/synapse/health');
|
|
539
542
|
setTimeout(load, 800);
|
|
@@ -26,6 +26,25 @@ function statusChip(s) {
|
|
|
26
26
|
if (['failed','error','blocked','zombie','dead'].includes(v)) return chipHtml(s,'bad');
|
|
27
27
|
return chipHtml(s);
|
|
28
28
|
}
|
|
29
|
+
function firstText(...values) {
|
|
30
|
+
for (const value of values) {
|
|
31
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
function clipText(value, max=140) {
|
|
36
|
+
const text = String(value ?? '').trim();
|
|
37
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
38
|
+
}
|
|
39
|
+
function lifecycleLabel(card, payload) {
|
|
40
|
+
const status = String(card?.status ?? payload?.status ?? '').toLowerCase();
|
|
41
|
+
if (status === 'done') return 'Completed';
|
|
42
|
+
if (status === 'failed') return 'Failed';
|
|
43
|
+
if (status === 'blocked') return 'Blocked';
|
|
44
|
+
if (status === 'review') return 'In review';
|
|
45
|
+
if (status === 'wip' || status === 'claimed') return 'Running';
|
|
46
|
+
return 'Queued';
|
|
47
|
+
}
|
|
29
48
|
|
|
30
49
|
/* ── Active-dispatch state (push-mode) ── */
|
|
31
50
|
// Keyed by runId. Entries are created on dispatch.started and removed on complete/failed/cancelled.
|
|
@@ -202,7 +221,7 @@ function renderKanban() {
|
|
|
202
221
|
if (total === 0) {
|
|
203
222
|
el.innerHTML = `<div class="kanban-empty kanban-empty-panel">
|
|
204
223
|
<div class="kanban-empty-title">No workforce jobs yet</div>
|
|
205
|
-
<div class="kanban-empty-sub">Run a goal or hire a specialist;
|
|
224
|
+
<div class="kanban-empty-sub">Run a dashboard goal or hire a specialist with a first goal; this board only shows real assigned work.</div>
|
|
206
225
|
</div>`;
|
|
207
226
|
return;
|
|
208
227
|
}
|
|
@@ -214,7 +233,7 @@ function renderKanban() {
|
|
|
214
233
|
const MAX_VISIBLE = 8;
|
|
215
234
|
const visible = cards.slice(0, MAX_VISIBLE);
|
|
216
235
|
const overflow = cards.length - MAX_VISIBLE;
|
|
217
|
-
const cardsHtml = visible.map(c => _buildKanbanCard(c)).join('');
|
|
236
|
+
const cardsHtml = visible.map(c => _buildKanbanCard(c, lane)).join('');
|
|
218
237
|
const overflowHtml = overflow > 0
|
|
219
238
|
? `<div class="kanban-overflow">+${overflow} more</div>`
|
|
220
239
|
: '';
|
|
@@ -231,14 +250,31 @@ function renderKanban() {
|
|
|
231
250
|
<div class="kanban-scroll">${lanesHtml || '<div class="kanban-empty">No visible lanes for the selected filters.</div>'}</div>`;
|
|
232
251
|
}
|
|
233
252
|
|
|
234
|
-
function _buildKanbanCard(c) {
|
|
253
|
+
function _buildKanbanCard(c, lane) {
|
|
254
|
+
const payload = c.payload && typeof c.payload === 'object' ? c.payload : {};
|
|
235
255
|
const shortId = String(c.id ?? '').slice(0, 8);
|
|
236
256
|
const title = esc(String(c.title ?? shortId).slice(0, 60));
|
|
257
|
+
const goal = clipText(firstText(payload.goal, c.title), 120);
|
|
258
|
+
const expected = clipText(firstText(payload.expectedBehavior, 'Worker owns this dashboard job and closes it with proof.'), 132);
|
|
259
|
+
const actual = clipText(firstText(payload.actualStatus, lifecycleLabel({ status: lane }, payload)), 132);
|
|
260
|
+
const runId = firstText(payload.runId, payload.completedRunId);
|
|
261
|
+
const mode = firstText(payload.mode, c.client);
|
|
237
262
|
const wid = c.workerId ? `<span class="kc-worker">→ ${esc(String(c.workerId).slice(0, 8))}</span>` : '';
|
|
238
263
|
const pri = c.priority != null ? `<span class="kc-pri">${c.priority}</span>` : '';
|
|
239
|
-
|
|
264
|
+
const meta = [
|
|
265
|
+
mode ? `<span>${esc(mode)}</span>` : '',
|
|
266
|
+
runId ? `<span>${esc(String(runId).slice(0, 12))}</span>` : '',
|
|
267
|
+
c.tokensUsed ? `<span>${fmtNum(c.tokensUsed)}t</span>` : '',
|
|
268
|
+
].filter(Boolean).join('');
|
|
269
|
+
return `<div class="kanban-card" title="${esc([title, goal, actual].filter(Boolean).join(' · '))}">
|
|
240
270
|
<div class="kc-top">${pri}<span class="kc-id">${shortId}</span></div>
|
|
241
271
|
<div class="kc-title">${title}</div>
|
|
272
|
+
${goal ? `<div class="kc-goal">${esc(goal)}</div>` : ''}
|
|
273
|
+
<div class="kc-flow">
|
|
274
|
+
<div><span>Expected</span><p>${esc(expected)}</p></div>
|
|
275
|
+
<div><span>Actual</span><p>${esc(actual)}</p></div>
|
|
276
|
+
</div>
|
|
277
|
+
${meta ? `<div class="kc-meta">${meta}</div>` : ''}
|
|
242
278
|
${wid}
|
|
243
279
|
</div>`;
|
|
244
280
|
}
|
|
@@ -470,6 +506,7 @@ function _formatHireFailure(result) {
|
|
|
470
506
|
function _showHireSheet(specialistId, name) {
|
|
471
507
|
const { opOptions, teamOptions } = _buildHireSelectors();
|
|
472
508
|
const readiness = _getHireReadiness();
|
|
509
|
+
const defaultGoal = `Use ${name} to inspect ${S.workspace?.repoName || 'this repo'} and identify the next useful task.`;
|
|
473
510
|
const noticesHtml = readiness.notes.length
|
|
474
511
|
? `<div class="dsec" style="margin-bottom:var(--space-4)">
|
|
475
512
|
${readiness.notes.map(note => `<div style="padding:8px 10px;border:1px solid ${note.tone === 'bad' ? '#ff5f5733' : '#ffd14d33'};background:${note.tone === 'bad' ? '#ff5f5712' : '#ffd14d12'};color:${note.tone === 'bad' ? 'var(--bad)' : '#ffd14d'};border-radius:8px;font-size:var(--text-sm);margin-bottom:8px">${esc(note.text)}</div>`).join('')}
|
|
@@ -485,7 +522,10 @@ function _showHireSheet(specialistId, name) {
|
|
|
485
522
|
<label style="display:block;margin-bottom:var(--space-2);font-size:var(--text-sm);color:var(--text-muted)">Budget cap (USD)</label>
|
|
486
523
|
<input type="number" id="hire-budget" value="2.00" step="0.50" min="0.50" max="50"
|
|
487
524
|
style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4)">
|
|
488
|
-
<
|
|
525
|
+
<label style="display:block;margin-bottom:var(--space-2);font-size:var(--text-sm);color:var(--text-muted)">First goal</label>
|
|
526
|
+
<textarea id="hire-goal" rows="3"
|
|
527
|
+
style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4);resize:vertical">${esc(defaultGoal)}</textarea>
|
|
528
|
+
<button class="btn btn-primary" id="hire-confirm-btn" data-specid="${esc(specialistId)}" data-specname="${esc(name)}" ${readiness.blocked ? 'disabled title="Synapse is not ready"' : ''}>Hire and Run Goal</button>
|
|
489
529
|
<div id="hire-inline-status" style="margin-top:var(--space-3);font-size:var(--text-sm);color:${readiness.blocked ? 'var(--bad)' : 'var(--text-muted)'}">${readiness.blocked ? esc(readiness.notes[0]?.text || 'Synapse is not ready for hires.') : ''}</div>
|
|
490
530
|
</div>` });
|
|
491
531
|
|
|
@@ -500,6 +540,7 @@ function _showHireSheet(specialistId, name) {
|
|
|
500
540
|
const budget = parseFloat(document.getElementById('hire-budget')?.value||'2');
|
|
501
541
|
const reportsToVal = (document.getElementById('hire-reports-to')?.value||'').trim()||null;
|
|
502
542
|
const teamVal = (document.getElementById('hire-team')?.value||'').trim()||null;
|
|
543
|
+
const goal = (document.getElementById('hire-goal')?.value || defaultGoal).trim() || defaultGoal;
|
|
503
544
|
const inlineStatus = document.getElementById('hire-inline-status');
|
|
504
545
|
if (inlineStatus) {
|
|
505
546
|
inlineStatus.textContent = readiness.notes.some(note => note.tone === 'warn')
|
|
@@ -518,6 +559,7 @@ function _showHireSheet(specialistId, name) {
|
|
|
518
559
|
reportsToOperativeId: reportsToVal,
|
|
519
560
|
strikeTeamId: teamVal,
|
|
520
561
|
fireFirstSortie: true,
|
|
562
|
+
goal,
|
|
521
563
|
}, { optimistic: optimisticRecord });
|
|
522
564
|
|
|
523
565
|
// Immediately open drawer with pending state.
|
|
@@ -529,6 +571,7 @@ function _showHireSheet(specialistId, name) {
|
|
|
529
571
|
<div class="drow"><span class="drow-k">Reports to</span><span class="drow-v">${esc(reportsToVal || 'team lead')}</span></div>
|
|
530
572
|
<div class="drow"><span class="drow-k">Strike team</span><span class="drow-v">${esc(teamVal || 'solo')}</span></div>
|
|
531
573
|
<div class="drow"><span class="drow-k">Budget cap</span><span class="drow-v">$${budget.toFixed(2)}</span></div>
|
|
574
|
+
<div class="drow"><span class="drow-k">Goal</span><span class="drow-v">${esc(clipText(goal, 120))}</span></div>
|
|
532
575
|
</div>` });
|
|
533
576
|
|
|
534
577
|
// Reconcile when real response arrives.
|
|
@@ -537,8 +580,8 @@ function _showHireSheet(specialistId, name) {
|
|
|
537
580
|
if (real.ok) {
|
|
538
581
|
if (msg) {
|
|
539
582
|
msg.textContent = readiness.notes.some(note => note.tone === 'warn')
|
|
540
|
-
? 'Hired
|
|
541
|
-
: 'Hired
|
|
583
|
+
? 'Hired and first goal queued. Fallback storage warning is still active.'
|
|
584
|
+
: 'Hired and first goal queued.';
|
|
542
585
|
}
|
|
543
586
|
const idRow = document.getElementById('hire-id-row');
|
|
544
587
|
const idVal = document.getElementById('hire-id-val');
|
|
@@ -564,7 +607,7 @@ function _showHireSheet(specialistId, name) {
|
|
|
564
607
|
drawerBody.appendChild(stripDiv);
|
|
565
608
|
}
|
|
566
609
|
const warmupKey = `__warmup__:${operativeId}`;
|
|
567
|
-
const warmupRun = { runId: warmupKey, operativeId, status: 'queued', tokens: 0, costUsd: 0, messages: ['Adapter pending; waiting for dispatch.started…'], filesChanged: [], pendingAdapter: true };
|
|
610
|
+
const warmupRun = { runId: warmupKey, operativeId, status: 'queued', tokens: 0, costUsd: 0, messages: [`Goal queued: ${goal}`, 'Adapter pending; waiting for dispatch.started…'], filesChanged: [], pendingAdapter: true };
|
|
568
611
|
_dispatches.set(warmupKey, warmupRun);
|
|
569
612
|
stripDiv.innerHTML = _buildDispatchStrip(warmupRun);
|
|
570
613
|
}
|
|
@@ -78,6 +78,9 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
|
|
|
78
78
|
const specialistId = typeof body.specialistId === 'string'
|
|
79
79
|
? body.specialistId
|
|
80
80
|
: (typeof body.specialist === 'string' ? body.specialist : undefined);
|
|
81
|
+
const requestedGoal = typeof body.goal === 'string' && body.goal.trim()
|
|
82
|
+
? body.goal.trim()
|
|
83
|
+
: null;
|
|
81
84
|
if (!name && !specialistId) {
|
|
82
85
|
ctx.respondJson(res, { error: 'name-or-specialistId-required' }, 400);
|
|
83
86
|
return true;
|
|
@@ -117,13 +120,23 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
|
|
|
117
120
|
// Non-blocking: the dashboard gets an immediate hire response while the
|
|
118
121
|
// Workforce job and run lifecycle continue through SSE and kanban state.
|
|
119
122
|
if (body.fireFirstSortie === true && operativeId) {
|
|
120
|
-
|
|
123
|
+
const displayName = result?.name ?? specialistProfile?.name ?? name ?? specialistId ?? operativeId;
|
|
124
|
+
const repoName = path.basename(ctx.repoRoot ?? process.cwd());
|
|
125
|
+
const firstGoal = requestedGoal
|
|
126
|
+
?? `Use ${displayName} to inspect ${repoName}, report what is working, and name the next useful task.`;
|
|
127
|
+
firstDispatch = {
|
|
128
|
+
queued: true,
|
|
129
|
+
operativeId,
|
|
130
|
+
mode: 'pending',
|
|
131
|
+
goal: firstGoal,
|
|
132
|
+
expectedBehavior: 'Hire creates an operative, assigns this goal as a Workforce job, and moves the job through dispatch or orchestrator fallback.',
|
|
133
|
+
};
|
|
121
134
|
runDashboardAgentControl(ctx, new URL(`${baseUrl}/api/workforce/agents/${encodeURIComponent(operativeId)}/dispatch`), {
|
|
122
135
|
operativeId,
|
|
123
136
|
specialistId: result?.specialistId ?? specialistId ?? null,
|
|
124
137
|
budgetCapUsd: result?.budgetCapUsd ?? 0.50,
|
|
125
|
-
title: `
|
|
126
|
-
goal:
|
|
138
|
+
title: requestedGoal ? `Dashboard goal: ${firstGoal.slice(0, 64)}` : `First sortie: ${displayName}`,
|
|
139
|
+
goal: firstGoal,
|
|
127
140
|
}).then(r => {
|
|
128
141
|
nexusEventBus.emit('dashboard.action', {
|
|
129
142
|
action: 'synapse.first-sortie.dispatched',
|
|
@@ -77,6 +77,11 @@ export async function runDashboardAgentControl(ctx, url, input) {
|
|
|
77
77
|
operativeId,
|
|
78
78
|
specialistId: input.specialistId ?? worker.role,
|
|
79
79
|
source: 'dashboard-agent-control',
|
|
80
|
+
expectedBehavior: 'A real Workforce job is owned by this operative, routed through dispatch when available, then moved to done or failed with proof.',
|
|
81
|
+
actualStatus: 'Job enqueued and claimed; waiting for dispatch adapter.',
|
|
82
|
+
lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired'],
|
|
83
|
+
mode: input.preferredRuntime ? `preferred:${input.preferredRuntime}` : 'dispatch',
|
|
84
|
+
assignedAt: Date.now(),
|
|
80
85
|
},
|
|
81
86
|
});
|
|
82
87
|
const claim = wf.claimJob(job.id, worker.id, 15 * 60_000);
|
|
@@ -105,12 +110,29 @@ export async function runDashboardAgentControl(ctx, url, input) {
|
|
|
105
110
|
crGraphContext,
|
|
106
111
|
storeMemory: async (content, priority, tags) => { memory?.store?.(content, priority, tags); },
|
|
107
112
|
});
|
|
113
|
+
wf.patchJobPayload(job.id, {
|
|
114
|
+
runId: dispatch.runId,
|
|
115
|
+
invoker: dispatch.invoker,
|
|
116
|
+
mode: 'dispatch',
|
|
117
|
+
actualStatus: 'Dispatch accepted; waiting for runtime completion.',
|
|
118
|
+
lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted'],
|
|
119
|
+
});
|
|
108
120
|
const cleanup = [
|
|
109
121
|
nexusEventBus.on('dispatch.complete', (payload) => {
|
|
110
122
|
if (payload.runId !== dispatch.runId)
|
|
111
123
|
return;
|
|
112
124
|
wf.recordTokens(job.id, worker.id, Number(payload.tokensUsed ?? 0));
|
|
113
|
-
|
|
125
|
+
const ok = payload.success !== false;
|
|
126
|
+
wf.patchJobPayload(job.id, {
|
|
127
|
+
actualStatus: ok ? 'Completed through dispatch runtime.' : 'Dispatch runtime reported failure.',
|
|
128
|
+
completedRunId: dispatch.runId,
|
|
129
|
+
tokensUsed: Number(payload.tokensUsed ?? 0),
|
|
130
|
+
filesChanged: Array.isArray(payload.filesChanged) ? payload.filesChanged : [],
|
|
131
|
+
lifecycle: ok
|
|
132
|
+
? ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-complete']
|
|
133
|
+
: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-failed'],
|
|
134
|
+
});
|
|
135
|
+
if (!ok)
|
|
114
136
|
wf.failJob(job.id, worker.id);
|
|
115
137
|
else
|
|
116
138
|
wf.completeJob(job.id, worker.id);
|
|
@@ -119,6 +141,12 @@ export async function runDashboardAgentControl(ctx, url, input) {
|
|
|
119
141
|
nexusEventBus.on('dispatch.failed', (payload) => {
|
|
120
142
|
if (payload.runId !== dispatch.runId)
|
|
121
143
|
return;
|
|
144
|
+
wf.patchJobPayload(job.id, {
|
|
145
|
+
actualStatus: 'Dispatch failed before completion.',
|
|
146
|
+
runId: dispatch.runId,
|
|
147
|
+
failureReason: payload.error ?? 'dispatch.failed',
|
|
148
|
+
lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-accepted', 'dispatch-failed'],
|
|
149
|
+
});
|
|
122
150
|
wf.failJob(job.id, worker.id);
|
|
123
151
|
cleanup.forEach((unsubscribe) => unsubscribe());
|
|
124
152
|
}),
|
|
@@ -127,6 +155,12 @@ export async function runDashboardAgentControl(ctx, url, input) {
|
|
|
127
155
|
}
|
|
128
156
|
catch (dispatchError) {
|
|
129
157
|
if (!orchestrator) {
|
|
158
|
+
wf.patchJobPayload(job.id, {
|
|
159
|
+
actualStatus: 'Dispatch failed and no local orchestrator fallback was available.',
|
|
160
|
+
failureReason: dispatchError?.message ?? String(dispatchError),
|
|
161
|
+
mode: 'dispatch-unavailable',
|
|
162
|
+
lifecycle: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed'],
|
|
163
|
+
});
|
|
130
164
|
wf.failJob(job.id, worker.id);
|
|
131
165
|
throw dispatchError;
|
|
132
166
|
}
|
|
@@ -137,6 +171,18 @@ export async function runDashboardAgentControl(ctx, url, input) {
|
|
|
137
171
|
specialistId: input.specialistId ?? worker.role,
|
|
138
172
|
operativeId,
|
|
139
173
|
});
|
|
174
|
+
wf.patchJobPayload(job.id, {
|
|
175
|
+
runId: run.runId,
|
|
176
|
+
mode: 'orchestrator-fallback',
|
|
177
|
+
invoker: 'nexus-orchestrator',
|
|
178
|
+
fallbackReason: dispatchError?.message ?? String(dispatchError),
|
|
179
|
+
actualStatus: run.state === 'failed'
|
|
180
|
+
? 'Local orchestrator fallback failed.'
|
|
181
|
+
: 'Completed through local orchestrator fallback.',
|
|
182
|
+
lifecycle: run.state === 'failed'
|
|
183
|
+
? ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed', 'orchestrator-fallback-failed']
|
|
184
|
+
: ['worker-materialized', 'job-enqueued', 'claim-acquired', 'dispatch-failed', 'orchestrator-fallback-complete'],
|
|
185
|
+
});
|
|
140
186
|
if (run.state === 'failed')
|
|
141
187
|
wf.failJob(job.id, worker.id);
|
|
142
188
|
else
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* (no DROP, no rename). Migration copies live data on first init.
|
|
6
6
|
*/
|
|
7
7
|
import type { WorkforceDb } from '../types.js';
|
|
8
|
-
export declare const WORKFORCE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS workforce_workers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n role TEXT,\n client TEXT NOT NULL DEFAULT 'unknown',\n status TEXT NOT NULL DEFAULT 'idle'\n CHECK(status IN ('idle','active','stale','retired')),\n budget_cap_usd REAL NOT NULL DEFAULT 10.0,\n spent_usd REAL NOT NULL DEFAULT 0.0,\n score REAL NOT NULL DEFAULT 0.0,\n last_heartbeat INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_jobs (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n tier TEXT NOT NULL DEFAULT 'tactical'\n CHECK(tier IN ('strategy','tactical')),\n status TEXT NOT NULL DEFAULT 'ready'\n CHECK(status IN ('backlog','ready','claimed','wip','blocked','review','done','failed','cancelled')),\n priority INTEGER NOT NULL DEFAULT 5,\n worker_id TEXT,\n parent_id TEXT,\n depends_on TEXT NOT NULL DEFAULT '[]',\n budget_cap_usd REAL,\n tokens_used INTEGER NOT NULL DEFAULT 0,\n payload TEXT NOT NULL DEFAULT '{}',\n client TEXT NOT NULL DEFAULT 'unknown',\n claimed_at INTEGER,\n completed_at INTEGER,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_claims (\n id TEXT PRIMARY KEY,\n job_id TEXT NOT NULL REFERENCES workforce_jobs(id) ON DELETE CASCADE,\n worker_id TEXT NOT NULL,\n acquired_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000),\n released_at INTEGER,\n lease_ms INTEGER NOT NULL DEFAULT 300000,\n expires_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_status ON workforce_jobs(status, priority);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_worker ON workforce_jobs(worker_id, status);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_parent ON workforce_jobs(parent_id);\nCREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status, last_heartbeat);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);\n\
|
|
8
|
+
export declare const WORKFORCE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS workforce_workers (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n role TEXT,\n client TEXT NOT NULL DEFAULT 'unknown',\n status TEXT NOT NULL DEFAULT 'idle'\n CHECK(status IN ('idle','active','stale','retired')),\n budget_cap_usd REAL NOT NULL DEFAULT 10.0,\n spent_usd REAL NOT NULL DEFAULT 0.0,\n score REAL NOT NULL DEFAULT 0.0,\n last_heartbeat INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_jobs (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n tier TEXT NOT NULL DEFAULT 'tactical'\n CHECK(tier IN ('strategy','tactical')),\n status TEXT NOT NULL DEFAULT 'ready'\n CHECK(status IN ('backlog','ready','claimed','wip','blocked','review','done','failed','cancelled')),\n priority INTEGER NOT NULL DEFAULT 5,\n worker_id TEXT,\n parent_id TEXT,\n depends_on TEXT NOT NULL DEFAULT '[]',\n budget_cap_usd REAL,\n tokens_used INTEGER NOT NULL DEFAULT 0,\n payload TEXT NOT NULL DEFAULT '{}',\n client TEXT NOT NULL DEFAULT 'unknown',\n claimed_at INTEGER,\n completed_at INTEGER,\n created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000)\n);\n\nCREATE TABLE IF NOT EXISTS workforce_claims (\n id TEXT PRIMARY KEY,\n job_id TEXT NOT NULL REFERENCES workforce_jobs(id) ON DELETE CASCADE,\n worker_id TEXT NOT NULL,\n acquired_at INTEGER NOT NULL DEFAULT (strftime('%s','now')*1000),\n released_at INTEGER,\n lease_ms INTEGER NOT NULL DEFAULT 300000,\n expires_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_status ON workforce_jobs(status, priority);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_worker ON workforce_jobs(worker_id, status);\nCREATE INDEX IF NOT EXISTS idx_wf_jobs_parent ON workforce_jobs(parent_id);\nCREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status, last_heartbeat);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);\nCREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);\n\nDROP VIEW IF EXISTS vw_kanban;\n\nCREATE VIEW vw_kanban AS\n SELECT\n status AS lane,\n id, title, worker_id, priority, tokens_used,\n budget_cap_usd, payload, client, created_at, claimed_at, completed_at\n FROM workforce_jobs\n WHERE\n -- Active lanes: always show\n status NOT IN ('done', 'failed', 'cancelled')\n OR\n -- Terminal lanes: only show last 7 days to prevent unbounded growth\n completed_at > (strftime('%s','now') - 7*86400) * 1000\n ORDER BY\n CASE status\n WHEN 'backlog' THEN 1\n WHEN 'ready' THEN 2\n WHEN 'claimed' THEN 3\n WHEN 'wip' THEN 4\n WHEN 'blocked' THEN 5\n WHEN 'review' THEN 6\n WHEN 'done' THEN 7\n WHEN 'failed' THEN 8\n WHEN 'cancelled' THEN 9\n ELSE 10\n END,\n priority,\n created_at;\n\nCREATE VIEW IF NOT EXISTS vw_worker_load AS\n SELECT\n w.id, w.name, w.role, w.status, w.client,\n COUNT(j.id) AS active_jobs,\n COALESCE(SUM(j.tokens_used), 0) AS total_tokens,\n w.spent_usd,\n w.budget_cap_usd,\n w.last_heartbeat\n FROM workforce_workers w\n LEFT JOIN workforce_jobs j\n ON j.worker_id = w.id AND j.status IN ('claimed','wip','review')\n GROUP BY w.id;\n";
|
|
9
9
|
export declare function initWorkforceSchema(db: WorkforceDb): void;
|
|
@@ -56,11 +56,13 @@ CREATE INDEX IF NOT EXISTS idx_wf_workers_status ON workforce_workers(status
|
|
|
56
56
|
CREATE INDEX IF NOT EXISTS idx_wf_claims_job ON workforce_claims(job_id, released_at);
|
|
57
57
|
CREATE INDEX IF NOT EXISTS idx_wf_claims_expiry ON workforce_claims(job_id, expires_at, released_at);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
DROP VIEW IF EXISTS vw_kanban;
|
|
60
|
+
|
|
61
|
+
CREATE VIEW vw_kanban AS
|
|
60
62
|
SELECT
|
|
61
63
|
status AS lane,
|
|
62
64
|
id, title, worker_id, priority, tokens_used,
|
|
63
|
-
budget_cap_usd, client, created_at, claimed_at
|
|
65
|
+
budget_cap_usd, payload, client, created_at, claimed_at, completed_at
|
|
64
66
|
FROM workforce_jobs
|
|
65
67
|
WHERE
|
|
66
68
|
-- Active lanes: always show
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* All MCP tools go through getWorkforce(root).
|
|
6
6
|
*/
|
|
7
7
|
import { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore } from './workers.js';
|
|
8
|
-
import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob } from './jobs.js';
|
|
8
|
+
import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload } from './jobs.js';
|
|
9
9
|
import { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks } from './claim-lock.js';
|
|
10
10
|
import { getKanbanBoard, getLaneJobs } from './kanban-view.js';
|
|
11
11
|
import { reconcileDag } from './dag.js';
|
|
12
12
|
import type { WorkforceDb, Worker, Job, Claim, KanbanBoard, KanbanCard, JobStatus, RegisterWorkerInput, EnqueueJobInput } from './types.js';
|
|
13
13
|
export type { Worker, Job, Claim, KanbanBoard, KanbanCard, JobStatus, WorkforceDb };
|
|
14
14
|
export { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore };
|
|
15
|
-
export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob };
|
|
15
|
+
export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload };
|
|
16
16
|
export { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks };
|
|
17
17
|
export { getKanbanBoard, getLaneJobs };
|
|
18
18
|
export { reconcileDag };
|
|
@@ -37,6 +37,7 @@ export declare class WorkforceRuntime {
|
|
|
37
37
|
advanceToWip(jobId: string, workerId: string): void;
|
|
38
38
|
advanceToReview(jobId: string, workerId: string): void;
|
|
39
39
|
recordTokens(jobId: string, workerId: string, tokens: number): void;
|
|
40
|
+
patchJobPayload(jobId: string, patch: Record<string, unknown>): Job | null;
|
|
40
41
|
completeJob(jobId: string, workerId: string): Job | null;
|
|
41
42
|
failJob(jobId: string, workerId: string): Job | null;
|
|
42
43
|
blockJob(jobId: string, workerId: string): Job | null;
|
package/dist/workforce/index.js
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { openWorkforceDb } from './db/client.js';
|
|
9
9
|
import { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore } from './workers.js';
|
|
10
|
-
import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob } from './jobs.js';
|
|
10
|
+
import { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload } from './jobs.js';
|
|
11
11
|
import { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks } from './claim-lock.js';
|
|
12
12
|
import { getKanbanBoard, getLaneJobs } from './kanban-view.js';
|
|
13
13
|
import { reconcileDag } from './dag.js';
|
|
14
14
|
export { registerWorker, getWorker, listWorkers, listCompanies, heartbeat, retireWorker, markStaleWorkers, recordSpend, adjustScore };
|
|
15
|
-
export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob };
|
|
15
|
+
export { enqueueJob, getJob, listJobs, claimNextReady, advanceToWip, advanceToReview, recordTokens, completeJob, failJob, blockJob, cancelJob, patchJobPayload };
|
|
16
16
|
export { claimJob, renewClaim, releaseClaim, reclaimExpiredLocks };
|
|
17
17
|
export { getKanbanBoard, getLaneJobs };
|
|
18
18
|
export { reconcileDag };
|
|
@@ -46,6 +46,7 @@ export class WorkforceRuntime {
|
|
|
46
46
|
advanceToWip(jobId, workerId) { advanceToWip(this.db, jobId, workerId); }
|
|
47
47
|
advanceToReview(jobId, workerId) { advanceToReview(this.db, jobId, workerId); }
|
|
48
48
|
recordTokens(jobId, workerId, tokens) { recordTokens(this.db, jobId, workerId, tokens); }
|
|
49
|
+
patchJobPayload(jobId, patch) { return patchJobPayload(this.db, jobId, patch); }
|
|
49
50
|
completeJob(jobId, workerId) { return completeJob(this.db, jobId, workerId); }
|
|
50
51
|
failJob(jobId, workerId) { return failJob(this.db, jobId, workerId); }
|
|
51
52
|
blockJob(jobId, workerId) { return blockJob(this.db, jobId, workerId); }
|
package/dist/workforce/jobs.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { WorkforceDb, Job, JobStatus, EnqueueJobInput } from './types.js';
|
|
8
8
|
import type { Claim } from './types.js';
|
|
9
9
|
export declare function enqueueJob(db: WorkforceDb, input: EnqueueJobInput): Job;
|
|
10
|
+
export declare function patchJobPayload(db: WorkforceDb, jobId: string, patch: Record<string, unknown>): Job | null;
|
|
10
11
|
export declare function getJob(db: WorkforceDb, id: string): Job | null;
|
|
11
12
|
export declare function listJobs(db: WorkforceDb, status?: JobStatus, limit?: number, companyId?: string): Job[];
|
|
12
13
|
export declare function claimNextReady(db: WorkforceDb, workerId: string, leaseMs?: number): Claim | null;
|
package/dist/workforce/jobs.js
CHANGED
|
@@ -55,6 +55,13 @@ export function enqueueJob(db, input) {
|
|
|
55
55
|
reconcileDag(db);
|
|
56
56
|
return getJob(db, id);
|
|
57
57
|
}
|
|
58
|
+
export function patchJobPayload(db, jobId, patch) {
|
|
59
|
+
const job = getJob(db, jobId);
|
|
60
|
+
if (!job)
|
|
61
|
+
return null;
|
|
62
|
+
db.prepare(`UPDATE workforce_jobs SET payload = ? WHERE id = ?`).run(JSON.stringify({ ...(job.payload ?? {}), ...patch }), jobId);
|
|
63
|
+
return getJob(db, jobId);
|
|
64
|
+
}
|
|
58
65
|
// ─── Read ─────────────────────────────────────────────────────────────────────
|
|
59
66
|
export function getJob(db, id) {
|
|
60
67
|
const row = db.prepare('SELECT * FROM workforce_jobs WHERE id = ?').get(id);
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
* Reads from vw_kanban SQL view; no business logic here.
|
|
4
4
|
*/
|
|
5
5
|
const ALL_LANES = ['backlog', 'ready', 'claimed', 'wip', 'blocked', 'review', 'done', 'failed', 'cancelled'];
|
|
6
|
+
function parsePayload(raw) {
|
|
7
|
+
if (!raw)
|
|
8
|
+
return {};
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
6
17
|
export function getKanbanBoard(db, client) {
|
|
7
18
|
const rows = client
|
|
8
19
|
? db.prepare(`SELECT * FROM vw_kanban WHERE client = ?`).all(client)
|
|
@@ -18,9 +29,11 @@ export function getKanbanBoard(db, client) {
|
|
|
18
29
|
priority: r.priority,
|
|
19
30
|
tokensUsed: r.tokens_used,
|
|
20
31
|
budgetCapUsd: r.budget_cap_usd,
|
|
32
|
+
payload: parsePayload(r.payload),
|
|
21
33
|
client: r.client,
|
|
22
34
|
createdAt: r.created_at,
|
|
23
35
|
claimedAt: r.claimed_at,
|
|
36
|
+
completedAt: r.completed_at,
|
|
24
37
|
});
|
|
25
38
|
}
|
|
26
39
|
return {
|
|
@@ -38,8 +51,10 @@ export function getLaneJobs(db, lane) {
|
|
|
38
51
|
priority: r.priority,
|
|
39
52
|
tokensUsed: r.tokens_used,
|
|
40
53
|
budgetCapUsd: r.budget_cap_usd,
|
|
54
|
+
payload: parsePayload(r.payload),
|
|
41
55
|
client: r.client,
|
|
42
56
|
createdAt: r.created_at,
|
|
43
57
|
claimedAt: r.claimed_at,
|
|
58
|
+
completedAt: r.completed_at,
|
|
44
59
|
}));
|
|
45
60
|
}
|
|
@@ -78,9 +78,11 @@ export interface KanbanCard {
|
|
|
78
78
|
priority: number;
|
|
79
79
|
tokensUsed: number;
|
|
80
80
|
budgetCapUsd: number | null;
|
|
81
|
+
payload: Record<string, unknown>;
|
|
81
82
|
client: string;
|
|
82
83
|
createdAt: number;
|
|
83
84
|
claimedAt: number | null;
|
|
85
|
+
completedAt: number | null;
|
|
84
86
|
}
|
|
85
87
|
export interface KanbanBoard {
|
|
86
88
|
lanes: Record<JobStatus, KanbanCard[]>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexus-prime",
|
|
3
|
-
"version": "7.9.
|
|
3
|
+
"version": "7.9.30",
|
|
4
4
|
"description": "Local-first MCP control plane for coding agents with bootstrap-orchestrate execution, memory fabric, token budgeting, and worktree-backed swarms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|