pm-orchestrator-runner 1.0.14 → 1.0.15
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/README.md +7 -7
- package/dist/cli/index.js +13 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/config/namespace.d.ts +38 -11
- package/dist/config/namespace.d.ts.map +1 -1
- package/dist/config/namespace.js +163 -26
- package/dist/config/namespace.js.map +1 -1
- package/dist/queue/index.d.ts +7 -32
- package/dist/queue/index.d.ts.map +1 -1
- package/dist/queue/index.js +9 -28
- package/dist/queue/index.js.map +1 -1
- package/dist/queue/queue-poller.d.ts +20 -1
- package/dist/queue/queue-poller.d.ts.map +1 -1
- package/dist/queue/queue-poller.js +40 -1
- package/dist/queue/queue-poller.js.map +1 -1
- package/dist/queue/queue-store.d.ts +110 -51
- package/dist/queue/queue-store.d.ts.map +1 -1
- package/dist/queue/queue-store.js +341 -120
- package/dist/queue/queue-store.js.map +1 -1
- package/dist/web/index.d.ts +8 -2
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +8 -2
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/index.html +81 -1
- package/dist/web/server.d.ts +13 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +101 -33
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Queue Store - DynamoDB Local implementation
|
|
3
|
+
* Queue Store - DynamoDB Local implementation (v2)
|
|
4
4
|
* Per spec/20_QUEUE_STORE.md
|
|
5
5
|
*
|
|
6
|
+
* v2 Changes:
|
|
7
|
+
* - Single fixed table: pm-runner-queue
|
|
8
|
+
* - Composite key: PK=namespace, SK=task_id
|
|
9
|
+
* - GSI: status-index (status + created_at)
|
|
10
|
+
* - Namespace-based separation in single table
|
|
11
|
+
*
|
|
6
12
|
* Provides queue operations with:
|
|
7
13
|
* - Atomic QUEUED -> RUNNING transitions (conditional update)
|
|
8
14
|
* - Double execution prevention
|
|
9
15
|
* - Fail-closed error handling
|
|
10
16
|
*/
|
|
11
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = void 0;
|
|
18
|
+
exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = exports.RUNNERS_TABLE_NAME = exports.QUEUE_TABLE_NAME = void 0;
|
|
13
19
|
exports.isValidStatusTransition = isValidStatusTransition;
|
|
14
20
|
const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
|
|
15
21
|
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
16
22
|
const uuid_1 = require("uuid");
|
|
23
|
+
/**
|
|
24
|
+
* Fixed table name (v2: single table for all namespaces)
|
|
25
|
+
*/
|
|
26
|
+
exports.QUEUE_TABLE_NAME = 'pm-runner-queue';
|
|
27
|
+
/**
|
|
28
|
+
* Runners table name (v2: heartbeat tracking)
|
|
29
|
+
*/
|
|
30
|
+
exports.RUNNERS_TABLE_NAME = 'pm-runner-runners';
|
|
17
31
|
/**
|
|
18
32
|
* Valid status transitions
|
|
19
33
|
* Per spec/20_QUEUE_STORE.md
|
|
@@ -32,17 +46,18 @@ function isValidStatusTransition(fromStatus, toStatus) {
|
|
|
32
46
|
return exports.VALID_STATUS_TRANSITIONS[fromStatus].includes(toStatus);
|
|
33
47
|
}
|
|
34
48
|
/**
|
|
35
|
-
* Queue Store
|
|
49
|
+
* Queue Store (v2)
|
|
36
50
|
* Manages task queue with DynamoDB Local
|
|
51
|
+
* Single table design with namespace-based separation
|
|
37
52
|
*/
|
|
38
53
|
class QueueStore {
|
|
39
54
|
client;
|
|
40
55
|
docClient;
|
|
41
|
-
|
|
56
|
+
namespace;
|
|
42
57
|
endpoint;
|
|
43
|
-
constructor(config
|
|
58
|
+
constructor(config) {
|
|
44
59
|
this.endpoint = config.endpoint || 'http://localhost:8000';
|
|
45
|
-
this.
|
|
60
|
+
this.namespace = config.namespace;
|
|
46
61
|
const region = config.region || 'local';
|
|
47
62
|
this.client = new client_dynamodb_1.DynamoDBClient({
|
|
48
63
|
endpoint: this.endpoint,
|
|
@@ -59,10 +74,16 @@ class QueueStore {
|
|
|
59
74
|
});
|
|
60
75
|
}
|
|
61
76
|
/**
|
|
62
|
-
* Get table name
|
|
77
|
+
* Get fixed table name (v2: always returns pm-runner-queue)
|
|
63
78
|
*/
|
|
64
79
|
getTableName() {
|
|
65
|
-
return
|
|
80
|
+
return exports.QUEUE_TABLE_NAME;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get namespace
|
|
84
|
+
*/
|
|
85
|
+
getNamespace() {
|
|
86
|
+
return this.namespace;
|
|
66
87
|
}
|
|
67
88
|
/**
|
|
68
89
|
* Get endpoint
|
|
@@ -75,7 +96,7 @@ class QueueStore {
|
|
|
75
96
|
*/
|
|
76
97
|
async tableExists() {
|
|
77
98
|
try {
|
|
78
|
-
await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName:
|
|
99
|
+
await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: exports.QUEUE_TABLE_NAME }));
|
|
79
100
|
return true;
|
|
80
101
|
}
|
|
81
102
|
catch (error) {
|
|
@@ -86,33 +107,39 @@ class QueueStore {
|
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
/**
|
|
89
|
-
*
|
|
90
|
-
|
|
110
|
+
* Delete queue table (for testing)
|
|
111
|
+
*/
|
|
112
|
+
async deleteTable() {
|
|
113
|
+
try {
|
|
114
|
+
await this.client.send(new client_dynamodb_1.DeleteTableCommand({ TableName: exports.QUEUE_TABLE_NAME }));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof client_dynamodb_1.ResourceNotFoundException) {
|
|
118
|
+
// Table doesn't exist, ignore
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create queue table with composite key (v2)
|
|
126
|
+
* PK: namespace, SK: task_id
|
|
91
127
|
*/
|
|
92
128
|
async createTable() {
|
|
93
129
|
const command = new client_dynamodb_1.CreateTableCommand({
|
|
94
|
-
TableName:
|
|
95
|
-
KeySchema: [
|
|
130
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
131
|
+
KeySchema: [
|
|
132
|
+
{ AttributeName: 'namespace', KeyType: 'HASH' },
|
|
133
|
+
{ AttributeName: 'task_id', KeyType: 'RANGE' },
|
|
134
|
+
],
|
|
96
135
|
AttributeDefinitions: [
|
|
136
|
+
{ AttributeName: 'namespace', AttributeType: 'S' },
|
|
97
137
|
{ AttributeName: 'task_id', AttributeType: 'S' },
|
|
98
|
-
{ AttributeName: 'session_id', AttributeType: 'S' },
|
|
99
138
|
{ AttributeName: 'status', AttributeType: 'S' },
|
|
100
139
|
{ AttributeName: 'created_at', AttributeType: 'S' },
|
|
101
140
|
{ AttributeName: 'task_group_id', AttributeType: 'S' },
|
|
102
141
|
],
|
|
103
142
|
GlobalSecondaryIndexes: [
|
|
104
|
-
{
|
|
105
|
-
IndexName: 'session-index',
|
|
106
|
-
KeySchema: [
|
|
107
|
-
{ AttributeName: 'session_id', KeyType: 'HASH' },
|
|
108
|
-
{ AttributeName: 'created_at', KeyType: 'RANGE' },
|
|
109
|
-
],
|
|
110
|
-
Projection: { ProjectionType: 'ALL' },
|
|
111
|
-
ProvisionedThroughput: {
|
|
112
|
-
ReadCapacityUnits: 5,
|
|
113
|
-
WriteCapacityUnits: 5,
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
143
|
{
|
|
117
144
|
IndexName: 'status-index',
|
|
118
145
|
KeySchema: [
|
|
@@ -146,24 +173,67 @@ class QueueStore {
|
|
|
146
173
|
await this.client.send(command);
|
|
147
174
|
}
|
|
148
175
|
/**
|
|
149
|
-
*
|
|
176
|
+
* Create runners table (v2)
|
|
177
|
+
* PK: namespace, SK: runner_id
|
|
178
|
+
*/
|
|
179
|
+
async createRunnersTable() {
|
|
180
|
+
const command = new client_dynamodb_1.CreateTableCommand({
|
|
181
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
182
|
+
KeySchema: [
|
|
183
|
+
{ AttributeName: 'namespace', KeyType: 'HASH' },
|
|
184
|
+
{ AttributeName: 'runner_id', KeyType: 'RANGE' },
|
|
185
|
+
],
|
|
186
|
+
AttributeDefinitions: [
|
|
187
|
+
{ AttributeName: 'namespace', AttributeType: 'S' },
|
|
188
|
+
{ AttributeName: 'runner_id', AttributeType: 'S' },
|
|
189
|
+
],
|
|
190
|
+
ProvisionedThroughput: {
|
|
191
|
+
ReadCapacityUnits: 5,
|
|
192
|
+
WriteCapacityUnits: 5,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
await this.client.send(command);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Check if runners table exists
|
|
199
|
+
*/
|
|
200
|
+
async runnersTableExists() {
|
|
201
|
+
try {
|
|
202
|
+
await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: exports.RUNNERS_TABLE_NAME }));
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof client_dynamodb_1.ResourceNotFoundException) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Ensure both tables exist
|
|
150
214
|
*/
|
|
151
215
|
async ensureTable() {
|
|
152
|
-
|
|
153
|
-
|
|
216
|
+
// Queue table
|
|
217
|
+
const queueExists = await this.tableExists();
|
|
218
|
+
if (!queueExists) {
|
|
154
219
|
await this.createTable();
|
|
155
|
-
|
|
156
|
-
|
|
220
|
+
await this.waitForTableActive(exports.QUEUE_TABLE_NAME);
|
|
221
|
+
}
|
|
222
|
+
// Runners table
|
|
223
|
+
const runnersExists = await this.runnersTableExists();
|
|
224
|
+
if (!runnersExists) {
|
|
225
|
+
await this.createRunnersTable();
|
|
226
|
+
await this.waitForTableActive(exports.RUNNERS_TABLE_NAME);
|
|
157
227
|
}
|
|
158
228
|
}
|
|
159
229
|
/**
|
|
160
230
|
* Wait for table to become active
|
|
161
231
|
*/
|
|
162
|
-
async waitForTableActive(maxWaitMs = 30000) {
|
|
232
|
+
async waitForTableActive(tableName, maxWaitMs = 30000) {
|
|
163
233
|
const startTime = Date.now();
|
|
164
234
|
while (Date.now() - startTime < maxWaitMs) {
|
|
165
235
|
try {
|
|
166
|
-
const result = await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName:
|
|
236
|
+
const result = await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: tableName }));
|
|
167
237
|
if (result.Table?.TableStatus === 'ACTIVE') {
|
|
168
238
|
return;
|
|
169
239
|
}
|
|
@@ -173,21 +243,16 @@ class QueueStore {
|
|
|
173
243
|
}
|
|
174
244
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
175
245
|
}
|
|
176
|
-
throw new Error(`Table
|
|
246
|
+
throw new Error(`Table \${tableName} did not become active within \${maxWaitMs}ms`);
|
|
177
247
|
}
|
|
178
248
|
/**
|
|
179
249
|
* Enqueue a new task
|
|
180
250
|
* Creates item with status=QUEUED
|
|
181
|
-
*
|
|
182
|
-
* @param sessionId - Session ID
|
|
183
|
-
* @param taskGroupId - Task Group ID
|
|
184
|
-
* @param prompt - User prompt
|
|
185
|
-
* @param taskId - Optional task ID (generates if not provided)
|
|
186
|
-
* @returns Created queue item
|
|
187
251
|
*/
|
|
188
252
|
async enqueue(sessionId, taskGroupId, prompt, taskId) {
|
|
189
253
|
const now = new Date().toISOString();
|
|
190
254
|
const item = {
|
|
255
|
+
namespace: this.namespace,
|
|
191
256
|
task_id: taskId || (0, uuid_1.v4)(),
|
|
192
257
|
task_group_id: taskGroupId,
|
|
193
258
|
session_id: sessionId,
|
|
@@ -197,52 +262,57 @@ class QueueStore {
|
|
|
197
262
|
updated_at: now,
|
|
198
263
|
};
|
|
199
264
|
await this.docClient.send(new lib_dynamodb_1.PutCommand({
|
|
200
|
-
TableName:
|
|
265
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
201
266
|
Item: item,
|
|
202
267
|
}));
|
|
203
268
|
return item;
|
|
204
269
|
}
|
|
205
270
|
/**
|
|
206
|
-
* Get item by task_id
|
|
271
|
+
* Get item by task_id (v2: uses composite key)
|
|
207
272
|
*/
|
|
208
|
-
async getItem(taskId) {
|
|
273
|
+
async getItem(taskId, targetNamespace) {
|
|
274
|
+
const ns = targetNamespace ?? this.namespace;
|
|
209
275
|
const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
|
|
210
|
-
TableName:
|
|
211
|
-
Key: {
|
|
276
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
277
|
+
Key: {
|
|
278
|
+
namespace: ns,
|
|
279
|
+
task_id: taskId
|
|
280
|
+
},
|
|
212
281
|
}));
|
|
213
282
|
return result.Item || null;
|
|
214
283
|
}
|
|
215
284
|
/**
|
|
216
|
-
* Claim the oldest QUEUED task
|
|
217
|
-
* Per spec: Uses conditional update for double execution prevention
|
|
218
|
-
*
|
|
219
|
-
* @returns ClaimResult with success flag and item if claimed
|
|
285
|
+
* Claim the oldest QUEUED task for this namespace
|
|
220
286
|
*/
|
|
221
287
|
async claim() {
|
|
222
|
-
// Query for oldest QUEUED item using status-index
|
|
223
288
|
const queryResult = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
224
|
-
TableName:
|
|
289
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
225
290
|
IndexName: 'status-index',
|
|
226
291
|
KeyConditionExpression: '#status = :queued',
|
|
292
|
+
FilterExpression: '#namespace = :namespace',
|
|
227
293
|
ExpressionAttributeNames: {
|
|
228
294
|
'#status': 'status',
|
|
295
|
+
'#namespace': 'namespace',
|
|
229
296
|
},
|
|
230
297
|
ExpressionAttributeValues: {
|
|
231
298
|
':queued': 'QUEUED',
|
|
299
|
+
':namespace': this.namespace,
|
|
232
300
|
},
|
|
233
|
-
Limit:
|
|
234
|
-
ScanIndexForward: true,
|
|
301
|
+
Limit: 10,
|
|
302
|
+
ScanIndexForward: true,
|
|
235
303
|
}));
|
|
236
304
|
if (!queryResult.Items || queryResult.Items.length === 0) {
|
|
237
305
|
return { success: false };
|
|
238
306
|
}
|
|
239
307
|
const item = queryResult.Items[0];
|
|
240
|
-
// Atomic update: QUEUED -> RUNNING with conditional check
|
|
241
308
|
try {
|
|
242
309
|
const now = new Date().toISOString();
|
|
243
310
|
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
244
|
-
TableName:
|
|
245
|
-
Key: {
|
|
311
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
312
|
+
Key: {
|
|
313
|
+
namespace: this.namespace,
|
|
314
|
+
task_id: item.task_id
|
|
315
|
+
},
|
|
246
316
|
UpdateExpression: 'SET #status = :running, updated_at = :now',
|
|
247
317
|
ConditionExpression: '#status = :queued',
|
|
248
318
|
ExpressionAttributeNames: {
|
|
@@ -254,30 +324,22 @@ class QueueStore {
|
|
|
254
324
|
':now': now,
|
|
255
325
|
},
|
|
256
326
|
}));
|
|
257
|
-
// Update local item with new status
|
|
258
327
|
item.status = 'RUNNING';
|
|
259
328
|
item.updated_at = now;
|
|
260
329
|
return { success: true, item };
|
|
261
330
|
}
|
|
262
331
|
catch (error) {
|
|
263
|
-
// ConditionalCheckFailed means another process claimed it
|
|
264
332
|
if (error &&
|
|
265
333
|
typeof error === 'object' &&
|
|
266
334
|
'name' in error &&
|
|
267
335
|
error.name === 'ConditionalCheckFailedException') {
|
|
268
336
|
return { success: false, error: 'Task already claimed by another process' };
|
|
269
337
|
}
|
|
270
|
-
// Fail-closed: re-throw unexpected errors
|
|
271
338
|
throw error;
|
|
272
339
|
}
|
|
273
340
|
}
|
|
274
341
|
/**
|
|
275
342
|
* Update task status
|
|
276
|
-
* Per spec: RUNNING -> COMPLETE or RUNNING -> ERROR
|
|
277
|
-
*
|
|
278
|
-
* @param taskId - Task ID
|
|
279
|
-
* @param status - New status
|
|
280
|
-
* @param errorMessage - Optional error message (for ERROR status)
|
|
281
343
|
*/
|
|
282
344
|
async updateStatus(taskId, status, errorMessage) {
|
|
283
345
|
const now = new Date().toISOString();
|
|
@@ -292,8 +354,11 @@ class QueueStore {
|
|
|
292
354
|
expressionAttributeValues[':error'] = errorMessage;
|
|
293
355
|
}
|
|
294
356
|
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
295
|
-
TableName:
|
|
296
|
-
Key: {
|
|
357
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
358
|
+
Key: {
|
|
359
|
+
namespace: this.namespace,
|
|
360
|
+
task_id: taskId
|
|
361
|
+
},
|
|
297
362
|
UpdateExpression: updateExpression,
|
|
298
363
|
ExpressionAttributeNames: {
|
|
299
364
|
'#status': 'status',
|
|
@@ -303,35 +368,27 @@ class QueueStore {
|
|
|
303
368
|
}
|
|
304
369
|
/**
|
|
305
370
|
* Update task status with validation
|
|
306
|
-
* Per spec/19_WEB_UI.md: PATCH /api/tasks/:task_id/status
|
|
307
|
-
*
|
|
308
|
-
* @param taskId - Task ID
|
|
309
|
-
* @param newStatus - Target status
|
|
310
|
-
* @returns StatusUpdateResult with success/error info
|
|
311
371
|
*/
|
|
312
372
|
async updateStatusWithValidation(taskId, newStatus) {
|
|
313
|
-
// First, get current task
|
|
314
373
|
const task = await this.getItem(taskId);
|
|
315
374
|
if (!task) {
|
|
316
375
|
return {
|
|
317
376
|
success: false,
|
|
318
377
|
task_id: taskId,
|
|
319
378
|
error: 'Task not found',
|
|
320
|
-
message: `Task not found:
|
|
379
|
+
message: `Task not found: \${taskId}`,
|
|
321
380
|
};
|
|
322
381
|
}
|
|
323
382
|
const oldStatus = task.status;
|
|
324
|
-
// Check if transition is valid
|
|
325
383
|
if (!isValidStatusTransition(oldStatus, newStatus)) {
|
|
326
384
|
return {
|
|
327
385
|
success: false,
|
|
328
386
|
task_id: taskId,
|
|
329
387
|
old_status: oldStatus,
|
|
330
388
|
error: 'Invalid status transition',
|
|
331
|
-
message: `Cannot transition from
|
|
389
|
+
message: `Cannot transition from \${oldStatus} to \${newStatus}`,
|
|
332
390
|
};
|
|
333
391
|
}
|
|
334
|
-
// Update status
|
|
335
392
|
await this.updateStatus(taskId, newStatus);
|
|
336
393
|
return {
|
|
337
394
|
success: true,
|
|
@@ -341,70 +398,70 @@ class QueueStore {
|
|
|
341
398
|
};
|
|
342
399
|
}
|
|
343
400
|
/**
|
|
344
|
-
* Get items by
|
|
345
|
-
* Uses session-index GSI
|
|
346
|
-
*/
|
|
347
|
-
async getBySession(sessionId) {
|
|
348
|
-
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
349
|
-
TableName: this.tableName,
|
|
350
|
-
IndexName: 'session-index',
|
|
351
|
-
KeyConditionExpression: 'session_id = :sid',
|
|
352
|
-
ExpressionAttributeValues: {
|
|
353
|
-
':sid': sessionId,
|
|
354
|
-
},
|
|
355
|
-
ScanIndexForward: true, // Ascending by created_at
|
|
356
|
-
}));
|
|
357
|
-
return result.Items || [];
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Get items by status
|
|
361
|
-
* Uses status-index GSI
|
|
401
|
+
* Get items by status for this namespace
|
|
362
402
|
*/
|
|
363
403
|
async getByStatus(status) {
|
|
364
404
|
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
365
|
-
TableName:
|
|
405
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
366
406
|
IndexName: 'status-index',
|
|
367
407
|
KeyConditionExpression: '#status = :status',
|
|
408
|
+
FilterExpression: '#namespace = :namespace',
|
|
368
409
|
ExpressionAttributeNames: {
|
|
369
410
|
'#status': 'status',
|
|
411
|
+
'#namespace': 'namespace',
|
|
370
412
|
},
|
|
371
413
|
ExpressionAttributeValues: {
|
|
372
414
|
':status': status,
|
|
415
|
+
':namespace': this.namespace,
|
|
373
416
|
},
|
|
374
|
-
ScanIndexForward: true,
|
|
417
|
+
ScanIndexForward: true,
|
|
375
418
|
}));
|
|
376
419
|
return result.Items || [];
|
|
377
420
|
}
|
|
378
421
|
/**
|
|
379
|
-
* Get items by task group ID
|
|
380
|
-
* Uses task-group-index GSI
|
|
381
|
-
* Per spec/19_WEB_UI.md: for listing tasks in a task group
|
|
422
|
+
* Get items by task group ID for this namespace
|
|
382
423
|
*/
|
|
383
|
-
async getByTaskGroup(taskGroupId) {
|
|
424
|
+
async getByTaskGroup(taskGroupId, targetNamespace) {
|
|
425
|
+
const ns = targetNamespace ?? this.namespace;
|
|
384
426
|
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
385
|
-
TableName:
|
|
427
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
386
428
|
IndexName: 'task-group-index',
|
|
387
429
|
KeyConditionExpression: 'task_group_id = :tgid',
|
|
430
|
+
FilterExpression: '#namespace = :namespace',
|
|
431
|
+
ExpressionAttributeNames: {
|
|
432
|
+
'#namespace': 'namespace',
|
|
433
|
+
},
|
|
388
434
|
ExpressionAttributeValues: {
|
|
389
435
|
':tgid': taskGroupId,
|
|
436
|
+
':namespace': ns,
|
|
390
437
|
},
|
|
391
|
-
ScanIndexForward: true,
|
|
438
|
+
ScanIndexForward: true,
|
|
392
439
|
}));
|
|
393
440
|
return result.Items || [];
|
|
394
441
|
}
|
|
395
442
|
/**
|
|
396
|
-
* Get all
|
|
397
|
-
* Per spec/19_WEB_UI.md: for task group list view
|
|
398
|
-
* Note: Uses Scan - consider pagination for large datasets
|
|
443
|
+
* Get all items in a namespace
|
|
399
444
|
*/
|
|
400
|
-
async
|
|
401
|
-
|
|
402
|
-
const result = await this.docClient.send(new lib_dynamodb_1.
|
|
403
|
-
TableName:
|
|
404
|
-
|
|
445
|
+
async getAllItems(targetNamespace) {
|
|
446
|
+
const ns = targetNamespace ?? this.namespace;
|
|
447
|
+
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
448
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
449
|
+
KeyConditionExpression: '#namespace = :namespace',
|
|
450
|
+
ExpressionAttributeNames: {
|
|
451
|
+
'#namespace': 'namespace',
|
|
452
|
+
},
|
|
453
|
+
ExpressionAttributeValues: {
|
|
454
|
+
':namespace': ns,
|
|
455
|
+
},
|
|
456
|
+
ScanIndexForward: true,
|
|
405
457
|
}));
|
|
406
|
-
|
|
407
|
-
|
|
458
|
+
return result.Items || [];
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Get all distinct task groups for a namespace with summary
|
|
462
|
+
*/
|
|
463
|
+
async getAllTaskGroups(targetNamespace) {
|
|
464
|
+
const items = await this.getAllItems(targetNamespace);
|
|
408
465
|
const groupMap = new Map();
|
|
409
466
|
for (const item of items) {
|
|
410
467
|
const existing = groupMap.get(item.task_group_id);
|
|
@@ -425,7 +482,6 @@ class QueueStore {
|
|
|
425
482
|
});
|
|
426
483
|
}
|
|
427
484
|
}
|
|
428
|
-
// Convert to array and sort by created_at (oldest first)
|
|
429
485
|
const groups = [];
|
|
430
486
|
for (const [taskGroupId, data] of groupMap) {
|
|
431
487
|
groups.push({
|
|
@@ -438,21 +494,73 @@ class QueueStore {
|
|
|
438
494
|
groups.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
439
495
|
return groups;
|
|
440
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* Get all distinct namespaces (scan entire table)
|
|
499
|
+
*/
|
|
500
|
+
async getAllNamespaces() {
|
|
501
|
+
const queueResult = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
|
|
502
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
503
|
+
ProjectionExpression: '#namespace',
|
|
504
|
+
ExpressionAttributeNames: {
|
|
505
|
+
'#namespace': 'namespace',
|
|
506
|
+
},
|
|
507
|
+
}));
|
|
508
|
+
let runnersResult = { Items: [] };
|
|
509
|
+
try {
|
|
510
|
+
runnersResult = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
|
|
511
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Runners table might not exist yet
|
|
516
|
+
}
|
|
517
|
+
const queueItems = (queueResult.Items || []);
|
|
518
|
+
const runners = (runnersResult.Items || []);
|
|
519
|
+
const taskCounts = new Map();
|
|
520
|
+
for (const item of queueItems) {
|
|
521
|
+
const count = taskCounts.get(item.namespace) || 0;
|
|
522
|
+
taskCounts.set(item.namespace, count + 1);
|
|
523
|
+
}
|
|
524
|
+
const runnerCounts = new Map();
|
|
525
|
+
const now = Date.now();
|
|
526
|
+
const HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
527
|
+
for (const runner of runners) {
|
|
528
|
+
const counts = runnerCounts.get(runner.namespace) || { total: 0, active: 0 };
|
|
529
|
+
counts.total++;
|
|
530
|
+
const lastHeartbeat = new Date(runner.last_heartbeat).getTime();
|
|
531
|
+
if (now - lastHeartbeat < HEARTBEAT_TIMEOUT_MS) {
|
|
532
|
+
counts.active++;
|
|
533
|
+
}
|
|
534
|
+
runnerCounts.set(runner.namespace, counts);
|
|
535
|
+
}
|
|
536
|
+
const allNamespaces = new Set([...taskCounts.keys(), ...runnerCounts.keys()]);
|
|
537
|
+
const summaries = [];
|
|
538
|
+
for (const ns of allNamespaces) {
|
|
539
|
+
const runnerInfo = runnerCounts.get(ns) || { total: 0, active: 0 };
|
|
540
|
+
summaries.push({
|
|
541
|
+
namespace: ns,
|
|
542
|
+
task_count: taskCounts.get(ns) || 0,
|
|
543
|
+
runner_count: runnerInfo.total,
|
|
544
|
+
active_runner_count: runnerInfo.active,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
summaries.sort((a, b) => a.namespace.localeCompare(b.namespace));
|
|
548
|
+
return summaries;
|
|
549
|
+
}
|
|
441
550
|
/**
|
|
442
551
|
* Delete item (for testing)
|
|
443
552
|
*/
|
|
444
553
|
async deleteItem(taskId) {
|
|
445
554
|
await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
|
|
446
|
-
TableName:
|
|
447
|
-
Key: {
|
|
555
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
556
|
+
Key: {
|
|
557
|
+
namespace: this.namespace,
|
|
558
|
+
task_id: taskId
|
|
559
|
+
},
|
|
448
560
|
}));
|
|
449
561
|
}
|
|
450
562
|
/**
|
|
451
563
|
* Mark stale RUNNING tasks as ERROR
|
|
452
|
-
* Per spec: fail-closed - don't leave tasks in "limbo"
|
|
453
|
-
*
|
|
454
|
-
* @param maxAgeMs - Max age in milliseconds for RUNNING tasks
|
|
455
|
-
* @returns Number of tasks marked as ERROR
|
|
456
564
|
*/
|
|
457
565
|
async recoverStaleTasks(maxAgeMs = 5 * 60 * 1000) {
|
|
458
566
|
const runningTasks = await this.getByStatus('RUNNING');
|
|
@@ -461,12 +569,125 @@ class QueueStore {
|
|
|
461
569
|
for (const task of runningTasks) {
|
|
462
570
|
const taskAge = now - new Date(task.updated_at).getTime();
|
|
463
571
|
if (taskAge > maxAgeMs) {
|
|
464
|
-
await this.updateStatus(task.task_id, 'ERROR', `Task stale: running for
|
|
572
|
+
await this.updateStatus(task.task_id, 'ERROR', `Task stale: running for \${Math.round(taskAge / 1000)}s without completion`);
|
|
465
573
|
recovered++;
|
|
466
574
|
}
|
|
467
575
|
}
|
|
468
576
|
return recovered;
|
|
469
577
|
}
|
|
578
|
+
// ===============================
|
|
579
|
+
// Runner Heartbeat Methods (v2)
|
|
580
|
+
// ===============================
|
|
581
|
+
/**
|
|
582
|
+
* Register or update runner heartbeat
|
|
583
|
+
*/
|
|
584
|
+
async updateRunnerHeartbeat(runnerId, projectRoot) {
|
|
585
|
+
const now = new Date().toISOString();
|
|
586
|
+
const existing = await this.getRunner(runnerId);
|
|
587
|
+
if (existing) {
|
|
588
|
+
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
589
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
590
|
+
Key: {
|
|
591
|
+
namespace: this.namespace,
|
|
592
|
+
runner_id: runnerId,
|
|
593
|
+
},
|
|
594
|
+
UpdateExpression: 'SET last_heartbeat = :now, #status = :status',
|
|
595
|
+
ExpressionAttributeNames: {
|
|
596
|
+
'#status': 'status',
|
|
597
|
+
},
|
|
598
|
+
ExpressionAttributeValues: {
|
|
599
|
+
':now': now,
|
|
600
|
+
':status': 'RUNNING',
|
|
601
|
+
},
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
const record = {
|
|
606
|
+
namespace: this.namespace,
|
|
607
|
+
runner_id: runnerId,
|
|
608
|
+
last_heartbeat: now,
|
|
609
|
+
started_at: now,
|
|
610
|
+
status: 'RUNNING',
|
|
611
|
+
project_root: projectRoot,
|
|
612
|
+
};
|
|
613
|
+
await this.docClient.send(new lib_dynamodb_1.PutCommand({
|
|
614
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
615
|
+
Item: record,
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Get runner by ID
|
|
621
|
+
*/
|
|
622
|
+
async getRunner(runnerId) {
|
|
623
|
+
const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
|
|
624
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
625
|
+
Key: {
|
|
626
|
+
namespace: this.namespace,
|
|
627
|
+
runner_id: runnerId,
|
|
628
|
+
},
|
|
629
|
+
}));
|
|
630
|
+
return result.Item || null;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get all runners for this namespace
|
|
634
|
+
*/
|
|
635
|
+
async getAllRunners(targetNamespace) {
|
|
636
|
+
const ns = targetNamespace ?? this.namespace;
|
|
637
|
+
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
638
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
639
|
+
KeyConditionExpression: '#namespace = :namespace',
|
|
640
|
+
ExpressionAttributeNames: {
|
|
641
|
+
'#namespace': 'namespace',
|
|
642
|
+
},
|
|
643
|
+
ExpressionAttributeValues: {
|
|
644
|
+
':namespace': ns,
|
|
645
|
+
},
|
|
646
|
+
}));
|
|
647
|
+
return result.Items || [];
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Get runners with their alive status
|
|
651
|
+
*/
|
|
652
|
+
async getRunnersWithStatus(heartbeatTimeoutMs = 2 * 60 * 1000, targetNamespace) {
|
|
653
|
+
const runners = await this.getAllRunners(targetNamespace);
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
return runners.map(runner => ({
|
|
656
|
+
...runner,
|
|
657
|
+
isAlive: now - new Date(runner.last_heartbeat).getTime() < heartbeatTimeoutMs,
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Mark runner as stopped
|
|
662
|
+
*/
|
|
663
|
+
async markRunnerStopped(runnerId) {
|
|
664
|
+
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
665
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
666
|
+
Key: {
|
|
667
|
+
namespace: this.namespace,
|
|
668
|
+
runner_id: runnerId,
|
|
669
|
+
},
|
|
670
|
+
UpdateExpression: 'SET #status = :status',
|
|
671
|
+
ExpressionAttributeNames: {
|
|
672
|
+
'#status': 'status',
|
|
673
|
+
},
|
|
674
|
+
ExpressionAttributeValues: {
|
|
675
|
+
':status': 'STOPPED',
|
|
676
|
+
},
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Delete runner record
|
|
681
|
+
*/
|
|
682
|
+
async deleteRunner(runnerId) {
|
|
683
|
+
await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
|
|
684
|
+
TableName: exports.RUNNERS_TABLE_NAME,
|
|
685
|
+
Key: {
|
|
686
|
+
namespace: this.namespace,
|
|
687
|
+
runner_id: runnerId,
|
|
688
|
+
},
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
470
691
|
/**
|
|
471
692
|
* Close the client connection
|
|
472
693
|
*/
|