@yeaft/webchat-agent 1.0.123 → 1.0.125
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/connection/buffer.js +5 -1
- package/connection/message-router.js +5 -0
- package/connection/upgrade.js +7 -1
- package/index.js +16 -5
- package/package.json +1 -1
- package/yeaft/engine.js +2 -2
- package/yeaft/sub-agent/runner.js +1 -0
- package/yeaft/tools/apply-patch.js +15 -5
- package/yeaft/tools/create-work-item.js +79 -0
- package/yeaft/tools/index.js +2 -0
- package/yeaft/web-bridge.js +1 -1
- package/yeaft/work-center/bridge.js +142 -0
- package/yeaft/work-center/controller.js +213 -0
- package/yeaft/work-center/evidence.js +48 -0
- package/yeaft/work-center/index.js +7 -0
- package/yeaft/work-center/projection.js +54 -0
- package/yeaft/work-center/runner.js +292 -0
- package/yeaft/work-center/service.js +119 -0
- package/yeaft/work-center/store.js +783 -0
- package/yeaft/work-center/watcher.js +123 -0
- package/yeaft/work-center/workflow.js +99 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const ALLOWED_KINDS = new Set(['text', 'tool', 'test', 'file', 'link', 'pr', 'commit']);
|
|
2
|
+
const ALLOWED_STATUSES = new Set(['completed', 'passed', 'failed', 'error', 'pending']);
|
|
3
|
+
const MAX_ITEMS = 50;
|
|
4
|
+
const MAX_LABEL_LENGTH = 500;
|
|
5
|
+
const MAX_REF_LENGTH = 1_000;
|
|
6
|
+
|
|
7
|
+
function boundedString(value, maxLength) {
|
|
8
|
+
if (typeof value !== 'string') return '';
|
|
9
|
+
return value.trim().slice(0, maxLength);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeEvidenceItem(value) {
|
|
13
|
+
if (typeof value === 'string') {
|
|
14
|
+
const label = boundedString(value, MAX_LABEL_LENGTH);
|
|
15
|
+
return label ? { kind: 'text', label } : null;
|
|
16
|
+
}
|
|
17
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
18
|
+
|
|
19
|
+
if (typeof value.tool === 'string') {
|
|
20
|
+
const label = boundedString(value.tool, MAX_LABEL_LENGTH);
|
|
21
|
+
if (!label) return null;
|
|
22
|
+
return {
|
|
23
|
+
kind: 'tool',
|
|
24
|
+
label,
|
|
25
|
+
status: value.isError === true ? 'error' : 'completed',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const kind = ALLOWED_KINDS.has(value.kind) ? value.kind : null;
|
|
30
|
+
const label = boundedString(value.label, MAX_LABEL_LENGTH);
|
|
31
|
+
if (!kind || !label) return null;
|
|
32
|
+
const item = { kind, label };
|
|
33
|
+
const ref = boundedString(value.ref, MAX_REF_LENGTH);
|
|
34
|
+
if (ref) item.ref = ref;
|
|
35
|
+
if (ALLOWED_STATUSES.has(value.status)) item.status = value.status;
|
|
36
|
+
return item;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeEvidence(value) {
|
|
40
|
+
if (!Array.isArray(value)) return [];
|
|
41
|
+
const result = [];
|
|
42
|
+
for (const raw of value) {
|
|
43
|
+
const item = normalizeEvidenceItem(raw);
|
|
44
|
+
if (item) result.push(item);
|
|
45
|
+
if (result.length >= MAX_ITEMS) break;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { WorkItemStore } from './store.js';
|
|
2
|
+
export { WorkflowController } from './controller.js';
|
|
3
|
+
export { WorkCenterService } from './service.js';
|
|
4
|
+
export { WorkItemWatcher } from './watcher.js';
|
|
5
|
+
export { WorkItemRunner, createWorkItemToolRegistry, parseStructuredResult, workItemToolPolicySnapshot } from './runner.js';
|
|
6
|
+
export { projectWorkItemSummary, projectWorkCenterEvent } from './projection.js';
|
|
7
|
+
export { WORKFLOW_TEMPLATES, RUN_OUTCOMES } from './workflow.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function currentAction(detail) {
|
|
2
|
+
if (!detail?.currentActionId || !Array.isArray(detail.actions)) return null;
|
|
3
|
+
return detail.actions.find(action => action.id === detail.currentActionId) || null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Browser event projection. This deliberately excludes local filesystem paths,
|
|
8
|
+
* Run evidence/tool output, prompts, model snapshots, and execution errors.
|
|
9
|
+
* Clients fetch the selected detail explicitly through the authenticated get op.
|
|
10
|
+
*/
|
|
11
|
+
export function projectWorkItemSummary(detail) {
|
|
12
|
+
if (!detail) return null;
|
|
13
|
+
if (!Array.isArray(detail.actions)) {
|
|
14
|
+
return {
|
|
15
|
+
id: detail.id,
|
|
16
|
+
revision: detail.revision,
|
|
17
|
+
title: detail.title,
|
|
18
|
+
goal: detail.goal,
|
|
19
|
+
status: detail.status,
|
|
20
|
+
currentActionId: detail.currentActionId || null,
|
|
21
|
+
currentAction: null,
|
|
22
|
+
origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
|
|
23
|
+
linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
|
|
24
|
+
createdAt: detail.createdAt,
|
|
25
|
+
updatedAt: detail.updatedAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const action = currentAction(detail);
|
|
29
|
+
return {
|
|
30
|
+
id: detail.id,
|
|
31
|
+
revision: detail.revision,
|
|
32
|
+
title: detail.title,
|
|
33
|
+
goal: detail.goal,
|
|
34
|
+
status: detail.status,
|
|
35
|
+
currentActionId: detail.currentActionId || null,
|
|
36
|
+
currentAction: action ? {
|
|
37
|
+
id: action.id,
|
|
38
|
+
type: action.type,
|
|
39
|
+
requiredRole: action.requiredRole,
|
|
40
|
+
status: action.status,
|
|
41
|
+
} : null,
|
|
42
|
+
origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
|
|
43
|
+
linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
|
|
44
|
+
createdAt: detail.createdAt,
|
|
45
|
+
updatedAt: detail.updatedAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function projectWorkCenterEvent(event) {
|
|
50
|
+
return {
|
|
51
|
+
type: event?.type || 'work_item.updated',
|
|
52
|
+
workItem: projectWorkItemSummary(event?.workItem),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Engine } from '../engine.js';
|
|
2
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { allTools } from '../tools/index.js';
|
|
4
|
+
import { parsePatch } from '../tools/apply-patch.js';
|
|
5
|
+
import { defaultRegistry } from '../vp/registry.js';
|
|
6
|
+
import { NullTrace } from '../debug-trace.js';
|
|
7
|
+
import { isPathInsideOrEqual } from '../tools/path-safety.js';
|
|
8
|
+
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const WORK_ITEM_TOOL_NAMES = Object.freeze([
|
|
12
|
+
'FileRead',
|
|
13
|
+
'FileWrite',
|
|
14
|
+
'FileEdit',
|
|
15
|
+
'ApplyPatch',
|
|
16
|
+
'Glob',
|
|
17
|
+
'Grep',
|
|
18
|
+
'ListDir',
|
|
19
|
+
'Bash',
|
|
20
|
+
'WebSearch',
|
|
21
|
+
'WebFetch',
|
|
22
|
+
]);
|
|
23
|
+
const WORK_ITEM_TOOL_ALLOWLIST = new Set(WORK_ITEM_TOOL_NAMES);
|
|
24
|
+
|
|
25
|
+
function copyVp(vp) {
|
|
26
|
+
if (!vp) return null;
|
|
27
|
+
return {
|
|
28
|
+
id: vp.id,
|
|
29
|
+
name: vp.name,
|
|
30
|
+
nameZh: vp.nameZh || '',
|
|
31
|
+
role: vp.role,
|
|
32
|
+
roleZh: vp.roleZh || '',
|
|
33
|
+
traits: Array.isArray(vp.traits) ? [...vp.traits] : [],
|
|
34
|
+
modelHint: vp.modelHint || null,
|
|
35
|
+
persona: vp.persona || '',
|
|
36
|
+
personaHash: vp.personaHash || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function personaFor(vp) {
|
|
41
|
+
return {
|
|
42
|
+
vpId: vp.id,
|
|
43
|
+
displayName: vp.name || vp.id,
|
|
44
|
+
displayNameZh: vp.nameZh || vp.name || vp.id,
|
|
45
|
+
role: vp.role || '',
|
|
46
|
+
roleZh: vp.roleZh || vp.role || '',
|
|
47
|
+
persona: vp.persona || '',
|
|
48
|
+
planInstruction: '',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function canonicalWorkDir(workDir) {
|
|
53
|
+
if (!existsSync(workDir)) throw new Error(`WorkItem workDir does not exist: ${workDir}`);
|
|
54
|
+
return realpathSync(workDir);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertPathInside(toolName, workDir, value) {
|
|
58
|
+
const resolved = path.resolve(workDir, value);
|
|
59
|
+
if (!isPathInsideOrEqual(workDir, resolved)) {
|
|
60
|
+
throw new Error(`${toolName} path escapes the WorkItem workDir`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const relative = path.relative(workDir, resolved);
|
|
64
|
+
let current = workDir;
|
|
65
|
+
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
66
|
+
current = path.join(current, component);
|
|
67
|
+
try {
|
|
68
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
69
|
+
throw new Error(`${toolName} path escapes the WorkItem workDir through a symbolic link`);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error?.code === 'ENOENT') break;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertToolInput(toolName, input, workDir) {
|
|
80
|
+
if (toolName === 'Bash') {
|
|
81
|
+
if (input?.background === true) throw new Error('Work Center does not allow background Bash jobs');
|
|
82
|
+
if (input?.cwd && canonicalWorkDir(path.resolve(input.cwd)) !== workDir) {
|
|
83
|
+
throw new Error('Work Center Bash cwd is fixed to the WorkItem workDir');
|
|
84
|
+
}
|
|
85
|
+
// fixedCwd is process setup, not a shell sandbox. The WorkItem role is
|
|
86
|
+
// trusted; containers are required before running untrusted shell input.
|
|
87
|
+
return { ...input, cwd: workDir, background: false };
|
|
88
|
+
}
|
|
89
|
+
const pathKeys = ['file_path', 'notebook_path', 'output_path', 'path'];
|
|
90
|
+
const next = { ...(input || {}) };
|
|
91
|
+
for (const key of pathKeys) {
|
|
92
|
+
if (typeof next[key] !== 'string' || !next[key]) continue;
|
|
93
|
+
next[key] = assertPathInside(toolName, workDir, next[key]);
|
|
94
|
+
}
|
|
95
|
+
if (toolName === 'ApplyPatch' && typeof next.patch === 'string') {
|
|
96
|
+
for (const fileDiff of parsePatch(next.patch)) {
|
|
97
|
+
assertPathInside('ApplyPatch', workDir, fileDiff.file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function workItemToolPolicySnapshot(workDir) {
|
|
104
|
+
return {
|
|
105
|
+
policyVersion: 1,
|
|
106
|
+
allowedToolNames: [...WORK_ITEM_TOOL_NAMES],
|
|
107
|
+
readRoots: [workDir],
|
|
108
|
+
writeRoots: [workDir],
|
|
109
|
+
shell: { enabled: true, fixedCwd: workDir, background: false, sandboxed: false },
|
|
110
|
+
async: false,
|
|
111
|
+
mcpTools: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createWorkItemToolRegistry({ workDir, isRunActive }) {
|
|
116
|
+
const canonicalDir = canonicalWorkDir(path.resolve(workDir));
|
|
117
|
+
const registry = new ToolRegistry();
|
|
118
|
+
for (const tool of allTools) {
|
|
119
|
+
if (!WORK_ITEM_TOOL_ALLOWLIST.has(tool.name)) continue;
|
|
120
|
+
registry.register({
|
|
121
|
+
...tool,
|
|
122
|
+
async execute(input, ctx) {
|
|
123
|
+
if (!isRunActive()) throw new Error('Work Center Run lease is no longer active');
|
|
124
|
+
const output = await tool.execute(assertToolInput(tool.name, input, canonicalDir), {
|
|
125
|
+
...ctx,
|
|
126
|
+
cwd: canonicalDir,
|
|
127
|
+
workDir: canonicalDir,
|
|
128
|
+
});
|
|
129
|
+
if (!isRunActive()) throw new Error('Work Center Run lease was lost during tool execution');
|
|
130
|
+
return output;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return registry;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseStructuredResult(text, actionType) {
|
|
138
|
+
const fenced = String(text || '').match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
139
|
+
const candidates = [fenced?.[1], String(text || '')].filter(Boolean);
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(candidate.trim());
|
|
143
|
+
if (!['completed', 'waiting', 'retryable', 'failed'].includes(parsed.outcome)) continue;
|
|
144
|
+
const result = {
|
|
145
|
+
outcome: parsed.outcome,
|
|
146
|
+
summary: String(parsed.summary || ''),
|
|
147
|
+
evidence: Array.isArray(parsed.evidence) ? parsed.evidence : [],
|
|
148
|
+
waitingReason: parsed.waitingReason ? String(parsed.waitingReason) : null,
|
|
149
|
+
error: parsed.error ? String(parsed.error) : null,
|
|
150
|
+
reviewDecision: ['approved', 'changes_requested'].includes(parsed.reviewDecision)
|
|
151
|
+
? parsed.reviewDecision
|
|
152
|
+
: null,
|
|
153
|
+
contractPatch: actionType === 'triage' && parsed.contractPatch && typeof parsed.contractPatch === 'object'
|
|
154
|
+
? parsed.contractPatch
|
|
155
|
+
: null,
|
|
156
|
+
};
|
|
157
|
+
if (actionType === 'review' && result.outcome === 'completed' && !result.reviewDecision) {
|
|
158
|
+
return {
|
|
159
|
+
...result,
|
|
160
|
+
outcome: 'failed',
|
|
161
|
+
error: 'Completed review requires approved or changes_requested',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
outcome: 'failed',
|
|
169
|
+
summary: String(text || '').trim(),
|
|
170
|
+
evidence: [],
|
|
171
|
+
error: 'Agent did not submit the required structured Work Center outcome',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function completionContract(action) {
|
|
176
|
+
const reviewField = action.type === 'review'
|
|
177
|
+
? ',\n "reviewDecision": "approved|changes_requested"'
|
|
178
|
+
: '';
|
|
179
|
+
const triageField = action.type === 'triage'
|
|
180
|
+
? ',\n "contractPatch": { "goal": "optional refined goal", "acceptanceCriteria": ["optional refined criterion"] }'
|
|
181
|
+
: '';
|
|
182
|
+
return `\n\nYou are executing one Work Center Action. End your response with exactly one JSON object, preferably in a json code fence:\n{
|
|
183
|
+
"outcome": "completed|waiting|retryable|failed",
|
|
184
|
+
"summary": "short result",
|
|
185
|
+
"evidence": ["test, PR, file, or other verifiable evidence"],
|
|
186
|
+
"waitingReason": null,
|
|
187
|
+
"error": null${reviewField}${triageField}
|
|
188
|
+
}\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
|
+
}
|
|
190
|
+
|
|
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
|
+
export class WorkItemRunner {
|
|
198
|
+
constructor(options) {
|
|
199
|
+
this.runtimeProvider = options.runtimeProvider;
|
|
200
|
+
this.store = options.store;
|
|
201
|
+
this.registry = options.registry || defaultRegistry;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async run({ workItem, action, run, signal, ownerBootId }) {
|
|
205
|
+
const runtime = await this.runtimeProvider();
|
|
206
|
+
const rawWorkDir = workItem.workDir || runtime.defaultWorkDir || process.cwd();
|
|
207
|
+
const workDir = canonicalWorkDir(path.resolve(rawWorkDir));
|
|
208
|
+
const vp = copyVp(this.registry.getVp(action.requiredRole));
|
|
209
|
+
if (!vp) {
|
|
210
|
+
const error = new Error(`Required Work Center role is unavailable: ${action.requiredRole}`);
|
|
211
|
+
error.retryable = false;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const isRunActive = () => !signal.aborted
|
|
215
|
+
&& this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
|
|
216
|
+
const toolPolicySnapshot = workItemToolPolicySnapshot(workDir);
|
|
217
|
+
const toolRegistry = createWorkItemToolRegistry({ workDir, isRunActive });
|
|
218
|
+
const model = resolveModel(runtime.config, vp);
|
|
219
|
+
const config = {
|
|
220
|
+
...runtime.config,
|
|
221
|
+
model,
|
|
222
|
+
_readOnly: true,
|
|
223
|
+
serverMode: true,
|
|
224
|
+
// WorkItem messages live in the Work Center DB. Never archive their
|
|
225
|
+
// transient tool bodies into the user memory tree.
|
|
226
|
+
archive: { ...(runtime.config.archive || {}), toolResults: false },
|
|
227
|
+
};
|
|
228
|
+
if (!this.store || typeof this.store.setRunExecutionSnapshots !== 'function') {
|
|
229
|
+
throw new Error('Work Center Runner requires a persistent Run store');
|
|
230
|
+
}
|
|
231
|
+
const snapshotsWritten = this.store.setRunExecutionSnapshots(
|
|
232
|
+
run.id,
|
|
233
|
+
ownerBootId,
|
|
234
|
+
run.leaseEpoch,
|
|
235
|
+
{
|
|
236
|
+
roleSnapshot: { id: action.requiredRole, actionType: action.type },
|
|
237
|
+
vpSnapshot: vp,
|
|
238
|
+
modelSnapshot: { id: model, provider: runtime.config.provider || null },
|
|
239
|
+
toolPolicySnapshot,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
if (!snapshotsWritten) throw new Error('Work Center Run lost its lease before execution');
|
|
243
|
+
|
|
244
|
+
const engine = new Engine({
|
|
245
|
+
adapter: runtime.adapter,
|
|
246
|
+
trace: new NullTrace(),
|
|
247
|
+
config,
|
|
248
|
+
conversationStore: null,
|
|
249
|
+
memoryIndex: null,
|
|
250
|
+
amsRegistry: null,
|
|
251
|
+
toolRegistry,
|
|
252
|
+
skillManager: null,
|
|
253
|
+
mcpManager: null,
|
|
254
|
+
// WorkItem execution has its own durable Run/Event record. Do not let
|
|
255
|
+
// the Engine create Session exec logs, archives, or shared tool stats.
|
|
256
|
+
yeaftDir: null,
|
|
257
|
+
toolStats: null,
|
|
258
|
+
taskManager: null,
|
|
259
|
+
vpId: vp.id,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
let text = '';
|
|
263
|
+
const toolEvidence = [];
|
|
264
|
+
try {
|
|
265
|
+
for await (const event of engine.query({
|
|
266
|
+
prompt: `${action.instruction}${completionContract(action)}`,
|
|
267
|
+
messages: [],
|
|
268
|
+
signal,
|
|
269
|
+
scenario: 'work-item',
|
|
270
|
+
vpPersona: personaFor(vp),
|
|
271
|
+
workDir,
|
|
272
|
+
userAlreadyPersisted: true,
|
|
273
|
+
collabToolPolicy: 'single-vp',
|
|
274
|
+
})) {
|
|
275
|
+
if (typeof event?.text === 'string') text += event.text;
|
|
276
|
+
else if (typeof event?.delta === 'string') text += event.delta;
|
|
277
|
+
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
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
try { engine.abort?.('work_item_run_finished'); } catch {}
|
|
287
|
+
}
|
|
288
|
+
const result = parseStructuredResult(text, action.type);
|
|
289
|
+
result.evidence = [...result.evidence, ...toolEvidence].slice(-100);
|
|
290
|
+
return result;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { WorkItemStore } from './store.js';
|
|
4
|
+
import { WorkflowController } from './controller.js';
|
|
5
|
+
import { WorkItemWatcher } from './watcher.js';
|
|
6
|
+
import { projectWorkItemSummary } from './projection.js';
|
|
7
|
+
|
|
8
|
+
function requiredString(value, name) {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required`);
|
|
10
|
+
return value.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class WorkCenterService {
|
|
14
|
+
constructor(options) {
|
|
15
|
+
const yeaftDir = requiredString(options?.yeaftDir, 'yeaftDir');
|
|
16
|
+
this.ownerBootId = options.ownerBootId || randomUUID();
|
|
17
|
+
this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
|
|
18
|
+
this.controller = options.controller || new WorkflowController(this.store);
|
|
19
|
+
this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
|
|
20
|
+
this.watcher = new WorkItemWatcher({
|
|
21
|
+
store: this.store,
|
|
22
|
+
controller: this.controller,
|
|
23
|
+
runner: options.runner,
|
|
24
|
+
ownerBootId: this.ownerBootId,
|
|
25
|
+
onEvent: event => this.#emit(event),
|
|
26
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
27
|
+
leaseMs: options.leaseMs,
|
|
28
|
+
});
|
|
29
|
+
this.store.recoverInterruptedRuns(this.ownerBootId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(op, payload = {}) {
|
|
33
|
+
switch (op) {
|
|
34
|
+
case 'list':
|
|
35
|
+
return {
|
|
36
|
+
items: this.store.listWorkItems(payload).map(projectWorkItemSummary),
|
|
37
|
+
watcher: this.watcher.status(),
|
|
38
|
+
};
|
|
39
|
+
case 'get':
|
|
40
|
+
return this.#requiredItem(payload.id);
|
|
41
|
+
case 'create': {
|
|
42
|
+
const item = this.controller.create({
|
|
43
|
+
title: requiredString(payload.title, 'title'),
|
|
44
|
+
goal: requiredString(payload.goal, 'goal'),
|
|
45
|
+
acceptanceCriteria: Array.isArray(payload.acceptanceCriteria)
|
|
46
|
+
? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean)
|
|
47
|
+
: [],
|
|
48
|
+
workflowTemplate: payload.workflowTemplate || 'software-change',
|
|
49
|
+
workDir: typeof payload.workDir === 'string' ? payload.workDir.trim() : '',
|
|
50
|
+
origin: payload.origin && typeof payload.origin === 'object'
|
|
51
|
+
? {
|
|
52
|
+
sessionId: typeof payload.origin.sessionId === 'string' ? payload.origin.sessionId : null,
|
|
53
|
+
messageId: typeof payload.origin.messageId === 'string' ? payload.origin.messageId : null,
|
|
54
|
+
createdBy: typeof payload.origin.createdBy === 'string' ? payload.origin.createdBy : null,
|
|
55
|
+
}
|
|
56
|
+
: null,
|
|
57
|
+
linkedSessionIds: Array.isArray(payload.linkedSessionIds)
|
|
58
|
+
? [...new Set(payload.linkedSessionIds.map(value => String(value).trim()).filter(Boolean))]
|
|
59
|
+
: [],
|
|
60
|
+
start: payload.start !== false,
|
|
61
|
+
});
|
|
62
|
+
const detail = this.#requiredItem(item.id);
|
|
63
|
+
this.#emit({ type: 'work_item.created', workItem: detail });
|
|
64
|
+
return detail;
|
|
65
|
+
}
|
|
66
|
+
case 'update': {
|
|
67
|
+
const id = requiredString(payload.id, 'id');
|
|
68
|
+
const detail = this.controller.update(id, payload.patch || {});
|
|
69
|
+
this.watcher.abortInvalidWorkItemRuns(id);
|
|
70
|
+
this.#emit({ type: 'work_item.updated', workItem: detail });
|
|
71
|
+
return detail;
|
|
72
|
+
}
|
|
73
|
+
case 'start': {
|
|
74
|
+
const detail = this.controller.start(requiredString(payload.id, 'id'));
|
|
75
|
+
this.#emit({ type: 'work_item.started', workItem: detail });
|
|
76
|
+
return detail;
|
|
77
|
+
}
|
|
78
|
+
case 'cancel': {
|
|
79
|
+
const id = requiredString(payload.id, 'id');
|
|
80
|
+
const detail = this.controller.cancel(id);
|
|
81
|
+
this.watcher.abortInvalidWorkItemRuns(id);
|
|
82
|
+
this.#emit({ type: 'work_item.cancelled', workItem: detail });
|
|
83
|
+
return detail;
|
|
84
|
+
}
|
|
85
|
+
case 'retry': {
|
|
86
|
+
const detail = this.controller.retry(requiredString(payload.id, 'id'), {
|
|
87
|
+
answer: typeof payload.answer === 'string' ? payload.answer : '',
|
|
88
|
+
});
|
|
89
|
+
this.#emit({ type: 'work_item.retried', workItem: detail });
|
|
90
|
+
return detail;
|
|
91
|
+
}
|
|
92
|
+
case 'set_watcher':
|
|
93
|
+
if (payload.enabled === false) await this.watcher.stop();
|
|
94
|
+
else this.watcher.start();
|
|
95
|
+
return this.watcher.status();
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`Unsupported Work Center operation: ${op || '(missing)'}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#requiredItem(id) {
|
|
102
|
+
const item = this.store.getWorkItemDetail(requiredString(id, 'id'));
|
|
103
|
+
if (!item) throw new Error(`WorkItem not found: ${id}`);
|
|
104
|
+
return item;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#emit(event) {
|
|
108
|
+
try { this.onEvent(event); } catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
start() {
|
|
112
|
+
this.watcher.start();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async shutdown() {
|
|
116
|
+
await this.watcher.stop();
|
|
117
|
+
this.store.close();
|
|
118
|
+
}
|
|
119
|
+
}
|