@yeaft/webchat-agent 1.0.130 → 1.0.132
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/package.json +1 -1
- package/yeaft/config.js +6 -3
- package/yeaft/tools/create-work-item.js +3 -1
- package/yeaft/work-center/assignment.js +135 -0
- package/yeaft/work-center/bridge.js +50 -4
- package/yeaft/work-center/controller.js +25 -14
- package/yeaft/work-center/planner.js +66 -0
- package/yeaft/work-center/projection.js +97 -2
- package/yeaft/work-center/runner.js +36 -12
- package/yeaft/work-center/service.js +29 -5
- package/yeaft/work-center/settings.js +59 -0
- package/yeaft/work-center/store.js +45 -8
- package/yeaft/work-center/workflow.js +252 -32
package/package.json
CHANGED
package/yeaft/config.js
CHANGED
|
@@ -454,11 +454,14 @@ export function loadConfig(overrides = {}) {
|
|
|
454
454
|
const p = normalizeKnownProviderForRuntime(rawProvider);
|
|
455
455
|
const normalized = normalizeProviderModels(p);
|
|
456
456
|
for (const m of normalized) {
|
|
457
|
-
|
|
458
|
-
|
|
457
|
+
const ref = p.name ? `${p.name}/${m.id}` : m.id;
|
|
458
|
+
// A model id is only unique inside its provider. Keep provider-qualified
|
|
459
|
+
// duplicates so an explicit `provider/model` never disappears from the
|
|
460
|
+
// runtime catalog just because another provider exposes the same id.
|
|
461
|
+
if (!config.availableModels.some(am => am.ref === ref)) {
|
|
459
462
|
const entry = {
|
|
460
463
|
id: m.id,
|
|
461
|
-
ref
|
|
464
|
+
ref,
|
|
462
465
|
provider: p.name,
|
|
463
466
|
label: m.id,
|
|
464
467
|
};
|
|
@@ -60,7 +60,9 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
|
|
|
60
60
|
goal,
|
|
61
61
|
acceptanceCriteria: cleanCriteria(input.acceptanceCriteria),
|
|
62
62
|
workDir: typeof input.workDir === 'string' ? input.workDir.trim() : (ctx.cwd || ''),
|
|
63
|
-
|
|
63
|
+
// The Agent-local Work Center settings choose the default workflow and
|
|
64
|
+
// freeze its policy snapshot. Tool callers create the contract; they do
|
|
65
|
+
// not get to smuggle a different dispatch policy into it.
|
|
64
66
|
origin: {
|
|
65
67
|
sessionId,
|
|
66
68
|
messageId: ctx.inboundEnvelope?.msgId || null,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { parseModelRef } from '../models.js';
|
|
2
|
+
import { normalizeAssignmentPolicy, normalizeModelPolicy } from './workflow.js';
|
|
3
|
+
|
|
4
|
+
function policyError(message) {
|
|
5
|
+
const error = new Error(message);
|
|
6
|
+
error.retryable = false;
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CAPABILITY_TERMS = Object.freeze({
|
|
11
|
+
triage: ['triage', 'requirement', 'flow', 'product', 'strategy', 'cross-domain', 'analysis'],
|
|
12
|
+
implement: ['implement', 'engineer', 'developer', 'engineering', 'systems', 'code', 'execution'],
|
|
13
|
+
test: ['test', 'testing', 'quality', 'qa', 'verification', 'reliability'],
|
|
14
|
+
review: ['review', 'reviewer', 'refactor', 'architecture', 'code-smells', 'readability', 'maintainability'],
|
|
15
|
+
deliver: ['deliver', 'release', 'ship', 'git', 'engineering', 'systems', 'execution'],
|
|
16
|
+
research: ['research', 'science', 'analysis', 'evidence', 'investigation'],
|
|
17
|
+
write: ['write', 'writer', 'writing', 'documentation', 'editor', 'communication'],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function vpSearchText(vp) {
|
|
21
|
+
return [
|
|
22
|
+
vp.id,
|
|
23
|
+
vp.name,
|
|
24
|
+
vp.nameZh,
|
|
25
|
+
vp.role,
|
|
26
|
+
vp.roleZh,
|
|
27
|
+
vp.area,
|
|
28
|
+
...(Array.isArray(vp.aliases) ? vp.aliases : []),
|
|
29
|
+
...(Array.isArray(vp.traits) ? vp.traits : []),
|
|
30
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function capabilityScore(vp, capability) {
|
|
34
|
+
const needle = String(capability || '').trim().toLowerCase();
|
|
35
|
+
if (!needle) return 0;
|
|
36
|
+
const text = vpSearchText(vp);
|
|
37
|
+
const terms = CAPABILITY_TERMS[needle] || [needle];
|
|
38
|
+
let score = 0;
|
|
39
|
+
for (const term of terms) {
|
|
40
|
+
if (text.includes(term)) score += term === needle ? 6 : 2;
|
|
41
|
+
}
|
|
42
|
+
if (String(vp.area || '').toLowerCase() === needle) score += 4;
|
|
43
|
+
return score;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function priorVpIdsForSeparation(policy, priorRuns) {
|
|
47
|
+
const separatedTypes = new Set(policy.separateFromStageTypes || []);
|
|
48
|
+
if (separatedTypes.size === 0) return new Set();
|
|
49
|
+
return new Set((priorRuns || [])
|
|
50
|
+
.filter(run => separatedTypes.has(run.actionType) || separatedTypes.has(run.roleSnapshot?.actionType))
|
|
51
|
+
.map(run => run.vpSnapshot?.id)
|
|
52
|
+
.filter(Boolean));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function selectWorkItemVp({ policy: rawPolicy, stageType, vps, priorRuns = [] }) {
|
|
56
|
+
const policy = normalizeAssignmentPolicy(rawPolicy, stageType);
|
|
57
|
+
const all = Array.isArray(vps) ? vps.filter(vp => vp?.id) : [];
|
|
58
|
+
if (all.length === 0) throw policyError('Work Center has no available VPs');
|
|
59
|
+
const byId = new Map(all.map(vp => [vp.id, vp]));
|
|
60
|
+
const excluded = priorVpIdsForSeparation(policy, priorRuns);
|
|
61
|
+
|
|
62
|
+
if (policy.mode === 'fixed') {
|
|
63
|
+
const fixed = byId.get(policy.fixedVpId);
|
|
64
|
+
if (!fixed) throw policyError(`Fixed Work Center VP is unavailable: ${policy.fixedVpId}`);
|
|
65
|
+
if (excluded.has(fixed.id)) throw policyError(`Work Center separation policy excludes VP: ${fixed.id}`);
|
|
66
|
+
return { vp: fixed, reason: `fixed:${fixed.id}`, policy };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pool = policy.mode === 'pool'
|
|
70
|
+
? policy.candidateVpIds.map(id => byId.get(id)).filter(Boolean)
|
|
71
|
+
: all;
|
|
72
|
+
if (pool.length === 0) throw policyError('No configured Work Center VP candidates are available');
|
|
73
|
+
const eligible = pool.filter(vp => !excluded.has(vp.id));
|
|
74
|
+
if (eligible.length === 0) throw policyError('No Work Center VP satisfies the stage separation policy');
|
|
75
|
+
|
|
76
|
+
const ranked = eligible
|
|
77
|
+
.map(vp => ({ vp, score: capabilityScore(vp, policy.capability || stageType) }))
|
|
78
|
+
.sort((left, right) => right.score - left.score || left.vp.id.localeCompare(right.vp.id));
|
|
79
|
+
if (ranked[0].score === 0 && policy.mode === 'auto') {
|
|
80
|
+
throw policyError(`No Work Center VP matches capability: ${policy.capability || stageType}`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
vp: ranked[0].vp,
|
|
84
|
+
reason: `${policy.mode}:${policy.capability || stageType}:score=${ranked[0].score}`,
|
|
85
|
+
policy,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function availableModel(config, ref) {
|
|
90
|
+
const models = Array.isArray(config.availableModels) ? config.availableModels : [];
|
|
91
|
+
if (models.length === 0) return null;
|
|
92
|
+
const parsed = parseModelRef(ref);
|
|
93
|
+
return models.find(model => model.ref === ref)
|
|
94
|
+
|| models.find(model => !parsed.providerName && model.id === parsed.modelId)
|
|
95
|
+
|| null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveWorkItemModel(config, vp, rawPolicy) {
|
|
99
|
+
const policy = normalizeModelPolicy(rawPolicy);
|
|
100
|
+
let model;
|
|
101
|
+
let source;
|
|
102
|
+
if (policy.mode === 'specific') {
|
|
103
|
+
model = policy.model;
|
|
104
|
+
source = 'stage-specific';
|
|
105
|
+
} else if (policy.mode === 'primary') {
|
|
106
|
+
model = config.primaryModel || config.model || null;
|
|
107
|
+
source = 'agent-primary';
|
|
108
|
+
} else if (policy.mode === 'fast') {
|
|
109
|
+
model = config.fastModel || null;
|
|
110
|
+
source = 'agent-fast';
|
|
111
|
+
} else if (vp.modelHint === 'fast') {
|
|
112
|
+
model = config.fastModel || config.primaryModel || config.model || null;
|
|
113
|
+
source = config.fastModel ? 'vp-fast' : 'vp-fast-primary-fallback';
|
|
114
|
+
} else {
|
|
115
|
+
model = config.primaryModel || config.model || null;
|
|
116
|
+
source = vp.modelHint === 'primary' ? 'vp-primary' : 'agent-default';
|
|
117
|
+
}
|
|
118
|
+
if (!model) throw policyError(`Work Center model policy "${policy.mode}" has no configured model`);
|
|
119
|
+
const available = availableModel(config, model);
|
|
120
|
+
if (Array.isArray(config.availableModels) && config.availableModels.length > 0 && !available) {
|
|
121
|
+
throw policyError(`Configured Work Center model is unavailable: ${model}`);
|
|
122
|
+
}
|
|
123
|
+
const effortOptions = Array.isArray(available?.effortOptions) ? available.effortOptions : [];
|
|
124
|
+
if (policy.effort && !effortOptions.includes(policy.effort)) {
|
|
125
|
+
throw policyError(`Configured Work Center effort is unsupported by ${model}: ${policy.effort}`);
|
|
126
|
+
}
|
|
127
|
+
const parsed = parseModelRef(model);
|
|
128
|
+
return {
|
|
129
|
+
model,
|
|
130
|
+
effort: policy.effort || null,
|
|
131
|
+
provider: available?.provider || parsed.providerName || null,
|
|
132
|
+
source,
|
|
133
|
+
policy,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import ctx from '../../context.js';
|
|
2
2
|
import { sendToServer } from '../../connection/buffer.js';
|
|
3
|
-
import { ensureSessionLoaded } from '../web-bridge.js';
|
|
3
|
+
import { ensureSessionLoaded, resetYeaftSession } from '../web-bridge.js';
|
|
4
4
|
import { defaultRegistry } from '../vp/registry.js';
|
|
5
5
|
import { scanVpLibrary } from '../vp/vp-store.js';
|
|
6
6
|
import { WorkCenterService } from './service.js';
|
|
7
7
|
import { WorkItemRunner } from './runner.js';
|
|
8
|
-
import { projectWorkCenterEvent } from './projection.js';
|
|
8
|
+
import { projectWorkCenterEvent, projectWorkItemDetail } from './projection.js';
|
|
9
|
+
import { previewWorkCenterPlan } from './planner.js';
|
|
10
|
+
import { readWorkCenterSettings } from './settings.js';
|
|
9
11
|
import { join } from 'node:path';
|
|
10
12
|
|
|
11
13
|
let service = null;
|
|
@@ -14,6 +16,8 @@ let shuttingDown = false;
|
|
|
14
16
|
let shutdownPromise = null;
|
|
15
17
|
let serviceFactory = null;
|
|
16
18
|
|
|
19
|
+
const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
|
|
20
|
+
|
|
17
21
|
function send(msg) {
|
|
18
22
|
sendToServer(msg);
|
|
19
23
|
}
|
|
@@ -29,7 +33,7 @@ async function createDefaultService() {
|
|
|
29
33
|
runtimeProvider: async () => {
|
|
30
34
|
const runtime = await getRuntime();
|
|
31
35
|
if (defaultRegistry.vpCount() === 0) {
|
|
32
|
-
for (const vp of scanVpLibrary({ dir: join(
|
|
36
|
+
for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
|
|
33
37
|
}
|
|
34
38
|
return {
|
|
35
39
|
...runtime,
|
|
@@ -42,6 +46,27 @@ async function createDefaultService() {
|
|
|
42
46
|
const created = new WorkCenterService({
|
|
43
47
|
yeaftDir,
|
|
44
48
|
runner,
|
|
49
|
+
runtimeInfoProvider: async () => {
|
|
50
|
+
const runtime = await getRuntime();
|
|
51
|
+
if (defaultRegistry.vpCount() === 0) {
|
|
52
|
+
for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
vps: defaultRegistry.listVps().map(vp => ({
|
|
56
|
+
id: vp.id,
|
|
57
|
+
name: vp.name || vp.id,
|
|
58
|
+
nameZh: vp.nameZh || '',
|
|
59
|
+
role: vp.role || '',
|
|
60
|
+
roleZh: vp.roleZh || '',
|
|
61
|
+
area: vp.area || '',
|
|
62
|
+
traits: Array.isArray(vp.traits) ? vp.traits : [],
|
|
63
|
+
modelHint: vp.modelHint || null,
|
|
64
|
+
})),
|
|
65
|
+
models: Array.isArray(runtime.config.availableModels) ? runtime.config.availableModels : [],
|
|
66
|
+
primaryModel: runtime.config.primaryModel || runtime.config.model || null,
|
|
67
|
+
fastModel: runtime.config.fastModel || null,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
45
70
|
onEvent(event) {
|
|
46
71
|
send({ type: 'work_center_event', event: projectWorkCenterEvent(event) });
|
|
47
72
|
},
|
|
@@ -85,7 +110,28 @@ export async function handleWorkCenterRequest(msg) {
|
|
|
85
110
|
const op = typeof msg.op === 'string' ? msg.op : '';
|
|
86
111
|
try {
|
|
87
112
|
const workCenter = await ensureWorkCenter();
|
|
88
|
-
|
|
113
|
+
let data;
|
|
114
|
+
if (op === 'preview') {
|
|
115
|
+
const runtime = await getRuntime();
|
|
116
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
117
|
+
if (!yeaftDir) throw new Error('Work Center requires a configured Yeaft directory');
|
|
118
|
+
if (defaultRegistry.vpCount() === 0) {
|
|
119
|
+
for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
|
|
120
|
+
}
|
|
121
|
+
data = previewWorkCenterPlan({
|
|
122
|
+
settings: readWorkCenterSettings(yeaftDir),
|
|
123
|
+
workflowId: msg.payload?.workflowTemplate,
|
|
124
|
+
stageOverrides: msg.payload?.stageOverrides,
|
|
125
|
+
registry: defaultRegistry,
|
|
126
|
+
config: runtime.config,
|
|
127
|
+
});
|
|
128
|
+
} else if (op === 'refresh_runtime') {
|
|
129
|
+
await resetYeaftSession();
|
|
130
|
+
data = await workCenter.handle('get_settings', {});
|
|
131
|
+
} else {
|
|
132
|
+
data = await workCenter.handle(op, msg.payload || {});
|
|
133
|
+
}
|
|
134
|
+
if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
|
|
89
135
|
send({
|
|
90
136
|
type: 'work_center_response',
|
|
91
137
|
requestId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
|
|
1
|
+
import { actionForStage, actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
|
|
2
2
|
import { normalizeEvidence } from './evidence.js';
|
|
3
3
|
|
|
4
4
|
function normalizeCriteria(value) {
|
|
@@ -48,10 +48,12 @@ function normalizeTerminalResult(result, action) {
|
|
|
48
48
|
return normalized;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function contextEntry(action, result) {
|
|
51
|
+
function contextEntry(action, result, run) {
|
|
52
52
|
return {
|
|
53
53
|
type: action.type,
|
|
54
|
-
|
|
54
|
+
stageId: action.stageId || action.type,
|
|
55
|
+
vpId: run?.vpSnapshot?.id || action.requiredRole || null,
|
|
56
|
+
role: run?.roleSnapshot?.id || action.requiredRole || null,
|
|
55
57
|
summary: result.summary || '',
|
|
56
58
|
evidence: result.evidence || [],
|
|
57
59
|
reviewDecision: result.reviewDecision || null,
|
|
@@ -125,7 +127,13 @@ export class WorkflowController {
|
|
|
125
127
|
summary: guidance,
|
|
126
128
|
evidence: [],
|
|
127
129
|
}];
|
|
128
|
-
const step = {
|
|
130
|
+
const step = {
|
|
131
|
+
type: previous.type,
|
|
132
|
+
stageId: previous.stageId || previous.type,
|
|
133
|
+
assignmentPolicy: previous.assignmentPolicy,
|
|
134
|
+
modelPolicy: previous.modelPolicy,
|
|
135
|
+
requiredRole: previous.requiredRole,
|
|
136
|
+
};
|
|
129
137
|
return {
|
|
130
138
|
...step,
|
|
131
139
|
context,
|
|
@@ -144,13 +152,21 @@ export class WorkflowController {
|
|
|
144
152
|
throw new Error('answer is required to resume a waiting WorkItem');
|
|
145
153
|
}
|
|
146
154
|
const step = previous
|
|
147
|
-
? {
|
|
155
|
+
? {
|
|
156
|
+
type: previous.type,
|
|
157
|
+
stageId: previous.stageId || previous.type,
|
|
158
|
+
assignmentPolicy: previous.assignmentPolicy,
|
|
159
|
+
modelPolicy: previous.modelPolicy,
|
|
160
|
+
requiredRole: previous.requiredRole,
|
|
161
|
+
}
|
|
148
162
|
: initialActionFor(workItem);
|
|
149
163
|
const context = Array.isArray(previous?.context) ? [...previous.context] : [];
|
|
150
164
|
if (previousRun) {
|
|
151
165
|
context.push({
|
|
152
166
|
type: previous.type,
|
|
153
|
-
|
|
167
|
+
stageId: previous.stageId || previous.type,
|
|
168
|
+
vpId: previousRun.vpSnapshot?.id || previous.requiredRole || null,
|
|
169
|
+
role: previousRun.roleSnapshot?.id || previous.requiredRole || null,
|
|
154
170
|
summary: previousRun.summary || '',
|
|
155
171
|
evidence: normalizeEvidence(previousRun.evidence),
|
|
156
172
|
waitingReason: previousRun.waitingReason || null,
|
|
@@ -223,7 +239,7 @@ export class WorkflowController {
|
|
|
223
239
|
revision: workItem.revision + 1,
|
|
224
240
|
}
|
|
225
241
|
: workItem;
|
|
226
|
-
const nextStep = getNextStep(workItem
|
|
242
|
+
const nextStep = getNextStep(workItem, action.stageId || action.type, result);
|
|
227
243
|
if (!nextStep) {
|
|
228
244
|
return {
|
|
229
245
|
actionStatus: 'completed',
|
|
@@ -234,17 +250,12 @@ export class WorkflowController {
|
|
|
234
250
|
};
|
|
235
251
|
}
|
|
236
252
|
|
|
237
|
-
const context = [...(action.context || []), contextEntry(action, result)];
|
|
253
|
+
const context = [...(action.context || []), contextEntry(action, result, activeRun)];
|
|
238
254
|
return {
|
|
239
255
|
actionStatus: 'completed',
|
|
240
256
|
workItemStatus: 'ready',
|
|
241
257
|
contractPatch,
|
|
242
|
-
nextAction:
|
|
243
|
-
...nextStep,
|
|
244
|
-
context,
|
|
245
|
-
instruction: actionInstruction(nextStep, effectiveWorkItem, context),
|
|
246
|
-
maxAttempts: 2,
|
|
247
|
-
},
|
|
258
|
+
nextAction: actionForStage(nextStep, effectiveWorkItem, context),
|
|
248
259
|
eventType: 'action.completed',
|
|
249
260
|
eventData: {
|
|
250
261
|
nextActionType: nextStep.type,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
|
|
2
|
+
import { resolveWorkflowSnapshot } from './workflow.js';
|
|
3
|
+
|
|
4
|
+
function publicVp(vp) {
|
|
5
|
+
return vp ? {
|
|
6
|
+
id: vp.id,
|
|
7
|
+
name: vp.name || vp.id,
|
|
8
|
+
nameZh: vp.nameZh || '',
|
|
9
|
+
role: vp.role || '',
|
|
10
|
+
roleZh: vp.roleZh || '',
|
|
11
|
+
area: vp.area || '',
|
|
12
|
+
traits: Array.isArray(vp.traits) ? vp.traits : [],
|
|
13
|
+
modelHint: vp.modelHint || null,
|
|
14
|
+
} : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function previewWorkCenterPlan({ settings, workflowId, stageOverrides, registry, config }) {
|
|
18
|
+
const workflow = resolveWorkflowSnapshot(settings, workflowId, stageOverrides);
|
|
19
|
+
const vps = registry.listVps();
|
|
20
|
+
const syntheticRuns = [];
|
|
21
|
+
const stages = workflow.stages.map(stage => {
|
|
22
|
+
try {
|
|
23
|
+
const assignment = selectWorkItemVp({
|
|
24
|
+
policy: stage.assignmentPolicy,
|
|
25
|
+
stageType: stage.type,
|
|
26
|
+
vps,
|
|
27
|
+
priorRuns: syntheticRuns,
|
|
28
|
+
});
|
|
29
|
+
const model = resolveWorkItemModel(config, assignment.vp, stage.modelPolicy);
|
|
30
|
+
syntheticRuns.push({
|
|
31
|
+
actionType: stage.type,
|
|
32
|
+
roleSnapshot: { actionType: stage.type },
|
|
33
|
+
vpSnapshot: { id: assignment.vp.id },
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
id: stage.id,
|
|
37
|
+
name: stage.name,
|
|
38
|
+
type: stage.type,
|
|
39
|
+
assignmentPolicy: stage.assignmentPolicy,
|
|
40
|
+
modelPolicy: stage.modelPolicy,
|
|
41
|
+
selectedVp: publicVp(assignment.vp),
|
|
42
|
+
selectionReason: assignment.reason,
|
|
43
|
+
model: {
|
|
44
|
+
id: model.model,
|
|
45
|
+
provider: model.provider,
|
|
46
|
+
effort: model.effort,
|
|
47
|
+
source: model.source,
|
|
48
|
+
},
|
|
49
|
+
error: null,
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
id: stage.id,
|
|
54
|
+
name: stage.name,
|
|
55
|
+
type: stage.type,
|
|
56
|
+
assignmentPolicy: stage.assignmentPolicy,
|
|
57
|
+
modelPolicy: stage.modelPolicy,
|
|
58
|
+
selectedVp: null,
|
|
59
|
+
selectionReason: null,
|
|
60
|
+
model: null,
|
|
61
|
+
error: error?.message || String(error),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return { workflow, stages, valid: stages.every(stage => !stage.error) };
|
|
66
|
+
}
|
|
@@ -3,10 +3,104 @@ function currentAction(detail) {
|
|
|
3
3
|
return detail.actions.find(action => action.id === detail.currentActionId) || null;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
function projectAction(action) {
|
|
7
|
+
if (!action) return null;
|
|
8
|
+
return {
|
|
9
|
+
id: action.id,
|
|
10
|
+
workItemId: action.workItemId,
|
|
11
|
+
sequence: action.sequence,
|
|
12
|
+
type: action.type,
|
|
13
|
+
stageId: action.stageId || action.type,
|
|
14
|
+
assignmentPolicy: action.assignmentPolicy || null,
|
|
15
|
+
modelPolicy: action.modelPolicy || null,
|
|
16
|
+
requiredRole: action.requiredRole || '',
|
|
17
|
+
status: action.status,
|
|
18
|
+
attempt: action.attempt,
|
|
19
|
+
maxAttempts: action.maxAttempts,
|
|
20
|
+
currentRunId: action.currentRunId || null,
|
|
21
|
+
createdAt: action.createdAt,
|
|
22
|
+
updatedAt: action.updatedAt,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function projectRun(run) {
|
|
27
|
+
if (!run) return null;
|
|
28
|
+
return {
|
|
29
|
+
id: run.id,
|
|
30
|
+
actionId: run.actionId,
|
|
31
|
+
workItemId: run.workItemId,
|
|
32
|
+
status: run.status,
|
|
33
|
+
startedAt: run.startedAt,
|
|
34
|
+
expiresAt: run.expiresAt,
|
|
35
|
+
endedAt: run.endedAt || null,
|
|
36
|
+
summary: run.summary || '',
|
|
37
|
+
evidence: Array.isArray(run.evidence) ? run.evidence : [],
|
|
38
|
+
waitingReason: run.waitingReason || '',
|
|
39
|
+
error: run.error || '',
|
|
40
|
+
reviewDecision: run.reviewDecision || null,
|
|
41
|
+
roleSnapshot: run.roleSnapshot ? {
|
|
42
|
+
id: run.roleSnapshot.id,
|
|
43
|
+
actionType: run.roleSnapshot.actionType,
|
|
44
|
+
selectionReason: run.roleSnapshot.selectionReason,
|
|
45
|
+
} : null,
|
|
46
|
+
vpSnapshot: run.vpSnapshot ? {
|
|
47
|
+
id: run.vpSnapshot.id,
|
|
48
|
+
name: run.vpSnapshot.name || run.vpSnapshot.id,
|
|
49
|
+
nameZh: run.vpSnapshot.nameZh || '',
|
|
50
|
+
role: run.vpSnapshot.role || '',
|
|
51
|
+
roleZh: run.vpSnapshot.roleZh || '',
|
|
52
|
+
} : null,
|
|
53
|
+
modelSnapshot: run.modelSnapshot ? {
|
|
54
|
+
id: run.modelSnapshot.id,
|
|
55
|
+
provider: run.modelSnapshot.provider || null,
|
|
56
|
+
effort: run.modelSnapshot.effort || null,
|
|
57
|
+
source: run.modelSnapshot.source || null,
|
|
58
|
+
} : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function projectEvent(event) {
|
|
63
|
+
if (!event) return null;
|
|
64
|
+
return {
|
|
65
|
+
id: event.id,
|
|
66
|
+
workItemId: event.workItemId,
|
|
67
|
+
actionId: event.actionId || null,
|
|
68
|
+
runId: event.runId || null,
|
|
69
|
+
type: event.type,
|
|
70
|
+
createdAt: event.createdAt,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Authenticated browser detail DTO. Execution-only snapshots, VP persona,
|
|
76
|
+
* tool policy, prompts, event data, and local filesystem paths never cross the wire.
|
|
77
|
+
*/
|
|
78
|
+
export function projectWorkItemDetail(detail) {
|
|
79
|
+
if (!detail) return null;
|
|
80
|
+
return {
|
|
81
|
+
id: detail.id,
|
|
82
|
+
revision: detail.revision,
|
|
83
|
+
title: detail.title,
|
|
84
|
+
goal: detail.goal,
|
|
85
|
+
acceptanceCriteria: Array.isArray(detail.acceptanceCriteria) ? detail.acceptanceCriteria : [],
|
|
86
|
+
workflowTemplate: detail.workflowTemplate,
|
|
87
|
+
status: detail.status,
|
|
88
|
+
currentActionId: detail.currentActionId || null,
|
|
89
|
+
currentRunId: detail.currentRunId || null,
|
|
90
|
+
reuseMemory: detail.reuseMemory !== false,
|
|
91
|
+
origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
|
|
92
|
+
linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
|
|
93
|
+
createdAt: detail.createdAt,
|
|
94
|
+
updatedAt: detail.updatedAt,
|
|
95
|
+
actions: Array.isArray(detail.actions) ? detail.actions.map(projectAction) : [],
|
|
96
|
+
runs: Array.isArray(detail.runs) ? detail.runs.map(projectRun) : [],
|
|
97
|
+
events: Array.isArray(detail.events) ? detail.events.map(projectEvent) : [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
6
101
|
/**
|
|
7
102
|
* Browser event projection. This deliberately excludes local filesystem paths,
|
|
8
103
|
* Run evidence/tool output, prompts, model snapshots, and execution errors.
|
|
9
|
-
* Clients fetch the selected detail explicitly through the authenticated get op.
|
|
10
104
|
*/
|
|
11
105
|
export function projectWorkItemSummary(detail) {
|
|
12
106
|
if (!detail) return null;
|
|
@@ -36,7 +130,8 @@ export function projectWorkItemSummary(detail) {
|
|
|
36
130
|
currentAction: action ? {
|
|
37
131
|
id: action.id,
|
|
38
132
|
type: action.type,
|
|
39
|
-
|
|
133
|
+
stageId: action.stageId || action.type,
|
|
134
|
+
assignmentMode: action.assignmentPolicy?.mode || (action.requiredRole ? 'fixed' : null),
|
|
40
135
|
status: action.status,
|
|
41
136
|
} : null,
|
|
42
137
|
origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
|
|
@@ -5,6 +5,7 @@ import { parsePatch } from '../tools/apply-patch.js';
|
|
|
5
5
|
import { defaultRegistry } from '../vp/registry.js';
|
|
6
6
|
import { NullTrace } from '../debug-trace.js';
|
|
7
7
|
import { isPathInsideOrEqual } from '../tools/path-safety.js';
|
|
8
|
+
import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
|
|
8
9
|
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
|
|
@@ -203,12 +204,6 @@ function completionContract(action) {
|
|
|
203
204
|
}\nA model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
function resolveModel(config, vp) {
|
|
207
|
-
if (vp.modelHint === 'fast' && config.fastModel) return config.fastModel;
|
|
208
|
-
if (vp.modelHint === 'primary' && config.primaryModel) return config.primaryModel;
|
|
209
|
-
return config.model || config.primaryModel || null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
207
|
export class WorkItemRunner {
|
|
213
208
|
constructor(options) {
|
|
214
209
|
this.runtimeProvider = options.runtimeProvider;
|
|
@@ -219,20 +214,38 @@ export class WorkItemRunner {
|
|
|
219
214
|
async run({ workItem, action, run, signal, ownerBootId }) {
|
|
220
215
|
const runtime = await this.runtimeProvider();
|
|
221
216
|
const workDir = resolveWorkItemWorkDir(workItem, runtime.defaultWorkDir);
|
|
222
|
-
const
|
|
217
|
+
const priorRuns = this.store.listCompletedRuns(workItem.id);
|
|
218
|
+
const assignment = action.assignmentPolicy
|
|
219
|
+
? selectWorkItemVp({
|
|
220
|
+
policy: action.assignmentPolicy,
|
|
221
|
+
stageType: action.type,
|
|
222
|
+
vps: this.registry.listVps(),
|
|
223
|
+
priorRuns,
|
|
224
|
+
})
|
|
225
|
+
: {
|
|
226
|
+
vp: this.registry.getVp(action.requiredRole),
|
|
227
|
+
reason: `legacy-fixed:${action.requiredRole}`,
|
|
228
|
+
policy: { mode: 'fixed', fixedVpId: action.requiredRole },
|
|
229
|
+
};
|
|
230
|
+
const vp = copyVp(assignment.vp);
|
|
223
231
|
if (!vp) {
|
|
224
|
-
const error = new Error(`Required Work Center
|
|
232
|
+
const error = new Error(`Required Work Center VP is unavailable: ${action.requiredRole || '(unassigned)'}`);
|
|
225
233
|
error.retryable = false;
|
|
226
234
|
throw error;
|
|
227
235
|
}
|
|
236
|
+
const resolvedModel = resolveWorkItemModel(runtime.config, vp, action.modelPolicy);
|
|
228
237
|
const isRunActive = () => !signal.aborted
|
|
229
238
|
&& this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
|
|
230
239
|
const toolPolicySnapshot = workItemToolPolicySnapshot(workDir);
|
|
231
240
|
const toolRegistry = createWorkItemToolRegistry({ workDir, isRunActive });
|
|
232
|
-
const model = resolveModel(runtime.config, vp);
|
|
233
241
|
const config = {
|
|
234
242
|
...runtime.config,
|
|
235
|
-
model,
|
|
243
|
+
model: resolvedModel.model,
|
|
244
|
+
// WorkItem model policy is part of the frozen execution contract. The
|
|
245
|
+
// Agent-level fallback would silently execute a different model while
|
|
246
|
+
// leaving the Run snapshot unchanged, so WorkItems must fail explicitly.
|
|
247
|
+
fallbackModel: null,
|
|
248
|
+
...(resolvedModel.effort ? { modelEffort: resolvedModel.effort } : {}),
|
|
236
249
|
_readOnly: true,
|
|
237
250
|
serverMode: true,
|
|
238
251
|
// WorkItem messages live in the Work Center DB. Never archive their
|
|
@@ -247,9 +260,20 @@ export class WorkItemRunner {
|
|
|
247
260
|
ownerBootId,
|
|
248
261
|
run.leaseEpoch,
|
|
249
262
|
{
|
|
250
|
-
roleSnapshot: {
|
|
263
|
+
roleSnapshot: {
|
|
264
|
+
id: action.stageId || action.type,
|
|
265
|
+
actionType: action.type,
|
|
266
|
+
assignmentPolicy: assignment.policy,
|
|
267
|
+
selectionReason: assignment.reason,
|
|
268
|
+
},
|
|
251
269
|
vpSnapshot: vp,
|
|
252
|
-
modelSnapshot: {
|
|
270
|
+
modelSnapshot: {
|
|
271
|
+
id: resolvedModel.model,
|
|
272
|
+
provider: resolvedModel.provider,
|
|
273
|
+
effort: resolvedModel.effort,
|
|
274
|
+
source: resolvedModel.source,
|
|
275
|
+
policy: resolvedModel.policy,
|
|
276
|
+
},
|
|
253
277
|
toolPolicySnapshot,
|
|
254
278
|
},
|
|
255
279
|
);
|
|
@@ -3,7 +3,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { WorkItemStore } from './store.js';
|
|
4
4
|
import { WorkflowController } from './controller.js';
|
|
5
5
|
import { WorkItemWatcher } from './watcher.js';
|
|
6
|
-
import { projectWorkItemSummary } from './projection.js';
|
|
6
|
+
import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
|
|
7
|
+
import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
|
|
8
|
+
import { resolveWorkflowSnapshot } from './workflow.js';
|
|
7
9
|
|
|
8
10
|
function requiredString(value, name) {
|
|
9
11
|
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required`);
|
|
@@ -13,6 +15,12 @@ function requiredString(value, name) {
|
|
|
13
15
|
export class WorkCenterService {
|
|
14
16
|
constructor(options) {
|
|
15
17
|
const yeaftDir = requiredString(options?.yeaftDir, 'yeaftDir');
|
|
18
|
+
this.yeaftDir = yeaftDir;
|
|
19
|
+
this.settingsReader = options.settingsReader || readWorkCenterSettings;
|
|
20
|
+
this.settingsWriter = options.settingsWriter || writeWorkCenterSettings;
|
|
21
|
+
this.runtimeInfoProvider = typeof options.runtimeInfoProvider === 'function'
|
|
22
|
+
? options.runtimeInfoProvider
|
|
23
|
+
: async () => ({ vps: [], models: [], primaryModel: null, fastModel: null });
|
|
16
24
|
this.ownerBootId = options.ownerBootId || randomUUID();
|
|
17
25
|
this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
|
|
18
26
|
this.controller = options.controller || new WorkflowController(this.store);
|
|
@@ -37,16 +45,32 @@ export class WorkCenterService {
|
|
|
37
45
|
watcher: this.watcher.status(),
|
|
38
46
|
};
|
|
39
47
|
case 'get':
|
|
40
|
-
return this.#requiredItem(payload.id);
|
|
48
|
+
return projectWorkItemDetail(this.#requiredItem(payload.id));
|
|
49
|
+
case 'get_settings': {
|
|
50
|
+
const settings = this.settingsReader(this.yeaftDir);
|
|
51
|
+
return { settings, runtime: await this.runtimeInfoProvider() };
|
|
52
|
+
}
|
|
53
|
+
case 'update_settings': {
|
|
54
|
+
const settings = this.settingsWriter(this.yeaftDir, payload.settings);
|
|
55
|
+
return { settings, runtime: await this.runtimeInfoProvider() };
|
|
56
|
+
}
|
|
41
57
|
case 'create': {
|
|
58
|
+
const settings = this.settingsReader(this.yeaftDir);
|
|
59
|
+
const workflowTemplate = typeof payload.workflowTemplate === 'string' && payload.workflowTemplate.trim()
|
|
60
|
+
? payload.workflowTemplate.trim()
|
|
61
|
+
: settings.defaultWorkflowId;
|
|
62
|
+
const workflowSnapshot = resolveWorkflowSnapshot(settings, workflowTemplate, payload.stageOverrides);
|
|
42
63
|
const item = this.controller.create({
|
|
43
64
|
title: requiredString(payload.title, 'title'),
|
|
44
65
|
goal: requiredString(payload.goal, 'goal'),
|
|
45
66
|
acceptanceCriteria: Array.isArray(payload.acceptanceCriteria)
|
|
46
67
|
? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean)
|
|
47
68
|
: [],
|
|
48
|
-
workflowTemplate
|
|
49
|
-
|
|
69
|
+
workflowTemplate,
|
|
70
|
+
workflowSnapshot,
|
|
71
|
+
workDir: typeof payload.workDir === 'string' && payload.workDir.trim()
|
|
72
|
+
? payload.workDir.trim()
|
|
73
|
+
: settings.defaultWorkDir,
|
|
50
74
|
reuseMemory: payload.reuseMemory !== false,
|
|
51
75
|
origin: payload.origin && typeof payload.origin === 'object'
|
|
52
76
|
? {
|
|
@@ -58,7 +82,7 @@ export class WorkCenterService {
|
|
|
58
82
|
linkedSessionIds: Array.isArray(payload.linkedSessionIds)
|
|
59
83
|
? [...new Set(payload.linkedSessionIds.map(value => String(value).trim()).filter(Boolean))]
|
|
60
84
|
: [],
|
|
61
|
-
start: payload.start !== false,
|
|
85
|
+
start: payload.start === undefined ? settings.startImmediately : payload.start !== false,
|
|
62
86
|
});
|
|
63
87
|
const detail = this.#requiredItem(item.id);
|
|
64
88
|
this.#emit({ type: 'work_item.created', workItem: detail });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { writeAtomic } from '../storage/atomic.js';
|
|
5
|
+
import { defaultWorkCenterSettings, normalizeWorkCenterSettings } from './workflow.js';
|
|
6
|
+
|
|
7
|
+
export const WORK_CENTER_SETTINGS_FILE = 'settings.json';
|
|
8
|
+
const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
function settingsPath(yeaftDir) {
|
|
11
|
+
return join(yeaftDir, 'work-center', WORK_CENTER_SETTINGS_FILE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withSettingsTransaction(yeaftDir, fn) {
|
|
15
|
+
const dir = dirname(settingsPath(yeaftDir));
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
const db = new DatabaseSync(join(dir, 'work-center.db'), { timeout: SETTINGS_LOCK_TIMEOUT_MS });
|
|
18
|
+
let transactionOpen = false;
|
|
19
|
+
try {
|
|
20
|
+
db.exec(`PRAGMA busy_timeout = ${SETTINGS_LOCK_TIMEOUT_MS};`);
|
|
21
|
+
db.exec('BEGIN IMMEDIATE');
|
|
22
|
+
transactionOpen = true;
|
|
23
|
+
const result = fn();
|
|
24
|
+
db.exec('COMMIT');
|
|
25
|
+
transactionOpen = false;
|
|
26
|
+
return result;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (transactionOpen) {
|
|
29
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {
|
|
33
|
+
db.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readWorkCenterSettings(yeaftDir) {
|
|
38
|
+
const file = settingsPath(yeaftDir);
|
|
39
|
+
if (!existsSync(file)) return defaultWorkCenterSettings();
|
|
40
|
+
try {
|
|
41
|
+
return normalizeWorkCenterSettings(JSON.parse(readFileSync(file, 'utf8')));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`Failed to read Work Center settings: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeWorkCenterSettings(yeaftDir, value) {
|
|
48
|
+
return withSettingsTransaction(yeaftDir, () => {
|
|
49
|
+
const current = readWorkCenterSettings(yeaftDir);
|
|
50
|
+
const expectedRevision = Number(value?.revision);
|
|
51
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision !== current.revision) {
|
|
52
|
+
throw new Error('Work Center settings changed elsewhere; reload before saving');
|
|
53
|
+
}
|
|
54
|
+
const normalized = normalizeWorkCenterSettings({ ...value, revision: current.revision + 1 });
|
|
55
|
+
const file = settingsPath(yeaftDir);
|
|
56
|
+
writeAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
57
|
+
return normalized;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -31,6 +31,7 @@ function mapWorkItem(row) {
|
|
|
31
31
|
goal: row.goal,
|
|
32
32
|
acceptanceCriteria: parseJson(row.acceptance_criteria, []),
|
|
33
33
|
workflowTemplate: row.workflow_template,
|
|
34
|
+
workflowSnapshot: parseJson(row.workflow_snapshot, null),
|
|
34
35
|
status: row.status,
|
|
35
36
|
currentActionId: row.current_action_id || null,
|
|
36
37
|
currentRunId: row.current_run_id || null,
|
|
@@ -51,7 +52,10 @@ function mapAction(row) {
|
|
|
51
52
|
workItemId: row.work_item_id,
|
|
52
53
|
sequence: row.sequence,
|
|
53
54
|
type: row.type,
|
|
54
|
-
|
|
55
|
+
stageId: row.stage_id || row.type,
|
|
56
|
+
assignmentPolicy: parseJson(row.assignment_policy, null),
|
|
57
|
+
modelPolicy: parseJson(row.model_policy, null),
|
|
58
|
+
requiredRole: row.required_role || '',
|
|
55
59
|
instruction: row.instruction,
|
|
56
60
|
context: parseJson(row.context, []),
|
|
57
61
|
contractRevision: row.contract_revision,
|
|
@@ -146,6 +150,7 @@ export class WorkItemStore {
|
|
|
146
150
|
goal TEXT NOT NULL,
|
|
147
151
|
acceptance_criteria TEXT NOT NULL,
|
|
148
152
|
workflow_template TEXT NOT NULL,
|
|
153
|
+
workflow_snapshot TEXT,
|
|
149
154
|
status TEXT NOT NULL,
|
|
150
155
|
current_action_id TEXT,
|
|
151
156
|
current_run_id TEXT,
|
|
@@ -163,6 +168,9 @@ export class WorkItemStore {
|
|
|
163
168
|
sequence INTEGER NOT NULL,
|
|
164
169
|
type TEXT NOT NULL,
|
|
165
170
|
required_role TEXT NOT NULL,
|
|
171
|
+
stage_id TEXT,
|
|
172
|
+
assignment_policy TEXT,
|
|
173
|
+
model_policy TEXT,
|
|
166
174
|
instruction TEXT NOT NULL,
|
|
167
175
|
context TEXT NOT NULL DEFAULT '[]',
|
|
168
176
|
contract_revision INTEGER NOT NULL DEFAULT 1,
|
|
@@ -241,6 +249,18 @@ export class WorkItemStore {
|
|
|
241
249
|
if (!hasColumn(this.db, 'runs', 'contract_patch')) {
|
|
242
250
|
this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
|
|
243
251
|
}
|
|
252
|
+
if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
|
|
253
|
+
this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
|
|
254
|
+
}
|
|
255
|
+
if (!hasColumn(this.db, 'actions', 'stage_id')) {
|
|
256
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN stage_id TEXT');
|
|
257
|
+
}
|
|
258
|
+
if (!hasColumn(this.db, 'actions', 'assignment_policy')) {
|
|
259
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN assignment_policy TEXT');
|
|
260
|
+
}
|
|
261
|
+
if (!hasColumn(this.db, 'actions', 'model_policy')) {
|
|
262
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN model_policy TEXT');
|
|
263
|
+
}
|
|
244
264
|
this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
|
|
245
265
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
|
|
246
266
|
}
|
|
@@ -269,15 +289,16 @@ export class WorkItemStore {
|
|
|
269
289
|
const id = input.id || randomUUID();
|
|
270
290
|
const workspaceKey = canonicalWorkspaceKey(input.workDir);
|
|
271
291
|
this.db.prepare(`INSERT INTO work_items
|
|
272
|
-
(id, revision, title, goal, acceptance_criteria, workflow_template, status,
|
|
292
|
+
(id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
|
|
273
293
|
current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
|
|
274
294
|
created_at, updated_at)
|
|
275
|
-
VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
295
|
+
VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
276
296
|
id,
|
|
277
297
|
input.title,
|
|
278
298
|
input.goal,
|
|
279
299
|
stringify(input.acceptanceCriteria || []),
|
|
280
300
|
input.workflowTemplate || 'software-change',
|
|
301
|
+
stringify(input.workflowSnapshot || null),
|
|
281
302
|
firstAction ? 'ready' : 'draft',
|
|
282
303
|
input.workDir || '',
|
|
283
304
|
workspaceKey,
|
|
@@ -303,7 +324,10 @@ export class WorkItemStore {
|
|
|
303
324
|
workItemId,
|
|
304
325
|
sequence,
|
|
305
326
|
type: input.type,
|
|
306
|
-
|
|
327
|
+
stageId: input.stageId || input.type,
|
|
328
|
+
assignmentPolicy: input.assignmentPolicy || null,
|
|
329
|
+
modelPolicy: input.modelPolicy || null,
|
|
330
|
+
requiredRole: input.requiredRole || '',
|
|
307
331
|
instruction: input.instruction || '',
|
|
308
332
|
context: Array.isArray(input.context) ? input.context : [],
|
|
309
333
|
contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
|
|
@@ -316,15 +340,18 @@ export class WorkItemStore {
|
|
|
316
340
|
updatedAt: now,
|
|
317
341
|
};
|
|
318
342
|
this.db.prepare(`INSERT INTO actions
|
|
319
|
-
(id, work_item_id, sequence, type, required_role,
|
|
320
|
-
contract_revision, status, attempt, max_attempts, current_run_id,
|
|
321
|
-
created_at, updated_at)
|
|
322
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
|
|
343
|
+
(id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
|
|
344
|
+
instruction, context, contract_revision, status, attempt, max_attempts, current_run_id,
|
|
345
|
+
lease_epoch, created_at, updated_at)
|
|
346
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
|
|
323
347
|
action.id,
|
|
324
348
|
workItemId,
|
|
325
349
|
action.sequence,
|
|
326
350
|
action.type,
|
|
327
351
|
action.requiredRole,
|
|
352
|
+
action.stageId,
|
|
353
|
+
stringify(action.assignmentPolicy),
|
|
354
|
+
stringify(action.modelPolicy),
|
|
328
355
|
action.instruction,
|
|
329
356
|
stringify(action.context),
|
|
330
357
|
action.contractRevision,
|
|
@@ -358,6 +385,16 @@ export class WorkItemStore {
|
|
|
358
385
|
return mapRun(this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
|
|
359
386
|
}
|
|
360
387
|
|
|
388
|
+
listCompletedRuns(workItemId) {
|
|
389
|
+
return this.db.prepare(`SELECT r.*, a.type AS action_type FROM runs r
|
|
390
|
+
JOIN actions a ON a.id = r.action_id
|
|
391
|
+
WHERE r.work_item_id = ? AND r.status != 'running'
|
|
392
|
+
ORDER BY r.started_at ASC`).all(workItemId).map(row => ({
|
|
393
|
+
...mapRun(row),
|
|
394
|
+
actionType: row.action_type,
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
361
398
|
listWorkItems(filters = {}) {
|
|
362
399
|
const where = [];
|
|
363
400
|
const values = [];
|
|
@@ -1,13 +1,35 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
{ type: 'deliver', requiredRole: 'linus' },
|
|
6
|
-
]);
|
|
1
|
+
const STAGE_TYPES = new Set(['triage', 'implement', 'test', 'review', 'deliver', 'research', 'write', 'custom']);
|
|
2
|
+
const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed']);
|
|
3
|
+
const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
|
|
4
|
+
const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const DEFAULT_SOFTWARE_CHANGE_STAGES = Object.freeze([
|
|
7
|
+
{
|
|
8
|
+
id: 'triage', name: 'Triage', type: 'triage',
|
|
9
|
+
assignmentPolicy: { mode: 'auto', capability: 'triage', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
|
|
10
|
+
modelPolicy: { mode: 'inherit', model: null, effort: null },
|
|
11
|
+
maxAttempts: 2,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'implement', name: 'Implement', type: 'implement',
|
|
15
|
+
assignmentPolicy: { mode: 'auto', capability: 'implement', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
|
|
16
|
+
modelPolicy: { mode: 'inherit', model: null, effort: null },
|
|
17
|
+
maxAttempts: 2,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'review', name: 'Review', type: 'review',
|
|
21
|
+
assignmentPolicy: { mode: 'auto', capability: 'review', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: ['implement'] },
|
|
22
|
+
modelPolicy: { mode: 'inherit', model: null, effort: null },
|
|
23
|
+
maxAttempts: 2,
|
|
24
|
+
changesRequestedStageId: 'implement',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'deliver', name: 'Deliver', type: 'deliver',
|
|
28
|
+
assignmentPolicy: { mode: 'auto', capability: 'deliver', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
|
|
29
|
+
modelPolicy: { mode: 'inherit', model: null, effort: null },
|
|
30
|
+
maxAttempts: 2,
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
11
33
|
|
|
12
34
|
export const RUN_OUTCOMES = Object.freeze([
|
|
13
35
|
'completed',
|
|
@@ -16,23 +38,204 @@ export const RUN_OUTCOMES = Object.freeze([
|
|
|
16
38
|
'failed',
|
|
17
39
|
]);
|
|
18
40
|
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
function cleanId(value, fallback) {
|
|
42
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
43
|
+
return normalized || fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function uniqueStrings(value) {
|
|
47
|
+
if (!Array.isArray(value)) return [];
|
|
48
|
+
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeAssignmentPolicy(value, stageType = 'custom') {
|
|
52
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
53
|
+
const mode = ASSIGNMENT_MODES.has(source.mode) ? source.mode : 'auto';
|
|
54
|
+
const fixedVpId = typeof source.fixedVpId === 'string' && source.fixedVpId.trim()
|
|
55
|
+
? source.fixedVpId.trim()
|
|
56
|
+
: null;
|
|
57
|
+
const candidateVpIds = uniqueStrings(source.candidateVpIds);
|
|
58
|
+
if (mode === 'fixed' && !fixedVpId) throw new Error('Fixed Work Center assignment requires fixedVpId');
|
|
59
|
+
if (mode === 'pool' && candidateVpIds.length === 0) throw new Error('Work Center VP pool cannot be empty');
|
|
60
|
+
return {
|
|
61
|
+
mode,
|
|
62
|
+
capability: cleanId(source.capability, stageType),
|
|
63
|
+
candidateVpIds,
|
|
64
|
+
fixedVpId,
|
|
65
|
+
separateFromStageTypes: uniqueStrings(source.separateFromStageTypes),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeModelPolicy(value) {
|
|
70
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
71
|
+
const mode = MODEL_MODES.has(source.mode) ? source.mode : 'inherit';
|
|
72
|
+
const model = typeof source.model === 'string' && source.model.trim() ? source.model.trim() : null;
|
|
73
|
+
const effort = MODEL_EFFORTS.has(source.effort) ? source.effort : null;
|
|
74
|
+
if (mode === 'specific' && !model) throw new Error('Specific Work Center model policy requires a model');
|
|
75
|
+
return { mode, model, effort };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeWorkflowDefinition(value, index = 0) {
|
|
79
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
80
|
+
throw new Error('Work Center workflow must be an object');
|
|
81
|
+
}
|
|
82
|
+
const id = cleanId(value.id, `workflow-${index + 1}`);
|
|
83
|
+
const name = String(value.name || id).trim() || id;
|
|
84
|
+
if (!Array.isArray(value.stages) || value.stages.length === 0) {
|
|
85
|
+
throw new Error(`Work Center workflow "${id}" requires at least one stage`);
|
|
86
|
+
}
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const stages = value.stages.map((rawStage, stageIndex) => {
|
|
89
|
+
const source = rawStage && typeof rawStage === 'object' ? rawStage : {};
|
|
90
|
+
const type = STAGE_TYPES.has(source.type) ? source.type : 'custom';
|
|
91
|
+
const stageId = cleanId(source.id, `${type}-${stageIndex + 1}`);
|
|
92
|
+
if (seen.has(stageId)) throw new Error(`Duplicate Work Center stage id: ${stageId}`);
|
|
93
|
+
seen.add(stageId);
|
|
94
|
+
const stage = {
|
|
95
|
+
id: stageId,
|
|
96
|
+
name: String(source.name || stageId).trim() || stageId,
|
|
97
|
+
type,
|
|
98
|
+
instruction: typeof source.instruction === 'string' ? source.instruction.trim() : '',
|
|
99
|
+
assignmentPolicy: normalizeAssignmentPolicy(source.assignmentPolicy, type),
|
|
100
|
+
modelPolicy: normalizeModelPolicy(source.modelPolicy),
|
|
101
|
+
maxAttempts: Math.min(Math.max(Number(source.maxAttempts) || 2, 1), 5),
|
|
102
|
+
};
|
|
103
|
+
if (type === 'review') {
|
|
104
|
+
stage.changesRequestedStageId = cleanId(source.changesRequestedStageId, 'implement');
|
|
105
|
+
}
|
|
106
|
+
return stage;
|
|
107
|
+
});
|
|
108
|
+
for (const [stageIndex, stage] of stages.entries()) {
|
|
109
|
+
if (stage.type !== 'review') continue;
|
|
110
|
+
const targetIndex = stages.findIndex(candidate => candidate.id === stage.changesRequestedStageId);
|
|
111
|
+
if (targetIndex === -1) {
|
|
112
|
+
throw new Error(`Review stage "${stage.id}" points to missing stage "${stage.changesRequestedStageId}"`);
|
|
113
|
+
}
|
|
114
|
+
const target = stages[targetIndex];
|
|
115
|
+
if (targetIndex >= stageIndex || target.type === 'review' || target.type === 'deliver') {
|
|
116
|
+
throw new Error(`Review stage "${stage.id}" must return to an earlier editable stage`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { version: 1, id, name, stages };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function defaultWorkCenterSettings() {
|
|
123
|
+
return {
|
|
124
|
+
version: 1,
|
|
125
|
+
revision: 1,
|
|
126
|
+
defaultWorkflowId: 'software-change',
|
|
127
|
+
startImmediately: true,
|
|
128
|
+
defaultWorkDir: '',
|
|
129
|
+
workflows: [normalizeWorkflowDefinition({
|
|
130
|
+
id: 'software-change',
|
|
131
|
+
name: 'Software change',
|
|
132
|
+
stages: DEFAULT_SOFTWARE_CHANGE_STAGES,
|
|
133
|
+
})],
|
|
134
|
+
};
|
|
23
135
|
}
|
|
24
136
|
|
|
25
|
-
export function
|
|
26
|
-
|
|
137
|
+
export function normalizeWorkCenterSettings(value) {
|
|
138
|
+
const defaults = defaultWorkCenterSettings();
|
|
139
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
140
|
+
const workflows = Array.isArray(source.workflows) && source.workflows.length > 0
|
|
141
|
+
? source.workflows.map(normalizeWorkflowDefinition)
|
|
142
|
+
: defaults.workflows;
|
|
143
|
+
const workflowIds = new Set();
|
|
144
|
+
for (const workflow of workflows) {
|
|
145
|
+
if (workflowIds.has(workflow.id)) throw new Error(`Duplicate Work Center workflow id: ${workflow.id}`);
|
|
146
|
+
workflowIds.add(workflow.id);
|
|
147
|
+
}
|
|
148
|
+
const defaultWorkflowId = workflowIds.has(source.defaultWorkflowId)
|
|
149
|
+
? source.defaultWorkflowId
|
|
150
|
+
: workflows[0].id;
|
|
151
|
+
const revision = Number.isInteger(source.revision) && source.revision > 0 ? source.revision : 1;
|
|
152
|
+
return {
|
|
153
|
+
version: 1,
|
|
154
|
+
revision,
|
|
155
|
+
defaultWorkflowId,
|
|
156
|
+
startImmediately: source.startImmediately !== false,
|
|
157
|
+
defaultWorkDir: typeof source.defaultWorkDir === 'string' ? source.defaultWorkDir.trim() : '',
|
|
158
|
+
workflows,
|
|
159
|
+
};
|
|
27
160
|
}
|
|
28
161
|
|
|
29
|
-
export function
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
if (
|
|
162
|
+
export function resolveWorkflowSnapshot(settings, workflowId, stageOverrides = {}) {
|
|
163
|
+
const normalized = normalizeWorkCenterSettings(settings);
|
|
164
|
+
const id = workflowId || normalized.defaultWorkflowId;
|
|
165
|
+
const workflow = normalized.workflows.find(item => item.id === id);
|
|
166
|
+
if (!workflow) throw new Error(`Unsupported Work Center workflow: ${id}`);
|
|
167
|
+
const overrides = stageOverrides && typeof stageOverrides === 'object' && !Array.isArray(stageOverrides)
|
|
168
|
+
? stageOverrides
|
|
169
|
+
: {};
|
|
170
|
+
return normalizeWorkflowDefinition({
|
|
171
|
+
...workflow,
|
|
172
|
+
stages: workflow.stages.map(stage => {
|
|
173
|
+
const override = overrides[stage.id];
|
|
174
|
+
if (!override || typeof override !== 'object' || Array.isArray(override)) return stage;
|
|
175
|
+
return {
|
|
176
|
+
...stage,
|
|
177
|
+
assignmentPolicy: override.assignmentPolicy
|
|
178
|
+
? { ...stage.assignmentPolicy, ...override.assignmentPolicy }
|
|
179
|
+
: stage.assignmentPolicy,
|
|
180
|
+
modelPolicy: override.modelPolicy
|
|
181
|
+
? { ...stage.modelPolicy, ...override.modelPolicy }
|
|
182
|
+
: stage.modelPolicy,
|
|
183
|
+
};
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const LEGACY_SOFTWARE_CHANGE_VPS = Object.freeze({
|
|
189
|
+
triage: 'omni',
|
|
190
|
+
implement: 'linus',
|
|
191
|
+
review: 'martin',
|
|
192
|
+
deliver: 'linus',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function workflowFrom(source = 'software-change') {
|
|
196
|
+
if (source && typeof source === 'object' && source.workflowSnapshot?.stages) {
|
|
197
|
+
return normalizeWorkflowDefinition(source.workflowSnapshot);
|
|
198
|
+
}
|
|
199
|
+
if (source && typeof source === 'object' && source.stages) return normalizeWorkflowDefinition(source);
|
|
200
|
+
const settings = defaultWorkCenterSettings();
|
|
201
|
+
const workflow = resolveWorkflowSnapshot(settings, typeof source === 'string' ? source : 'software-change');
|
|
202
|
+
// Existing WorkItems did not persist a workflow snapshot. Preserve their
|
|
203
|
+
// historical deterministic assignments; only newly created WorkItems use
|
|
204
|
+
// pool selection. This avoids silently changing in-flight durable work.
|
|
205
|
+
return {
|
|
206
|
+
...workflow,
|
|
207
|
+
stages: workflow.stages.map(stage => ({
|
|
208
|
+
...stage,
|
|
209
|
+
assignmentPolicy: {
|
|
210
|
+
...stage.assignmentPolicy,
|
|
211
|
+
mode: 'fixed',
|
|
212
|
+
fixedVpId: LEGACY_SOFTWARE_CHANGE_VPS[stage.type],
|
|
213
|
+
},
|
|
214
|
+
})),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getWorkflowSteps(source = 'software-change') {
|
|
219
|
+
return workflowFrom(source).stages;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getStep(source, stageIdOrType) {
|
|
223
|
+
return getWorkflowSteps(source).find(stage => stage.id === stageIdOrType)
|
|
224
|
+
|| getWorkflowSteps(source).find(stage => stage.type === stageIdOrType)
|
|
225
|
+
|| null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getNextStep(source, stageIdOrType, result = {}) {
|
|
229
|
+
const steps = getWorkflowSteps(source);
|
|
230
|
+
const index = steps.findIndex(stage => stage.id === stageIdOrType)
|
|
231
|
+
!== -1
|
|
232
|
+
? steps.findIndex(stage => stage.id === stageIdOrType)
|
|
233
|
+
: steps.findIndex(stage => stage.type === stageIdOrType);
|
|
234
|
+
if (index === -1) throw new Error(`Work Center stage ${stageIdOrType} is not in the workflow snapshot`);
|
|
235
|
+
const current = steps[index];
|
|
236
|
+
if (current.type === 'review') {
|
|
34
237
|
if (result.reviewDecision === 'changes_requested') {
|
|
35
|
-
return steps.find(
|
|
238
|
+
return steps.find(stage => stage.id === current.changesRequestedStageId) || null;
|
|
36
239
|
}
|
|
37
240
|
if (result.reviewDecision !== 'approved') {
|
|
38
241
|
throw new Error('Completed review requires approved or changes_requested');
|
|
@@ -55,44 +258,61 @@ function renderContext(context = []) {
|
|
|
55
258
|
const waitingReason = entry.waitingReason ? `\nWaiting reason: ${entry.waitingReason}` : '';
|
|
56
259
|
const answer = entry.answer ? `\nUser answer: ${entry.answer}` : '';
|
|
57
260
|
const source = entry.sourceTitle ? ` from ${entry.sourceTitle}` : '';
|
|
58
|
-
return `### ${entry.type}${source} (${entry.role || 'unknown
|
|
261
|
+
return `### ${entry.type}${source} (${entry.vpId || entry.role || 'unknown VP'})\n${entry.summary || '(no summary)'}${decision}${waitingReason}${answer}${evidence}`;
|
|
59
262
|
});
|
|
60
263
|
return `\n\nReusable Work Center context and prior Action results:\n${blocks.join('\n\n')}`;
|
|
61
264
|
}
|
|
62
265
|
|
|
63
|
-
export function actionInstruction(
|
|
266
|
+
export function actionInstruction(stage, workItem, context = []) {
|
|
64
267
|
const criteria = (workItem.acceptanceCriteria || []).map(item => `- ${item}`).join('\n') || '- No explicit criteria';
|
|
65
268
|
const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${renderContext(context)}`;
|
|
66
|
-
|
|
269
|
+
if (stage.instruction) return `${common}\n\n${stage.instruction}`;
|
|
270
|
+
switch (stage.type) {
|
|
67
271
|
case 'triage':
|
|
68
272
|
return `${common}\n\nAnalyze the request, verify scope and risks, and make the contract executable. Do not implement yet. If the goal or acceptance criteria need refinement, submit a contractPatch.`;
|
|
69
273
|
case 'implement':
|
|
70
274
|
return `${common}\n\nImplement the smallest correct change in the supplied work directory. Add and run relevant tests. Use the prior triage/review findings. Do not approve your own work.`;
|
|
275
|
+
case 'test':
|
|
276
|
+
return `${common}\n\nVerify the implementation against the acceptance criteria. Run focused tests and report reproducible evidence. Do not modify unrelated code.`;
|
|
71
277
|
case 'review':
|
|
72
278
|
return `${common}\n\nReview the implementation and evidence independently. Return approved or changes_requested with concrete findings.`;
|
|
73
279
|
case 'deliver':
|
|
74
280
|
return `${common}\n\nDeliver the approved change using the repository release policy. Verify the final remote state and provide evidence.`;
|
|
281
|
+
case 'research':
|
|
282
|
+
return `${common}\n\nResearch the question using verifiable sources. Separate evidence from inference and return a concise synthesis.`;
|
|
283
|
+
case 'write':
|
|
284
|
+
return `${common}\n\nProduce the requested written deliverable, then verify it against every acceptance criterion.`;
|
|
75
285
|
default:
|
|
76
|
-
return common
|
|
286
|
+
return `${common}\n\nComplete this stage and return verifiable evidence.`;
|
|
77
287
|
}
|
|
78
288
|
}
|
|
79
289
|
|
|
80
|
-
export function
|
|
81
|
-
const step = getWorkflowSteps(workItem.workflowTemplate)[0];
|
|
290
|
+
export function actionForStage(stage, workItem, context = []) {
|
|
82
291
|
return {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
292
|
+
type: stage.type,
|
|
293
|
+
stageId: stage.id,
|
|
294
|
+
assignmentPolicy: stage.assignmentPolicy,
|
|
295
|
+
modelPolicy: stage.modelPolicy,
|
|
296
|
+
// Storage compatibility for databases created before assignment policies.
|
|
297
|
+
requiredRole: stage.assignmentPolicy.mode === 'fixed' ? stage.assignmentPolicy.fixedVpId : '',
|
|
298
|
+
context,
|
|
299
|
+
instruction: actionInstruction(stage, workItem, context),
|
|
300
|
+
maxAttempts: stage.maxAttempts,
|
|
87
301
|
};
|
|
88
302
|
}
|
|
89
303
|
|
|
304
|
+
export function initialActionFor(workItem) {
|
|
305
|
+
const stage = getWorkflowSteps(workItem)[0];
|
|
306
|
+
return actionForStage(stage, workItem, []);
|
|
307
|
+
}
|
|
308
|
+
|
|
90
309
|
export function actionContextFromRuns(runs = []) {
|
|
91
310
|
return runs
|
|
92
311
|
.filter(run => run && run.summary)
|
|
93
312
|
.map(run => ({
|
|
94
313
|
type: run.actionType || 'action',
|
|
95
|
-
|
|
314
|
+
vpId: run.vpSnapshot?.id || null,
|
|
315
|
+
role: run.roleSnapshot?.id || null,
|
|
96
316
|
summary: run.summary,
|
|
97
317
|
evidence: Array.isArray(run.evidence) ? run.evidence : [],
|
|
98
318
|
reviewDecision: run.reviewDecision || null,
|