@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,783 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { normalizeEvidence } from './evidence.js';
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 2;
|
|
8
|
+
const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
|
|
9
|
+
|
|
10
|
+
function parseJson(value, fallback) {
|
|
11
|
+
if (typeof value !== 'string' || !value) return fallback;
|
|
12
|
+
try { return JSON.parse(value); } catch { return fallback; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stringify(value) {
|
|
16
|
+
return JSON.stringify(value ?? null);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapWorkItem(row) {
|
|
20
|
+
if (!row) return null;
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
revision: row.revision,
|
|
24
|
+
title: row.title,
|
|
25
|
+
goal: row.goal,
|
|
26
|
+
acceptanceCriteria: parseJson(row.acceptance_criteria, []),
|
|
27
|
+
workflowTemplate: row.workflow_template,
|
|
28
|
+
status: row.status,
|
|
29
|
+
currentActionId: row.current_action_id || null,
|
|
30
|
+
currentRunId: row.current_run_id || null,
|
|
31
|
+
workDir: row.work_dir || '',
|
|
32
|
+
origin: parseJson(row.origin, null),
|
|
33
|
+
linkedSessionIds: parseJson(row.linked_session_ids, []),
|
|
34
|
+
createdAt: row.created_at,
|
|
35
|
+
updatedAt: row.updated_at,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mapAction(row) {
|
|
40
|
+
if (!row) return null;
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
workItemId: row.work_item_id,
|
|
44
|
+
sequence: row.sequence,
|
|
45
|
+
type: row.type,
|
|
46
|
+
requiredRole: row.required_role,
|
|
47
|
+
instruction: row.instruction,
|
|
48
|
+
context: parseJson(row.context, []),
|
|
49
|
+
contractRevision: row.contract_revision,
|
|
50
|
+
status: row.status,
|
|
51
|
+
attempt: row.attempt,
|
|
52
|
+
maxAttempts: row.max_attempts,
|
|
53
|
+
currentRunId: row.current_run_id || null,
|
|
54
|
+
leaseEpoch: row.lease_epoch,
|
|
55
|
+
createdAt: row.created_at,
|
|
56
|
+
updatedAt: row.updated_at,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mapRun(row) {
|
|
61
|
+
if (!row) return null;
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
actionId: row.action_id,
|
|
65
|
+
workItemId: row.work_item_id,
|
|
66
|
+
ownerBootId: row.owner_boot_id,
|
|
67
|
+
leaseEpoch: row.lease_epoch,
|
|
68
|
+
status: row.status,
|
|
69
|
+
startedAt: row.started_at,
|
|
70
|
+
expiresAt: row.expires_at,
|
|
71
|
+
endedAt: row.ended_at || null,
|
|
72
|
+
roleSnapshot: parseJson(row.role_snapshot, null),
|
|
73
|
+
vpSnapshot: parseJson(row.vp_snapshot, null),
|
|
74
|
+
modelSnapshot: parseJson(row.model_snapshot, null),
|
|
75
|
+
toolPolicySnapshot: parseJson(row.tool_policy_snapshot, null),
|
|
76
|
+
summary: row.summary || '',
|
|
77
|
+
evidence: normalizeEvidence(parseJson(row.evidence, [])),
|
|
78
|
+
waitingReason: row.waiting_reason || null,
|
|
79
|
+
error: row.error || null,
|
|
80
|
+
reviewDecision: row.review_decision || null,
|
|
81
|
+
contractPatch: parseJson(row.contract_patch, null),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mapEvent(row) {
|
|
86
|
+
if (!row) return null;
|
|
87
|
+
return {
|
|
88
|
+
id: row.id,
|
|
89
|
+
workItemId: row.work_item_id,
|
|
90
|
+
actionId: row.action_id || null,
|
|
91
|
+
runId: row.run_id || null,
|
|
92
|
+
type: row.type,
|
|
93
|
+
data: parseJson(row.data, {}),
|
|
94
|
+
createdAt: row.created_at,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function withTransaction(db, fn) {
|
|
99
|
+
db.exec('BEGIN IMMEDIATE');
|
|
100
|
+
try {
|
|
101
|
+
const result = fn();
|
|
102
|
+
db.exec('COMMIT');
|
|
103
|
+
return result;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasColumn(db, table, column) {
|
|
111
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class WorkItemStore {
|
|
115
|
+
constructor(dbPath, options = {}) {
|
|
116
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
117
|
+
this.now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
118
|
+
this.onTransitionStep = typeof options.onTransitionStep === 'function'
|
|
119
|
+
? options.onTransitionStep
|
|
120
|
+
: null;
|
|
121
|
+
this.db = new DatabaseSync(dbPath);
|
|
122
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
123
|
+
this.db.exec('PRAGMA synchronous = NORMAL;');
|
|
124
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
125
|
+
this.#initSchema();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#initSchema() {
|
|
129
|
+
this.db.exec(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
131
|
+
key TEXT PRIMARY KEY,
|
|
132
|
+
value TEXT NOT NULL
|
|
133
|
+
);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS work_items (
|
|
135
|
+
id TEXT PRIMARY KEY,
|
|
136
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
137
|
+
title TEXT NOT NULL,
|
|
138
|
+
goal TEXT NOT NULL,
|
|
139
|
+
acceptance_criteria TEXT NOT NULL,
|
|
140
|
+
workflow_template TEXT NOT NULL,
|
|
141
|
+
status TEXT NOT NULL,
|
|
142
|
+
current_action_id TEXT,
|
|
143
|
+
current_run_id TEXT,
|
|
144
|
+
work_dir TEXT NOT NULL DEFAULT '',
|
|
145
|
+
origin TEXT,
|
|
146
|
+
linked_session_ids TEXT NOT NULL DEFAULT '[]',
|
|
147
|
+
created_at INTEGER NOT NULL,
|
|
148
|
+
updated_at INTEGER NOT NULL
|
|
149
|
+
);
|
|
150
|
+
CREATE TABLE IF NOT EXISTS actions (
|
|
151
|
+
id TEXT PRIMARY KEY,
|
|
152
|
+
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
|
|
153
|
+
sequence INTEGER NOT NULL,
|
|
154
|
+
type TEXT NOT NULL,
|
|
155
|
+
required_role TEXT NOT NULL,
|
|
156
|
+
instruction TEXT NOT NULL,
|
|
157
|
+
context TEXT NOT NULL DEFAULT '[]',
|
|
158
|
+
contract_revision INTEGER NOT NULL DEFAULT 1,
|
|
159
|
+
status TEXT NOT NULL,
|
|
160
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
max_attempts INTEGER NOT NULL DEFAULT 2,
|
|
162
|
+
current_run_id TEXT,
|
|
163
|
+
lease_epoch INTEGER NOT NULL DEFAULT 0,
|
|
164
|
+
created_at INTEGER NOT NULL,
|
|
165
|
+
updated_at INTEGER NOT NULL,
|
|
166
|
+
UNIQUE(work_item_id, sequence)
|
|
167
|
+
);
|
|
168
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
169
|
+
id TEXT PRIMARY KEY,
|
|
170
|
+
action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
|
|
171
|
+
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
|
|
172
|
+
owner_boot_id TEXT NOT NULL,
|
|
173
|
+
lease_epoch INTEGER NOT NULL,
|
|
174
|
+
status TEXT NOT NULL,
|
|
175
|
+
started_at INTEGER NOT NULL,
|
|
176
|
+
expires_at INTEGER NOT NULL,
|
|
177
|
+
ended_at INTEGER,
|
|
178
|
+
role_snapshot TEXT,
|
|
179
|
+
vp_snapshot TEXT,
|
|
180
|
+
model_snapshot TEXT,
|
|
181
|
+
tool_policy_snapshot TEXT,
|
|
182
|
+
summary TEXT,
|
|
183
|
+
evidence TEXT NOT NULL DEFAULT '[]',
|
|
184
|
+
waiting_reason TEXT,
|
|
185
|
+
error TEXT,
|
|
186
|
+
review_decision TEXT,
|
|
187
|
+
contract_patch TEXT
|
|
188
|
+
);
|
|
189
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
190
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
191
|
+
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
|
|
192
|
+
action_id TEXT,
|
|
193
|
+
run_id TEXT,
|
|
194
|
+
type TEXT NOT NULL,
|
|
195
|
+
data TEXT NOT NULL,
|
|
196
|
+
created_at INTEGER NOT NULL
|
|
197
|
+
);
|
|
198
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_status_updated ON work_items(status, updated_at DESC);
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_actions_ready ON actions(status, updated_at, sequence);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, expires_at);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_events_work_item ON events(work_item_id, id);
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
// The feature shipped first as an unmerged PR, but keep the store tolerant
|
|
205
|
+
// of a v1 database created by a review build.
|
|
206
|
+
if (!hasColumn(this.db, 'actions', 'context')) {
|
|
207
|
+
this.db.exec("ALTER TABLE actions ADD COLUMN context TEXT NOT NULL DEFAULT '[]'");
|
|
208
|
+
}
|
|
209
|
+
if (!hasColumn(this.db, 'actions', 'contract_revision')) {
|
|
210
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN contract_revision INTEGER NOT NULL DEFAULT 1');
|
|
211
|
+
}
|
|
212
|
+
if (!hasColumn(this.db, 'runs', 'tool_policy_snapshot')) {
|
|
213
|
+
this.db.exec('ALTER TABLE runs ADD COLUMN tool_policy_snapshot TEXT');
|
|
214
|
+
}
|
|
215
|
+
if (!hasColumn(this.db, 'runs', 'review_decision')) {
|
|
216
|
+
this.db.exec('ALTER TABLE runs ADD COLUMN review_decision TEXT');
|
|
217
|
+
}
|
|
218
|
+
if (!hasColumn(this.db, 'runs', 'contract_patch')) {
|
|
219
|
+
this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
|
|
220
|
+
}
|
|
221
|
+
this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
|
|
222
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
close() {
|
|
226
|
+
this.db.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
appendEvent(workItemId, type, data = {}, refs = {}) {
|
|
230
|
+
const result = this.db.prepare(`INSERT INTO events
|
|
231
|
+
(work_item_id, action_id, run_id, type, data, created_at)
|
|
232
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
|
233
|
+
workItemId,
|
|
234
|
+
refs.actionId || null,
|
|
235
|
+
refs.runId || null,
|
|
236
|
+
type,
|
|
237
|
+
stringify(data),
|
|
238
|
+
this.now(),
|
|
239
|
+
);
|
|
240
|
+
return Number(result.lastInsertRowid);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
createWorkItem(input, firstAction) {
|
|
244
|
+
return withTransaction(this.db, () => {
|
|
245
|
+
const now = this.now();
|
|
246
|
+
const id = input.id || randomUUID();
|
|
247
|
+
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,
|
|
250
|
+
created_at, updated_at)
|
|
251
|
+
VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`).run(
|
|
252
|
+
id,
|
|
253
|
+
input.title,
|
|
254
|
+
input.goal,
|
|
255
|
+
stringify(input.acceptanceCriteria || []),
|
|
256
|
+
input.workflowTemplate || 'software-change',
|
|
257
|
+
firstAction ? 'ready' : 'draft',
|
|
258
|
+
input.workDir || '',
|
|
259
|
+
stringify(input.origin || null),
|
|
260
|
+
stringify(input.linkedSessionIds || []),
|
|
261
|
+
now,
|
|
262
|
+
now,
|
|
263
|
+
);
|
|
264
|
+
let action = null;
|
|
265
|
+
if (firstAction) {
|
|
266
|
+
action = this.#insertAction(id, { ...firstAction, contractRevision: 1 }, 1, now);
|
|
267
|
+
this.db.prepare('UPDATE work_items SET current_action_id = ? WHERE id = ?').run(action.id, id);
|
|
268
|
+
}
|
|
269
|
+
this.appendEvent(id, 'work_item.created', { status: firstAction ? 'ready' : 'draft' }, { actionId: action?.id });
|
|
270
|
+
return this.getWorkItem(id);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#insertAction(workItemId, input, sequence, now = this.now()) {
|
|
275
|
+
const action = {
|
|
276
|
+
id: input.id || randomUUID(),
|
|
277
|
+
workItemId,
|
|
278
|
+
sequence,
|
|
279
|
+
type: input.type,
|
|
280
|
+
requiredRole: input.requiredRole,
|
|
281
|
+
instruction: input.instruction || '',
|
|
282
|
+
context: Array.isArray(input.context) ? input.context : [],
|
|
283
|
+
contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
|
|
284
|
+
status: input.status || 'ready',
|
|
285
|
+
attempt: Number.isInteger(input.attempt) ? input.attempt : 0,
|
|
286
|
+
maxAttempts: Number.isInteger(input.maxAttempts) ? input.maxAttempts : 2,
|
|
287
|
+
currentRunId: null,
|
|
288
|
+
leaseEpoch: 0,
|
|
289
|
+
createdAt: now,
|
|
290
|
+
updatedAt: now,
|
|
291
|
+
};
|
|
292
|
+
this.db.prepare(`INSERT INTO actions
|
|
293
|
+
(id, work_item_id, sequence, type, required_role, instruction, context,
|
|
294
|
+
contract_revision, status, attempt, max_attempts, current_run_id, lease_epoch,
|
|
295
|
+
created_at, updated_at)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
|
|
297
|
+
action.id,
|
|
298
|
+
workItemId,
|
|
299
|
+
action.sequence,
|
|
300
|
+
action.type,
|
|
301
|
+
action.requiredRole,
|
|
302
|
+
action.instruction,
|
|
303
|
+
stringify(action.context),
|
|
304
|
+
action.contractRevision,
|
|
305
|
+
action.status,
|
|
306
|
+
action.attempt,
|
|
307
|
+
action.maxAttempts,
|
|
308
|
+
now,
|
|
309
|
+
now,
|
|
310
|
+
);
|
|
311
|
+
return action;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#nextSequence(workItemId) {
|
|
315
|
+
const row = this.db.prepare('SELECT COALESCE(MAX(sequence), 0) AS seq FROM actions WHERE work_item_id = ?').get(workItemId);
|
|
316
|
+
return Number(row.seq) + 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
createNextAction(workItemId, input) {
|
|
320
|
+
return this.#insertAction(workItemId, input, this.#nextSequence(workItemId));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getWorkItem(id) {
|
|
324
|
+
return mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
getAction(id) {
|
|
328
|
+
return mapAction(this.db.prepare('SELECT * FROM actions WHERE id = ?').get(id));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
getRun(id) {
|
|
332
|
+
return mapRun(this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
listWorkItems(filters = {}) {
|
|
336
|
+
const where = [];
|
|
337
|
+
const values = [];
|
|
338
|
+
if (typeof filters.status === 'string' && filters.status) {
|
|
339
|
+
where.push('status = ?');
|
|
340
|
+
values.push(filters.status);
|
|
341
|
+
}
|
|
342
|
+
if (typeof filters.sessionId === 'string' && filters.sessionId.trim()) {
|
|
343
|
+
const sessionId = filters.sessionId.trim();
|
|
344
|
+
where.push('(instr(origin, ?) > 0 OR instr(linked_session_ids, ?) > 0)');
|
|
345
|
+
values.push(`\"sessionId\":${JSON.stringify(sessionId)}`, JSON.stringify(sessionId));
|
|
346
|
+
}
|
|
347
|
+
if (typeof filters.search === 'string' && filters.search.trim()) {
|
|
348
|
+
where.push('(title LIKE ? OR goal LIKE ?)');
|
|
349
|
+
const query = `%${filters.search.trim()}%`;
|
|
350
|
+
values.push(query, query);
|
|
351
|
+
}
|
|
352
|
+
const limit = Math.min(Math.max(Number(filters.limit) || 100, 1), 500);
|
|
353
|
+
const sql = `SELECT * FROM work_items ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
354
|
+
ORDER BY updated_at DESC LIMIT ?`;
|
|
355
|
+
return this.db.prepare(sql).all(...values, limit).map(mapWorkItem);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
getWorkItemDetail(id) {
|
|
359
|
+
const workItem = this.getWorkItem(id);
|
|
360
|
+
if (!workItem) return null;
|
|
361
|
+
return {
|
|
362
|
+
...workItem,
|
|
363
|
+
actions: this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence').all(id).map(mapAction),
|
|
364
|
+
runs: this.db.prepare('SELECT * FROM runs WHERE work_item_id = ? ORDER BY started_at DESC').all(id).map(mapRun),
|
|
365
|
+
events: this.db.prepare('SELECT * FROM events WHERE work_item_id = ? ORDER BY id DESC LIMIT 500').all(id).map(mapEvent),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
|
|
370
|
+
if (workItem.currentRunId) {
|
|
371
|
+
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
|
|
372
|
+
WHERE id = ? AND status = 'running'`).run(runStatus, now, reason, workItem.currentRunId);
|
|
373
|
+
}
|
|
374
|
+
this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
375
|
+
WHERE work_item_id = ? AND status IN (${OPEN_ACTION_STATUSES})`).run(actionStatus, now, workItem.id);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
updateWorkItemAtomic(id, patch, makeInitialAction) {
|
|
379
|
+
return withTransaction(this.db, () => {
|
|
380
|
+
const current = this.getWorkItem(id);
|
|
381
|
+
if (!current) return null;
|
|
382
|
+
if (['done', 'cancelled'].includes(current.status)) {
|
|
383
|
+
throw new Error(`Cannot update WorkItem in ${current.status}`);
|
|
384
|
+
}
|
|
385
|
+
const next = {
|
|
386
|
+
title: patch.title ?? current.title,
|
|
387
|
+
goal: patch.goal ?? current.goal,
|
|
388
|
+
acceptanceCriteria: patch.acceptanceCriteria ?? current.acceptanceCriteria,
|
|
389
|
+
workDir: patch.workDir ?? current.workDir,
|
|
390
|
+
};
|
|
391
|
+
const contractChanged = next.goal !== current.goal
|
|
392
|
+
|| next.workDir !== current.workDir
|
|
393
|
+
|| JSON.stringify(next.acceptanceCriteria) !== JSON.stringify(current.acceptanceCriteria);
|
|
394
|
+
const now = this.now();
|
|
395
|
+
const revision = current.revision + (contractChanged ? 1 : 0);
|
|
396
|
+
if (contractChanged) {
|
|
397
|
+
this.#invalidateExecution(
|
|
398
|
+
current,
|
|
399
|
+
'superseded',
|
|
400
|
+
'superseded',
|
|
401
|
+
'WorkItem contract changed while this Run was active',
|
|
402
|
+
now,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
this.db.prepare(`UPDATE work_items SET title = ?, goal = ?, acceptance_criteria = ?,
|
|
406
|
+
work_dir = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
|
|
407
|
+
next.title,
|
|
408
|
+
next.goal,
|
|
409
|
+
stringify(next.acceptanceCriteria),
|
|
410
|
+
next.workDir,
|
|
411
|
+
revision,
|
|
412
|
+
now,
|
|
413
|
+
id,
|
|
414
|
+
);
|
|
415
|
+
let action = null;
|
|
416
|
+
if (contractChanged) {
|
|
417
|
+
const updated = this.getWorkItem(id);
|
|
418
|
+
action = this.#insertAction(id, {
|
|
419
|
+
...makeInitialAction(updated),
|
|
420
|
+
contractRevision: revision,
|
|
421
|
+
}, this.#nextSequence(id), now);
|
|
422
|
+
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
423
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
|
|
424
|
+
}
|
|
425
|
+
this.appendEvent(id, contractChanged ? 'workflow.retriaged' : 'work_item.updated', {
|
|
426
|
+
revision,
|
|
427
|
+
changedFields: Object.keys(patch || {}),
|
|
428
|
+
}, { actionId: action?.id });
|
|
429
|
+
return { workItem: this.getWorkItem(id), contractChanged };
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
cancelWorkItemAtomic(id) {
|
|
434
|
+
return withTransaction(this.db, () => {
|
|
435
|
+
const workItem = this.getWorkItem(id);
|
|
436
|
+
if (!workItem) return null;
|
|
437
|
+
if (workItem.status === 'done') throw new Error('Completed WorkItem cannot be cancelled');
|
|
438
|
+
if (workItem.status === 'cancelled') return workItem;
|
|
439
|
+
const now = this.now();
|
|
440
|
+
this.#invalidateExecution(
|
|
441
|
+
workItem,
|
|
442
|
+
'cancelled',
|
|
443
|
+
'cancelled',
|
|
444
|
+
'WorkItem was cancelled',
|
|
445
|
+
now,
|
|
446
|
+
);
|
|
447
|
+
this.db.prepare(`UPDATE work_items SET status = 'cancelled', current_action_id = NULL,
|
|
448
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
|
|
449
|
+
this.appendEvent(id, 'work_item.cancelled');
|
|
450
|
+
return this.getWorkItem(id);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
startWorkItemAtomic(id, makeInitialAction) {
|
|
455
|
+
return withTransaction(this.db, () => {
|
|
456
|
+
const workItem = this.getWorkItem(id);
|
|
457
|
+
if (!workItem) return null;
|
|
458
|
+
if (['done', 'cancelled'].includes(workItem.status)) {
|
|
459
|
+
throw new Error(`Cannot start WorkItem in ${workItem.status}`);
|
|
460
|
+
}
|
|
461
|
+
const current = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
|
|
462
|
+
if (current?.status === 'ready') return this.getWorkItemDetail(id);
|
|
463
|
+
if (workItem.status !== 'draft') {
|
|
464
|
+
throw new Error(`WorkItem in ${workItem.status} must be resumed with retry`);
|
|
465
|
+
}
|
|
466
|
+
const now = this.now();
|
|
467
|
+
const action = this.#insertAction(id, {
|
|
468
|
+
...makeInitialAction(workItem),
|
|
469
|
+
contractRevision: workItem.revision,
|
|
470
|
+
}, this.#nextSequence(id), now);
|
|
471
|
+
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
472
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
|
|
473
|
+
this.appendEvent(id, 'work_item.started', {}, { actionId: action.id });
|
|
474
|
+
return this.getWorkItemDetail(id);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
retryWorkItemAtomic(id, makeAction) {
|
|
479
|
+
return withTransaction(this.db, () => {
|
|
480
|
+
const workItem = this.getWorkItem(id);
|
|
481
|
+
if (!workItem) return null;
|
|
482
|
+
if (!['waiting', 'needs_attention'].includes(workItem.status)) {
|
|
483
|
+
throw new Error(`WorkItem in ${workItem.status} does not need retry`);
|
|
484
|
+
}
|
|
485
|
+
const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
|
|
486
|
+
const previousRun = previous
|
|
487
|
+
? mapRun(this.db.prepare(`SELECT * FROM runs WHERE work_item_id = ? AND action_id = ?
|
|
488
|
+
AND status != 'running' ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(id, previous.id))
|
|
489
|
+
: null;
|
|
490
|
+
const now = this.now();
|
|
491
|
+
const action = this.#insertAction(id, {
|
|
492
|
+
...makeAction(workItem, previous, previousRun),
|
|
493
|
+
contractRevision: workItem.revision,
|
|
494
|
+
}, this.#nextSequence(id), now);
|
|
495
|
+
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
496
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
|
|
497
|
+
this.appendEvent(id, 'work_item.retried', {}, { actionId: action.id });
|
|
498
|
+
return this.getWorkItemDetail(id);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
claimReadyAction(ownerBootId, leaseMs = 60_000) {
|
|
503
|
+
return withTransaction(this.db, () => {
|
|
504
|
+
const row = this.db.prepare(`SELECT a.* FROM actions a
|
|
505
|
+
JOIN work_items w ON w.id = a.work_item_id
|
|
506
|
+
WHERE a.status = 'ready' AND a.current_run_id IS NULL
|
|
507
|
+
AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL
|
|
508
|
+
ORDER BY a.updated_at ASC, a.sequence ASC LIMIT 1`).get();
|
|
509
|
+
if (!row) return null;
|
|
510
|
+
const now = this.now();
|
|
511
|
+
const runId = randomUUID();
|
|
512
|
+
const leaseEpoch = Number(row.lease_epoch) + 1;
|
|
513
|
+
const changedAction = this.db.prepare(`UPDATE actions SET status = 'running', attempt = attempt + 1,
|
|
514
|
+
current_run_id = ?, lease_epoch = ?, updated_at = ?
|
|
515
|
+
WHERE id = ? AND status = 'ready' AND current_run_id IS NULL`).run(
|
|
516
|
+
runId,
|
|
517
|
+
leaseEpoch,
|
|
518
|
+
now,
|
|
519
|
+
row.id,
|
|
520
|
+
);
|
|
521
|
+
if (Number(changedAction.changes) !== 1) return null;
|
|
522
|
+
const changedWorkItem = this.db.prepare(`UPDATE work_items SET status = 'running',
|
|
523
|
+
current_run_id = ?, updated_at = ? WHERE id = ? AND status = 'ready'
|
|
524
|
+
AND current_action_id = ? AND current_run_id IS NULL`).run(
|
|
525
|
+
runId,
|
|
526
|
+
now,
|
|
527
|
+
row.work_item_id,
|
|
528
|
+
row.id,
|
|
529
|
+
);
|
|
530
|
+
if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its current Action');
|
|
531
|
+
this.db.prepare(`INSERT INTO runs
|
|
532
|
+
(id, action_id, work_item_id, owner_boot_id, lease_epoch, status, started_at,
|
|
533
|
+
expires_at, evidence)
|
|
534
|
+
VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]')`).run(
|
|
535
|
+
runId,
|
|
536
|
+
row.id,
|
|
537
|
+
row.work_item_id,
|
|
538
|
+
ownerBootId,
|
|
539
|
+
leaseEpoch,
|
|
540
|
+
now,
|
|
541
|
+
now + leaseMs,
|
|
542
|
+
);
|
|
543
|
+
this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, { actionId: row.id, runId });
|
|
544
|
+
return {
|
|
545
|
+
workItem: this.getWorkItem(row.work_item_id),
|
|
546
|
+
action: this.getAction(row.id),
|
|
547
|
+
run: this.getRun(runId),
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#activeRunRow(runId, ownerBootId, leaseEpoch, requireUnexpired = true) {
|
|
553
|
+
return this.db.prepare(`SELECT r.* FROM runs r
|
|
554
|
+
JOIN actions a ON a.id = r.action_id
|
|
555
|
+
JOIN work_items w ON w.id = r.work_item_id
|
|
556
|
+
WHERE r.id = ? AND r.owner_boot_id = ? AND r.lease_epoch = ? AND r.status = 'running'
|
|
557
|
+
AND a.status = 'running' AND a.current_run_id = r.id AND a.lease_epoch = r.lease_epoch
|
|
558
|
+
AND w.status = 'running' AND w.current_action_id = a.id AND w.current_run_id = r.id
|
|
559
|
+
${requireUnexpired ? 'AND r.expires_at > ?' : ''}`).get(
|
|
560
|
+
runId,
|
|
561
|
+
ownerBootId,
|
|
562
|
+
leaseEpoch,
|
|
563
|
+
...(requireUnexpired ? [this.now()] : []),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
renewLease(runId, ownerBootId, leaseEpoch, leaseMs = 60_000) {
|
|
568
|
+
return withTransaction(this.db, () => {
|
|
569
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
570
|
+
if (!active) return false;
|
|
571
|
+
const result = this.db.prepare(`UPDATE runs SET expires_at = ?
|
|
572
|
+
WHERE id = ? AND status = 'running' AND expires_at > ?`).run(
|
|
573
|
+
this.now() + leaseMs,
|
|
574
|
+
runId,
|
|
575
|
+
this.now(),
|
|
576
|
+
);
|
|
577
|
+
return Number(result.changes) === 1;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
isActiveRun(runId, ownerBootId, leaseEpoch) {
|
|
582
|
+
return !!this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
interruptRun(runId, ownerBootId, leaseEpoch, reason = 'Work Center watcher stopped') {
|
|
586
|
+
return withTransaction(this.db, () => {
|
|
587
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, false);
|
|
588
|
+
if (!active) return false;
|
|
589
|
+
const action = this.getAction(active.action_id);
|
|
590
|
+
const now = this.now();
|
|
591
|
+
const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
|
|
592
|
+
const runChanged = this.db.prepare(`UPDATE runs SET status = 'interrupted', ended_at = ?, error = ?
|
|
593
|
+
WHERE id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'`).run(
|
|
594
|
+
now,
|
|
595
|
+
reason,
|
|
596
|
+
runId,
|
|
597
|
+
ownerBootId,
|
|
598
|
+
leaseEpoch,
|
|
599
|
+
);
|
|
600
|
+
if (Number(runChanged.changes) !== 1) return false;
|
|
601
|
+
const actionChanged = this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL,
|
|
602
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ?
|
|
603
|
+
AND lease_epoch = ?`).run(
|
|
604
|
+
retryable ? 'ready' : 'failed',
|
|
605
|
+
now,
|
|
606
|
+
action.id,
|
|
607
|
+
runId,
|
|
608
|
+
leaseEpoch,
|
|
609
|
+
);
|
|
610
|
+
if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
|
|
611
|
+
const itemChanged = this.db.prepare(`UPDATE work_items SET status = ?, current_run_id = NULL,
|
|
612
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_action_id = ?
|
|
613
|
+
AND current_run_id = ?`).run(
|
|
614
|
+
retryable ? 'ready' : 'needs_attention',
|
|
615
|
+
now,
|
|
616
|
+
active.work_item_id,
|
|
617
|
+
action.id,
|
|
618
|
+
runId,
|
|
619
|
+
);
|
|
620
|
+
if (Number(itemChanged.changes) !== 1) throw new Error('Run interruption lost the WorkItem fence');
|
|
621
|
+
this.appendEvent(active.work_item_id, 'run.interrupted', { retryable, reason }, {
|
|
622
|
+
actionId: action.id,
|
|
623
|
+
runId,
|
|
624
|
+
});
|
|
625
|
+
return true;
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
setRunExecutionSnapshots(runId, ownerBootId, leaseEpoch, snapshots) {
|
|
630
|
+
return withTransaction(this.db, () => {
|
|
631
|
+
if (!this.#activeRunRow(runId, ownerBootId, leaseEpoch, true)) return false;
|
|
632
|
+
const result = this.db.prepare(`UPDATE runs SET role_snapshot = ?, vp_snapshot = ?,
|
|
633
|
+
model_snapshot = ?, tool_policy_snapshot = ? WHERE id = ? AND status = 'running'
|
|
634
|
+
AND role_snapshot IS NULL AND vp_snapshot IS NULL AND model_snapshot IS NULL
|
|
635
|
+
AND tool_policy_snapshot IS NULL`).run(
|
|
636
|
+
stringify(snapshots.roleSnapshot || null),
|
|
637
|
+
stringify(snapshots.vpSnapshot || null),
|
|
638
|
+
stringify(snapshots.modelSnapshot || null),
|
|
639
|
+
stringify(snapshots.toolPolicySnapshot || null),
|
|
640
|
+
runId,
|
|
641
|
+
);
|
|
642
|
+
return Number(result.changes) === 1;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
finalizeRun(runId, ownerBootId, leaseEpoch, result, makeTransition) {
|
|
647
|
+
return withTransaction(this.db, () => {
|
|
648
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
649
|
+
if (!active) return null;
|
|
650
|
+
const action = this.getAction(active.action_id);
|
|
651
|
+
const workItem = this.getWorkItem(active.work_item_id);
|
|
652
|
+
const priorRuns = this.db.prepare(`SELECT * FROM runs
|
|
653
|
+
WHERE work_item_id = ? AND id != ? AND status != 'running'
|
|
654
|
+
ORDER BY started_at ASC`).all(workItem.id, runId).map(mapRun);
|
|
655
|
+
const transition = makeTransition({ run: mapRun(active), action, workItem, priorRuns });
|
|
656
|
+
if (!transition || !transition.actionStatus || !transition.workItemStatus) {
|
|
657
|
+
throw new Error('Work Center transition plan is incomplete');
|
|
658
|
+
}
|
|
659
|
+
const now = this.now();
|
|
660
|
+
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, summary = ?, evidence = ?,
|
|
661
|
+
waiting_reason = ?, error = ?, review_decision = ?, contract_patch = ?
|
|
662
|
+
WHERE id = ?`).run(
|
|
663
|
+
result.outcome,
|
|
664
|
+
now,
|
|
665
|
+
result.summary || '',
|
|
666
|
+
stringify(normalizeEvidence(result.evidence)),
|
|
667
|
+
result.waitingReason || null,
|
|
668
|
+
result.error || null,
|
|
669
|
+
result.reviewDecision || null,
|
|
670
|
+
stringify(result.contractPatch || null),
|
|
671
|
+
runId,
|
|
672
|
+
);
|
|
673
|
+
this.onTransitionStep?.('after_run_update');
|
|
674
|
+
|
|
675
|
+
const changedAction = this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
676
|
+
WHERE id = ? AND status = 'running' AND current_run_id = ?`).run(
|
|
677
|
+
transition.actionStatus,
|
|
678
|
+
now,
|
|
679
|
+
action.id,
|
|
680
|
+
runId,
|
|
681
|
+
);
|
|
682
|
+
if (Number(changedAction.changes) !== 1) {
|
|
683
|
+
throw new Error('Work Center terminal transition lost the current Action fence');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let nextWorkItem = workItem;
|
|
687
|
+
if (transition.contractPatch) {
|
|
688
|
+
const patch = transition.contractPatch;
|
|
689
|
+
const goal = patch.goal ?? workItem.goal;
|
|
690
|
+
const criteria = patch.acceptanceCriteria ?? workItem.acceptanceCriteria;
|
|
691
|
+
this.db.prepare(`UPDATE work_items SET goal = ?, acceptance_criteria = ?,
|
|
692
|
+
revision = revision + 1, updated_at = ? WHERE id = ?`).run(
|
|
693
|
+
goal,
|
|
694
|
+
stringify(criteria),
|
|
695
|
+
now,
|
|
696
|
+
workItem.id,
|
|
697
|
+
);
|
|
698
|
+
nextWorkItem = this.getWorkItem(workItem.id);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
let nextAction = null;
|
|
702
|
+
if (transition.nextAction) {
|
|
703
|
+
nextAction = this.#insertAction(workItem.id, {
|
|
704
|
+
...transition.nextAction,
|
|
705
|
+
contractRevision: nextWorkItem.revision,
|
|
706
|
+
}, this.#nextSequence(workItem.id), now);
|
|
707
|
+
}
|
|
708
|
+
const currentActionId = nextAction?.id
|
|
709
|
+
?? (transition.keepCurrentAction ? action.id : null);
|
|
710
|
+
const changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
711
|
+
current_run_id = NULL, updated_at = ? WHERE id = ? AND current_run_id = ?
|
|
712
|
+
AND current_action_id = ? AND status = 'running'`).run(
|
|
713
|
+
transition.workItemStatus,
|
|
714
|
+
currentActionId,
|
|
715
|
+
now,
|
|
716
|
+
workItem.id,
|
|
717
|
+
runId,
|
|
718
|
+
action.id,
|
|
719
|
+
);
|
|
720
|
+
if (Number(changedWorkItem.changes) !== 1) {
|
|
721
|
+
throw new Error('Work Center terminal transition lost the current WorkItem fence');
|
|
722
|
+
}
|
|
723
|
+
this.onTransitionStep?.('before_event');
|
|
724
|
+
this.appendEvent(workItem.id, transition.eventType, transition.eventData || {}, {
|
|
725
|
+
actionId: action.id,
|
|
726
|
+
runId,
|
|
727
|
+
});
|
|
728
|
+
return this.getWorkItemDetail(workItem.id);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
recoverInterruptedRuns(ownerBootId) {
|
|
733
|
+
return withTransaction(this.db, () => {
|
|
734
|
+
const now = this.now();
|
|
735
|
+
const rows = this.db.prepare(`SELECT * FROM runs
|
|
736
|
+
WHERE status = 'running' AND (owner_boot_id != ? OR expires_at <= ?)`).all(ownerBootId, now);
|
|
737
|
+
for (const row of rows) {
|
|
738
|
+
const action = this.getAction(row.action_id);
|
|
739
|
+
const workItem = this.getWorkItem(row.work_item_id);
|
|
740
|
+
const isCurrent = action?.status === 'running'
|
|
741
|
+
&& action.currentRunId === row.id
|
|
742
|
+
&& action.leaseEpoch === row.lease_epoch
|
|
743
|
+
&& workItem?.status === 'running'
|
|
744
|
+
&& workItem.currentActionId === action.id
|
|
745
|
+
&& workItem.currentRunId === row.id;
|
|
746
|
+
if (!isCurrent) {
|
|
747
|
+
const staleStatus = workItem?.status === 'cancelled' ? 'cancelled' : 'superseded';
|
|
748
|
+
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
|
|
749
|
+
WHERE id = ? AND status = 'running'`).run(
|
|
750
|
+
staleStatus,
|
|
751
|
+
now,
|
|
752
|
+
'Run no longer owns the current WorkItem Action',
|
|
753
|
+
row.id,
|
|
754
|
+
);
|
|
755
|
+
this.appendEvent(row.work_item_id, 'run.stale_recovered', { status: staleStatus }, {
|
|
756
|
+
actionId: row.action_id,
|
|
757
|
+
runId: row.id,
|
|
758
|
+
});
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
this.db.prepare(`UPDATE runs SET status = 'interrupted', ended_at = ?,
|
|
763
|
+
error = 'Agent process or lease ended before the Run submitted a terminal outcome'
|
|
764
|
+
WHERE id = ?`).run(now, row.id);
|
|
765
|
+
const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
|
|
766
|
+
this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
767
|
+
WHERE id = ?`).run(retryable ? 'ready' : 'failed', now, action.id);
|
|
768
|
+
this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
769
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
|
|
770
|
+
retryable ? 'ready' : 'needs_attention',
|
|
771
|
+
action.id,
|
|
772
|
+
now,
|
|
773
|
+
workItem.id,
|
|
774
|
+
);
|
|
775
|
+
this.appendEvent(workItem.id, 'run.interrupted', { retryable }, {
|
|
776
|
+
actionId: action.id,
|
|
777
|
+
runId: row.id,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return rows.length;
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|