@yeaft/webchat-agent 1.0.129 → 1.0.131
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 +72 -15
- package/yeaft/work-center/planner.js +66 -0
- package/yeaft/work-center/projection.js +97 -2
- package/yeaft/work-center/runner.js +53 -24
- package/yeaft/work-center/service.js +41 -5
- package/yeaft/work-center/settings.js +59 -0
- package/yeaft/work-center/store.js +136 -14
- package/yeaft/work-center/workflow.js +254 -33
|
@@ -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
|
|
|
@@ -54,6 +55,21 @@ function canonicalWorkDir(workDir) {
|
|
|
54
55
|
return realpathSync(workDir);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function resolveWorkItemWorkDir(workItem, defaultWorkDir) {
|
|
59
|
+
if (typeof workItem?.workspaceKey === 'string' && workItem.workspaceKey) {
|
|
60
|
+
const expected = path.resolve(workItem.workspaceKey);
|
|
61
|
+
const actual = canonicalWorkDir(expected);
|
|
62
|
+
if (actual !== expected) {
|
|
63
|
+
throw new Error('WorkItem canonical workspace identity changed; update its workDir before retrying');
|
|
64
|
+
}
|
|
65
|
+
return expected;
|
|
66
|
+
}
|
|
67
|
+
if (typeof workItem?.workDir === 'string' && workItem.workDir.trim()) {
|
|
68
|
+
throw new Error('WorkItem has no canonical workspace identity; update its workDir before retrying');
|
|
69
|
+
}
|
|
70
|
+
return canonicalWorkDir(path.resolve(defaultWorkDir || process.cwd()));
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function assertPathInside(toolName, workDir, value) {
|
|
58
74
|
const resolved = path.resolve(workDir, value);
|
|
59
75
|
if (!isPathInsideOrEqual(workDir, resolved)) {
|
|
@@ -188,12 +204,6 @@ function completionContract(action) {
|
|
|
188
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.`;
|
|
189
205
|
}
|
|
190
206
|
|
|
191
|
-
function resolveModel(config, vp) {
|
|
192
|
-
if (vp.modelHint === 'fast' && config.fastModel) return config.fastModel;
|
|
193
|
-
if (vp.modelHint === 'primary' && config.primaryModel) return config.primaryModel;
|
|
194
|
-
return config.model || config.primaryModel || null;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
207
|
export class WorkItemRunner {
|
|
198
208
|
constructor(options) {
|
|
199
209
|
this.runtimeProvider = options.runtimeProvider;
|
|
@@ -203,22 +213,39 @@ export class WorkItemRunner {
|
|
|
203
213
|
|
|
204
214
|
async run({ workItem, action, run, signal, ownerBootId }) {
|
|
205
215
|
const runtime = await this.runtimeProvider();
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
const
|
|
216
|
+
const workDir = resolveWorkItemWorkDir(workItem, runtime.defaultWorkDir);
|
|
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);
|
|
209
231
|
if (!vp) {
|
|
210
|
-
const error = new Error(`Required Work Center
|
|
232
|
+
const error = new Error(`Required Work Center VP is unavailable: ${action.requiredRole || '(unassigned)'}`);
|
|
211
233
|
error.retryable = false;
|
|
212
234
|
throw error;
|
|
213
235
|
}
|
|
236
|
+
const resolvedModel = resolveWorkItemModel(runtime.config, vp, action.modelPolicy);
|
|
214
237
|
const isRunActive = () => !signal.aborted
|
|
215
238
|
&& this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
|
|
216
239
|
const toolPolicySnapshot = workItemToolPolicySnapshot(workDir);
|
|
217
240
|
const toolRegistry = createWorkItemToolRegistry({ workDir, isRunActive });
|
|
218
|
-
const model = resolveModel(runtime.config, vp);
|
|
219
241
|
const config = {
|
|
220
242
|
...runtime.config,
|
|
221
|
-
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 } : {}),
|
|
222
249
|
_readOnly: true,
|
|
223
250
|
serverMode: true,
|
|
224
251
|
// WorkItem messages live in the Work Center DB. Never archive their
|
|
@@ -233,9 +260,20 @@ export class WorkItemRunner {
|
|
|
233
260
|
ownerBootId,
|
|
234
261
|
run.leaseEpoch,
|
|
235
262
|
{
|
|
236
|
-
roleSnapshot: {
|
|
263
|
+
roleSnapshot: {
|
|
264
|
+
id: action.stageId || action.type,
|
|
265
|
+
actionType: action.type,
|
|
266
|
+
assignmentPolicy: assignment.policy,
|
|
267
|
+
selectionReason: assignment.reason,
|
|
268
|
+
},
|
|
237
269
|
vpSnapshot: vp,
|
|
238
|
-
modelSnapshot: {
|
|
270
|
+
modelSnapshot: {
|
|
271
|
+
id: resolvedModel.model,
|
|
272
|
+
provider: resolvedModel.provider,
|
|
273
|
+
effort: resolvedModel.effort,
|
|
274
|
+
source: resolvedModel.source,
|
|
275
|
+
policy: resolvedModel.policy,
|
|
276
|
+
},
|
|
239
277
|
toolPolicySnapshot,
|
|
240
278
|
},
|
|
241
279
|
);
|
|
@@ -260,7 +298,6 @@ export class WorkItemRunner {
|
|
|
260
298
|
});
|
|
261
299
|
|
|
262
300
|
let text = '';
|
|
263
|
-
const toolEvidence = [];
|
|
264
301
|
try {
|
|
265
302
|
for await (const event of engine.query({
|
|
266
303
|
prompt: `${action.instruction}${completionContract(action)}`,
|
|
@@ -275,18 +312,10 @@ export class WorkItemRunner {
|
|
|
275
312
|
if (typeof event?.text === 'string') text += event.text;
|
|
276
313
|
else if (typeof event?.delta === 'string') text += event.delta;
|
|
277
314
|
else if (typeof event?.content === 'string' && event.type === 'assistant') text += event.content;
|
|
278
|
-
if (event?.type === 'tool_end') {
|
|
279
|
-
toolEvidence.push({
|
|
280
|
-
tool: event.name,
|
|
281
|
-
isError: !!event.isError,
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
315
|
}
|
|
285
316
|
} finally {
|
|
286
317
|
try { engine.abort?.('work_item_run_finished'); } catch {}
|
|
287
318
|
}
|
|
288
|
-
|
|
289
|
-
result.evidence = [...result.evidence, ...toolEvidence].slice(-100);
|
|
290
|
-
return result;
|
|
319
|
+
return parseStructuredResult(text, action.type);
|
|
291
320
|
}
|
|
292
321
|
}
|
|
@@ -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,33 @@ 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,
|
|
74
|
+
reuseMemory: payload.reuseMemory !== false,
|
|
50
75
|
origin: payload.origin && typeof payload.origin === 'object'
|
|
51
76
|
? {
|
|
52
77
|
sessionId: typeof payload.origin.sessionId === 'string' ? payload.origin.sessionId : null,
|
|
@@ -57,7 +82,7 @@ export class WorkCenterService {
|
|
|
57
82
|
linkedSessionIds: Array.isArray(payload.linkedSessionIds)
|
|
58
83
|
? [...new Set(payload.linkedSessionIds.map(value => String(value).trim()).filter(Boolean))]
|
|
59
84
|
: [],
|
|
60
|
-
start: payload.start !== false,
|
|
85
|
+
start: payload.start === undefined ? settings.startImmediately : payload.start !== false,
|
|
61
86
|
});
|
|
62
87
|
const detail = this.#requiredItem(item.id);
|
|
63
88
|
this.#emit({ type: 'work_item.created', workItem: detail });
|
|
@@ -82,6 +107,17 @@ export class WorkCenterService {
|
|
|
82
107
|
this.#emit({ type: 'work_item.cancelled', workItem: detail });
|
|
83
108
|
return detail;
|
|
84
109
|
}
|
|
110
|
+
case 'guide': {
|
|
111
|
+
const id = requiredString(payload.id, 'id');
|
|
112
|
+
const detail = this.controller.guide(id, {
|
|
113
|
+
guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
|
|
114
|
+
actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
|
|
115
|
+
revision: payload.revision,
|
|
116
|
+
});
|
|
117
|
+
this.watcher.abortInvalidWorkItemRuns(id);
|
|
118
|
+
this.#emit({ type: 'action.guidance_added', workItem: detail });
|
|
119
|
+
return detail;
|
|
120
|
+
}
|
|
85
121
|
case 'retry': {
|
|
86
122
|
const detail = this.controller.retry(requiredString(payload.id, 'id'), {
|
|
87
123
|
answer: typeof payload.answer === 'string' ? payload.answer : '',
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { normalizeEvidence } from './evidence.js';
|
|
6
6
|
|
|
7
|
-
const SCHEMA_VERSION =
|
|
7
|
+
const SCHEMA_VERSION = 3;
|
|
8
8
|
const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
|
|
9
|
+
const MAX_REUSABLE_CONTEXT_ITEMS = 12;
|
|
10
|
+
|
|
11
|
+
function canonicalWorkspaceKey(workDir) {
|
|
12
|
+
if (typeof workDir !== 'string' || !workDir.trim()) return '';
|
|
13
|
+
try { return realpathSync(resolve(workDir.trim())); } catch { return ''; }
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
function parseJson(value, fallback) {
|
|
11
17
|
if (typeof value !== 'string' || !value) return fallback;
|
|
@@ -25,10 +31,13 @@ function mapWorkItem(row) {
|
|
|
25
31
|
goal: row.goal,
|
|
26
32
|
acceptanceCriteria: parseJson(row.acceptance_criteria, []),
|
|
27
33
|
workflowTemplate: row.workflow_template,
|
|
34
|
+
workflowSnapshot: parseJson(row.workflow_snapshot, null),
|
|
28
35
|
status: row.status,
|
|
29
36
|
currentActionId: row.current_action_id || null,
|
|
30
37
|
currentRunId: row.current_run_id || null,
|
|
31
38
|
workDir: row.work_dir || '',
|
|
39
|
+
workspaceKey: row.workspace_key || '',
|
|
40
|
+
reuseMemory: row.reuse_memory !== 0,
|
|
32
41
|
origin: parseJson(row.origin, null),
|
|
33
42
|
linkedSessionIds: parseJson(row.linked_session_ids, []),
|
|
34
43
|
createdAt: row.created_at,
|
|
@@ -43,7 +52,10 @@ function mapAction(row) {
|
|
|
43
52
|
workItemId: row.work_item_id,
|
|
44
53
|
sequence: row.sequence,
|
|
45
54
|
type: row.type,
|
|
46
|
-
|
|
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 || '',
|
|
47
59
|
instruction: row.instruction,
|
|
48
60
|
context: parseJson(row.context, []),
|
|
49
61
|
contractRevision: row.contract_revision,
|
|
@@ -138,10 +150,13 @@ export class WorkItemStore {
|
|
|
138
150
|
goal TEXT NOT NULL,
|
|
139
151
|
acceptance_criteria TEXT NOT NULL,
|
|
140
152
|
workflow_template TEXT NOT NULL,
|
|
153
|
+
workflow_snapshot TEXT,
|
|
141
154
|
status TEXT NOT NULL,
|
|
142
155
|
current_action_id TEXT,
|
|
143
156
|
current_run_id TEXT,
|
|
144
157
|
work_dir TEXT NOT NULL DEFAULT '',
|
|
158
|
+
workspace_key TEXT NOT NULL DEFAULT '',
|
|
159
|
+
reuse_memory INTEGER NOT NULL DEFAULT 1,
|
|
145
160
|
origin TEXT,
|
|
146
161
|
linked_session_ids TEXT NOT NULL DEFAULT '[]',
|
|
147
162
|
created_at INTEGER NOT NULL,
|
|
@@ -153,6 +168,9 @@ export class WorkItemStore {
|
|
|
153
168
|
sequence INTEGER NOT NULL,
|
|
154
169
|
type TEXT NOT NULL,
|
|
155
170
|
required_role TEXT NOT NULL,
|
|
171
|
+
stage_id TEXT,
|
|
172
|
+
assignment_policy TEXT,
|
|
173
|
+
model_policy TEXT,
|
|
156
174
|
instruction TEXT NOT NULL,
|
|
157
175
|
context TEXT NOT NULL DEFAULT '[]',
|
|
158
176
|
contract_revision INTEGER NOT NULL DEFAULT 1,
|
|
@@ -202,7 +220,20 @@ export class WorkItemStore {
|
|
|
202
220
|
`);
|
|
203
221
|
|
|
204
222
|
// The feature shipped first as an unmerged PR, but keep the store tolerant
|
|
205
|
-
// of
|
|
223
|
+
// of databases created by review builds.
|
|
224
|
+
if (!hasColumn(this.db, 'work_items', 'workspace_key')) {
|
|
225
|
+
withTransaction(this.db, () => {
|
|
226
|
+
this.db.exec("ALTER TABLE work_items ADD COLUMN workspace_key TEXT NOT NULL DEFAULT ''");
|
|
227
|
+
const update = this.db.prepare('UPDATE work_items SET workspace_key = ? WHERE id = ?');
|
|
228
|
+
for (const row of this.db.prepare("SELECT id, work_dir FROM work_items WHERE work_dir != ''").all()) {
|
|
229
|
+
const workspaceKey = canonicalWorkspaceKey(row.work_dir);
|
|
230
|
+
if (workspaceKey) update.run(workspaceKey, row.id);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (!hasColumn(this.db, 'work_items', 'reuse_memory')) {
|
|
235
|
+
this.db.exec('ALTER TABLE work_items ADD COLUMN reuse_memory INTEGER NOT NULL DEFAULT 1');
|
|
236
|
+
}
|
|
206
237
|
if (!hasColumn(this.db, 'actions', 'context')) {
|
|
207
238
|
this.db.exec("ALTER TABLE actions ADD COLUMN context TEXT NOT NULL DEFAULT '[]'");
|
|
208
239
|
}
|
|
@@ -218,6 +249,18 @@ export class WorkItemStore {
|
|
|
218
249
|
if (!hasColumn(this.db, 'runs', 'contract_patch')) {
|
|
219
250
|
this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
|
|
220
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
|
+
}
|
|
221
264
|
this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
|
|
222
265
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
|
|
223
266
|
}
|
|
@@ -244,18 +287,22 @@ export class WorkItemStore {
|
|
|
244
287
|
return withTransaction(this.db, () => {
|
|
245
288
|
const now = this.now();
|
|
246
289
|
const id = input.id || randomUUID();
|
|
290
|
+
const workspaceKey = canonicalWorkspaceKey(input.workDir);
|
|
247
291
|
this.db.prepare(`INSERT INTO work_items
|
|
248
|
-
(id, revision, title, goal, acceptance_criteria, workflow_template, status,
|
|
249
|
-
current_action_id, current_run_id, work_dir, origin, linked_session_ids,
|
|
292
|
+
(id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
|
|
293
|
+
current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
|
|
250
294
|
created_at, updated_at)
|
|
251
|
-
VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`).run(
|
|
295
|
+
VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
252
296
|
id,
|
|
253
297
|
input.title,
|
|
254
298
|
input.goal,
|
|
255
299
|
stringify(input.acceptanceCriteria || []),
|
|
256
300
|
input.workflowTemplate || 'software-change',
|
|
301
|
+
stringify(input.workflowSnapshot || null),
|
|
257
302
|
firstAction ? 'ready' : 'draft',
|
|
258
303
|
input.workDir || '',
|
|
304
|
+
workspaceKey,
|
|
305
|
+
input.reuseMemory === false ? 0 : 1,
|
|
259
306
|
stringify(input.origin || null),
|
|
260
307
|
stringify(input.linkedSessionIds || []),
|
|
261
308
|
now,
|
|
@@ -277,7 +324,10 @@ export class WorkItemStore {
|
|
|
277
324
|
workItemId,
|
|
278
325
|
sequence,
|
|
279
326
|
type: input.type,
|
|
280
|
-
|
|
327
|
+
stageId: input.stageId || input.type,
|
|
328
|
+
assignmentPolicy: input.assignmentPolicy || null,
|
|
329
|
+
modelPolicy: input.modelPolicy || null,
|
|
330
|
+
requiredRole: input.requiredRole || '',
|
|
281
331
|
instruction: input.instruction || '',
|
|
282
332
|
context: Array.isArray(input.context) ? input.context : [],
|
|
283
333
|
contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
|
|
@@ -290,15 +340,18 @@ export class WorkItemStore {
|
|
|
290
340
|
updatedAt: now,
|
|
291
341
|
};
|
|
292
342
|
this.db.prepare(`INSERT INTO actions
|
|
293
|
-
(id, work_item_id, sequence, type, required_role,
|
|
294
|
-
contract_revision, status, attempt, max_attempts, current_run_id,
|
|
295
|
-
created_at, updated_at)
|
|
296
|
-
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(
|
|
297
347
|
action.id,
|
|
298
348
|
workItemId,
|
|
299
349
|
action.sequence,
|
|
300
350
|
action.type,
|
|
301
351
|
action.requiredRole,
|
|
352
|
+
action.stageId,
|
|
353
|
+
stringify(action.assignmentPolicy),
|
|
354
|
+
stringify(action.modelPolicy),
|
|
302
355
|
action.instruction,
|
|
303
356
|
stringify(action.context),
|
|
304
357
|
action.contractRevision,
|
|
@@ -332,6 +385,16 @@ export class WorkItemStore {
|
|
|
332
385
|
return mapRun(this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
|
|
333
386
|
}
|
|
334
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
|
+
|
|
335
398
|
listWorkItems(filters = {}) {
|
|
336
399
|
const where = [];
|
|
337
400
|
const values = [];
|
|
@@ -366,6 +429,64 @@ export class WorkItemStore {
|
|
|
366
429
|
};
|
|
367
430
|
}
|
|
368
431
|
|
|
432
|
+
getReusableContext(workDir, excludeWorkItemId = null) {
|
|
433
|
+
const workspaceKey = canonicalWorkspaceKey(workDir);
|
|
434
|
+
if (!workspaceKey) return [];
|
|
435
|
+
const rows = this.db.prepare(`SELECT r.*, a.type AS action_type, a.required_role,
|
|
436
|
+
w.title AS source_title
|
|
437
|
+
FROM runs r
|
|
438
|
+
JOIN actions a ON a.id = r.action_id
|
|
439
|
+
JOIN work_items w ON w.id = r.work_item_id
|
|
440
|
+
WHERE w.workspace_key = ? AND w.reuse_memory = 1 AND w.id != ? AND w.status = 'done'
|
|
441
|
+
AND r.status = 'completed' AND length(trim(COALESCE(r.summary, ''))) > 0
|
|
442
|
+
ORDER BY r.ended_at DESC, r.started_at DESC
|
|
443
|
+
LIMIT ?`).all(workspaceKey, excludeWorkItemId || '', MAX_REUSABLE_CONTEXT_ITEMS);
|
|
444
|
+
return rows.reverse().map(row => {
|
|
445
|
+
const run = mapRun(row);
|
|
446
|
+
return {
|
|
447
|
+
type: row.action_type,
|
|
448
|
+
role: row.required_role,
|
|
449
|
+
summary: run.summary,
|
|
450
|
+
evidence: run.evidence,
|
|
451
|
+
reviewDecision: run.reviewDecision,
|
|
452
|
+
sourceTitle: row.source_title,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
addActionGuidance(id, guidance, expected, makeAction) {
|
|
458
|
+
return withTransaction(this.db, () => {
|
|
459
|
+
const workItem = this.getWorkItem(id);
|
|
460
|
+
if (!workItem) return null;
|
|
461
|
+
if (workItem.currentActionId !== expected.actionId || workItem.revision !== expected.revision) {
|
|
462
|
+
throw new Error('Action changed before guidance was applied; refresh and try again');
|
|
463
|
+
}
|
|
464
|
+
if (!['ready', 'running'].includes(workItem.status)) {
|
|
465
|
+
throw new Error(`WorkItem in ${workItem.status} cannot accept Action guidance`);
|
|
466
|
+
}
|
|
467
|
+
const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
|
|
468
|
+
if (!previous || !['ready', 'running'].includes(previous.status)) {
|
|
469
|
+
throw new Error('WorkItem has no active Action for guidance');
|
|
470
|
+
}
|
|
471
|
+
const now = this.now();
|
|
472
|
+
this.#invalidateExecution(
|
|
473
|
+
workItem,
|
|
474
|
+
'superseded',
|
|
475
|
+
'superseded',
|
|
476
|
+
'Action restarted after user guidance',
|
|
477
|
+
now,
|
|
478
|
+
);
|
|
479
|
+
const action = this.#insertAction(id, {
|
|
480
|
+
...makeAction(workItem, previous),
|
|
481
|
+
contractRevision: workItem.revision,
|
|
482
|
+
}, this.#nextSequence(id), now);
|
|
483
|
+
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
484
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
|
|
485
|
+
this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
|
|
486
|
+
return this.getWorkItemDetail(id);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
369
490
|
#invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
|
|
370
491
|
if (workItem.currentRunId) {
|
|
371
492
|
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
|
|
@@ -403,11 +524,12 @@ export class WorkItemStore {
|
|
|
403
524
|
);
|
|
404
525
|
}
|
|
405
526
|
this.db.prepare(`UPDATE work_items SET title = ?, goal = ?, acceptance_criteria = ?,
|
|
406
|
-
work_dir = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
|
|
527
|
+
work_dir = ?, workspace_key = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
|
|
407
528
|
next.title,
|
|
408
529
|
next.goal,
|
|
409
530
|
stringify(next.acceptanceCriteria),
|
|
410
531
|
next.workDir,
|
|
532
|
+
canonicalWorkspaceKey(next.workDir),
|
|
411
533
|
revision,
|
|
412
534
|
now,
|
|
413
535
|
id,
|