pm-orchestrator-runner 1.0.13 → 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 +11 -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 +136 -47
- package/dist/queue/queue-store.d.ts.map +1 -1
- package/dist/queue/queue-store.js +385 -107
- 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 +155 -27
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,30 +1,63 @@
|
|
|
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 = void 0;
|
|
18
|
+
exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = exports.RUNNERS_TABLE_NAME = exports.QUEUE_TABLE_NAME = void 0;
|
|
19
|
+
exports.isValidStatusTransition = isValidStatusTransition;
|
|
13
20
|
const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
|
|
14
21
|
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
15
22
|
const uuid_1 = require("uuid");
|
|
16
23
|
/**
|
|
17
|
-
*
|
|
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';
|
|
31
|
+
/**
|
|
32
|
+
* Valid status transitions
|
|
33
|
+
* Per spec/20_QUEUE_STORE.md
|
|
34
|
+
*/
|
|
35
|
+
exports.VALID_STATUS_TRANSITIONS = {
|
|
36
|
+
QUEUED: ['RUNNING', 'CANCELLED'],
|
|
37
|
+
RUNNING: ['COMPLETE', 'ERROR', 'CANCELLED'],
|
|
38
|
+
COMPLETE: [], // Terminal state
|
|
39
|
+
ERROR: [], // Terminal state
|
|
40
|
+
CANCELLED: [], // Terminal state
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Check if a status transition is valid
|
|
44
|
+
*/
|
|
45
|
+
function isValidStatusTransition(fromStatus, toStatus) {
|
|
46
|
+
return exports.VALID_STATUS_TRANSITIONS[fromStatus].includes(toStatus);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Queue Store (v2)
|
|
18
50
|
* Manages task queue with DynamoDB Local
|
|
51
|
+
* Single table design with namespace-based separation
|
|
19
52
|
*/
|
|
20
53
|
class QueueStore {
|
|
21
54
|
client;
|
|
22
55
|
docClient;
|
|
23
|
-
|
|
56
|
+
namespace;
|
|
24
57
|
endpoint;
|
|
25
|
-
constructor(config
|
|
58
|
+
constructor(config) {
|
|
26
59
|
this.endpoint = config.endpoint || 'http://localhost:8000';
|
|
27
|
-
this.
|
|
60
|
+
this.namespace = config.namespace;
|
|
28
61
|
const region = config.region || 'local';
|
|
29
62
|
this.client = new client_dynamodb_1.DynamoDBClient({
|
|
30
63
|
endpoint: this.endpoint,
|
|
@@ -41,10 +74,16 @@ class QueueStore {
|
|
|
41
74
|
});
|
|
42
75
|
}
|
|
43
76
|
/**
|
|
44
|
-
* Get table name
|
|
77
|
+
* Get fixed table name (v2: always returns pm-runner-queue)
|
|
45
78
|
*/
|
|
46
79
|
getTableName() {
|
|
47
|
-
return
|
|
80
|
+
return exports.QUEUE_TABLE_NAME;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get namespace
|
|
84
|
+
*/
|
|
85
|
+
getNamespace() {
|
|
86
|
+
return this.namespace;
|
|
48
87
|
}
|
|
49
88
|
/**
|
|
50
89
|
* Get endpoint
|
|
@@ -57,7 +96,7 @@ class QueueStore {
|
|
|
57
96
|
*/
|
|
58
97
|
async tableExists() {
|
|
59
98
|
try {
|
|
60
|
-
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 }));
|
|
61
100
|
return true;
|
|
62
101
|
}
|
|
63
102
|
catch (error) {
|
|
@@ -68,33 +107,39 @@ class QueueStore {
|
|
|
68
107
|
}
|
|
69
108
|
}
|
|
70
109
|
/**
|
|
71
|
-
*
|
|
72
|
-
|
|
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
|
|
73
127
|
*/
|
|
74
128
|
async createTable() {
|
|
75
129
|
const command = new client_dynamodb_1.CreateTableCommand({
|
|
76
|
-
TableName:
|
|
77
|
-
KeySchema: [
|
|
130
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
131
|
+
KeySchema: [
|
|
132
|
+
{ AttributeName: 'namespace', KeyType: 'HASH' },
|
|
133
|
+
{ AttributeName: 'task_id', KeyType: 'RANGE' },
|
|
134
|
+
],
|
|
78
135
|
AttributeDefinitions: [
|
|
136
|
+
{ AttributeName: 'namespace', AttributeType: 'S' },
|
|
79
137
|
{ AttributeName: 'task_id', AttributeType: 'S' },
|
|
80
|
-
{ AttributeName: 'session_id', AttributeType: 'S' },
|
|
81
138
|
{ AttributeName: 'status', AttributeType: 'S' },
|
|
82
139
|
{ AttributeName: 'created_at', AttributeType: 'S' },
|
|
83
140
|
{ AttributeName: 'task_group_id', AttributeType: 'S' },
|
|
84
141
|
],
|
|
85
142
|
GlobalSecondaryIndexes: [
|
|
86
|
-
{
|
|
87
|
-
IndexName: 'session-index',
|
|
88
|
-
KeySchema: [
|
|
89
|
-
{ AttributeName: 'session_id', KeyType: 'HASH' },
|
|
90
|
-
{ AttributeName: 'created_at', KeyType: 'RANGE' },
|
|
91
|
-
],
|
|
92
|
-
Projection: { ProjectionType: 'ALL' },
|
|
93
|
-
ProvisionedThroughput: {
|
|
94
|
-
ReadCapacityUnits: 5,
|
|
95
|
-
WriteCapacityUnits: 5,
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
143
|
{
|
|
99
144
|
IndexName: 'status-index',
|
|
100
145
|
KeySchema: [
|
|
@@ -128,24 +173,67 @@ class QueueStore {
|
|
|
128
173
|
await this.client.send(command);
|
|
129
174
|
}
|
|
130
175
|
/**
|
|
131
|
-
*
|
|
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
|
|
132
214
|
*/
|
|
133
215
|
async ensureTable() {
|
|
134
|
-
|
|
135
|
-
|
|
216
|
+
// Queue table
|
|
217
|
+
const queueExists = await this.tableExists();
|
|
218
|
+
if (!queueExists) {
|
|
136
219
|
await this.createTable();
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
227
|
}
|
|
140
228
|
}
|
|
141
229
|
/**
|
|
142
230
|
* Wait for table to become active
|
|
143
231
|
*/
|
|
144
|
-
async waitForTableActive(maxWaitMs = 30000) {
|
|
232
|
+
async waitForTableActive(tableName, maxWaitMs = 30000) {
|
|
145
233
|
const startTime = Date.now();
|
|
146
234
|
while (Date.now() - startTime < maxWaitMs) {
|
|
147
235
|
try {
|
|
148
|
-
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 }));
|
|
149
237
|
if (result.Table?.TableStatus === 'ACTIVE') {
|
|
150
238
|
return;
|
|
151
239
|
}
|
|
@@ -155,21 +243,16 @@ class QueueStore {
|
|
|
155
243
|
}
|
|
156
244
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
157
245
|
}
|
|
158
|
-
throw new Error(`Table
|
|
246
|
+
throw new Error(`Table \${tableName} did not become active within \${maxWaitMs}ms`);
|
|
159
247
|
}
|
|
160
248
|
/**
|
|
161
249
|
* Enqueue a new task
|
|
162
250
|
* Creates item with status=QUEUED
|
|
163
|
-
*
|
|
164
|
-
* @param sessionId - Session ID
|
|
165
|
-
* @param taskGroupId - Task Group ID
|
|
166
|
-
* @param prompt - User prompt
|
|
167
|
-
* @param taskId - Optional task ID (generates if not provided)
|
|
168
|
-
* @returns Created queue item
|
|
169
251
|
*/
|
|
170
252
|
async enqueue(sessionId, taskGroupId, prompt, taskId) {
|
|
171
253
|
const now = new Date().toISOString();
|
|
172
254
|
const item = {
|
|
255
|
+
namespace: this.namespace,
|
|
173
256
|
task_id: taskId || (0, uuid_1.v4)(),
|
|
174
257
|
task_group_id: taskGroupId,
|
|
175
258
|
session_id: sessionId,
|
|
@@ -179,52 +262,57 @@ class QueueStore {
|
|
|
179
262
|
updated_at: now,
|
|
180
263
|
};
|
|
181
264
|
await this.docClient.send(new lib_dynamodb_1.PutCommand({
|
|
182
|
-
TableName:
|
|
265
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
183
266
|
Item: item,
|
|
184
267
|
}));
|
|
185
268
|
return item;
|
|
186
269
|
}
|
|
187
270
|
/**
|
|
188
|
-
* Get item by task_id
|
|
271
|
+
* Get item by task_id (v2: uses composite key)
|
|
189
272
|
*/
|
|
190
|
-
async getItem(taskId) {
|
|
273
|
+
async getItem(taskId, targetNamespace) {
|
|
274
|
+
const ns = targetNamespace ?? this.namespace;
|
|
191
275
|
const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
|
|
192
|
-
TableName:
|
|
193
|
-
Key: {
|
|
276
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
277
|
+
Key: {
|
|
278
|
+
namespace: ns,
|
|
279
|
+
task_id: taskId
|
|
280
|
+
},
|
|
194
281
|
}));
|
|
195
282
|
return result.Item || null;
|
|
196
283
|
}
|
|
197
284
|
/**
|
|
198
|
-
* Claim the oldest QUEUED task
|
|
199
|
-
* Per spec: Uses conditional update for double execution prevention
|
|
200
|
-
*
|
|
201
|
-
* @returns ClaimResult with success flag and item if claimed
|
|
285
|
+
* Claim the oldest QUEUED task for this namespace
|
|
202
286
|
*/
|
|
203
287
|
async claim() {
|
|
204
|
-
// Query for oldest QUEUED item using status-index
|
|
205
288
|
const queryResult = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
206
|
-
TableName:
|
|
289
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
207
290
|
IndexName: 'status-index',
|
|
208
291
|
KeyConditionExpression: '#status = :queued',
|
|
292
|
+
FilterExpression: '#namespace = :namespace',
|
|
209
293
|
ExpressionAttributeNames: {
|
|
210
294
|
'#status': 'status',
|
|
295
|
+
'#namespace': 'namespace',
|
|
211
296
|
},
|
|
212
297
|
ExpressionAttributeValues: {
|
|
213
298
|
':queued': 'QUEUED',
|
|
299
|
+
':namespace': this.namespace,
|
|
214
300
|
},
|
|
215
|
-
Limit:
|
|
216
|
-
ScanIndexForward: true,
|
|
301
|
+
Limit: 10,
|
|
302
|
+
ScanIndexForward: true,
|
|
217
303
|
}));
|
|
218
304
|
if (!queryResult.Items || queryResult.Items.length === 0) {
|
|
219
305
|
return { success: false };
|
|
220
306
|
}
|
|
221
307
|
const item = queryResult.Items[0];
|
|
222
|
-
// Atomic update: QUEUED -> RUNNING with conditional check
|
|
223
308
|
try {
|
|
224
309
|
const now = new Date().toISOString();
|
|
225
310
|
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
226
|
-
TableName:
|
|
227
|
-
Key: {
|
|
311
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
312
|
+
Key: {
|
|
313
|
+
namespace: this.namespace,
|
|
314
|
+
task_id: item.task_id
|
|
315
|
+
},
|
|
228
316
|
UpdateExpression: 'SET #status = :running, updated_at = :now',
|
|
229
317
|
ConditionExpression: '#status = :queued',
|
|
230
318
|
ExpressionAttributeNames: {
|
|
@@ -236,30 +324,22 @@ class QueueStore {
|
|
|
236
324
|
':now': now,
|
|
237
325
|
},
|
|
238
326
|
}));
|
|
239
|
-
// Update local item with new status
|
|
240
327
|
item.status = 'RUNNING';
|
|
241
328
|
item.updated_at = now;
|
|
242
329
|
return { success: true, item };
|
|
243
330
|
}
|
|
244
331
|
catch (error) {
|
|
245
|
-
// ConditionalCheckFailed means another process claimed it
|
|
246
332
|
if (error &&
|
|
247
333
|
typeof error === 'object' &&
|
|
248
334
|
'name' in error &&
|
|
249
335
|
error.name === 'ConditionalCheckFailedException') {
|
|
250
336
|
return { success: false, error: 'Task already claimed by another process' };
|
|
251
337
|
}
|
|
252
|
-
// Fail-closed: re-throw unexpected errors
|
|
253
338
|
throw error;
|
|
254
339
|
}
|
|
255
340
|
}
|
|
256
341
|
/**
|
|
257
342
|
* Update task status
|
|
258
|
-
* Per spec: RUNNING -> COMPLETE or RUNNING -> ERROR
|
|
259
|
-
*
|
|
260
|
-
* @param taskId - Task ID
|
|
261
|
-
* @param status - New status
|
|
262
|
-
* @param errorMessage - Optional error message (for ERROR status)
|
|
263
343
|
*/
|
|
264
344
|
async updateStatus(taskId, status, errorMessage) {
|
|
265
345
|
const now = new Date().toISOString();
|
|
@@ -274,8 +354,11 @@ class QueueStore {
|
|
|
274
354
|
expressionAttributeValues[':error'] = errorMessage;
|
|
275
355
|
}
|
|
276
356
|
await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
|
|
277
|
-
TableName:
|
|
278
|
-
Key: {
|
|
357
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
358
|
+
Key: {
|
|
359
|
+
namespace: this.namespace,
|
|
360
|
+
task_id: taskId
|
|
361
|
+
},
|
|
279
362
|
UpdateExpression: updateExpression,
|
|
280
363
|
ExpressionAttributeNames: {
|
|
281
364
|
'#status': 'status',
|
|
@@ -284,70 +367,101 @@ class QueueStore {
|
|
|
284
367
|
}));
|
|
285
368
|
}
|
|
286
369
|
/**
|
|
287
|
-
*
|
|
288
|
-
* Uses session-index GSI
|
|
370
|
+
* Update task status with validation
|
|
289
371
|
*/
|
|
290
|
-
async
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
372
|
+
async updateStatusWithValidation(taskId, newStatus) {
|
|
373
|
+
const task = await this.getItem(taskId);
|
|
374
|
+
if (!task) {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
task_id: taskId,
|
|
378
|
+
error: 'Task not found',
|
|
379
|
+
message: `Task not found: \${taskId}`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const oldStatus = task.status;
|
|
383
|
+
if (!isValidStatusTransition(oldStatus, newStatus)) {
|
|
384
|
+
return {
|
|
385
|
+
success: false,
|
|
386
|
+
task_id: taskId,
|
|
387
|
+
old_status: oldStatus,
|
|
388
|
+
error: 'Invalid status transition',
|
|
389
|
+
message: `Cannot transition from \${oldStatus} to \${newStatus}`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
await this.updateStatus(taskId, newStatus);
|
|
393
|
+
return {
|
|
394
|
+
success: true,
|
|
395
|
+
task_id: taskId,
|
|
396
|
+
old_status: oldStatus,
|
|
397
|
+
new_status: newStatus,
|
|
398
|
+
};
|
|
301
399
|
}
|
|
302
400
|
/**
|
|
303
|
-
* Get items by status
|
|
304
|
-
* Uses status-index GSI
|
|
401
|
+
* Get items by status for this namespace
|
|
305
402
|
*/
|
|
306
403
|
async getByStatus(status) {
|
|
307
404
|
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
308
|
-
TableName:
|
|
405
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
309
406
|
IndexName: 'status-index',
|
|
310
407
|
KeyConditionExpression: '#status = :status',
|
|
408
|
+
FilterExpression: '#namespace = :namespace',
|
|
311
409
|
ExpressionAttributeNames: {
|
|
312
410
|
'#status': 'status',
|
|
411
|
+
'#namespace': 'namespace',
|
|
313
412
|
},
|
|
314
413
|
ExpressionAttributeValues: {
|
|
315
414
|
':status': status,
|
|
415
|
+
':namespace': this.namespace,
|
|
316
416
|
},
|
|
317
|
-
ScanIndexForward: true,
|
|
417
|
+
ScanIndexForward: true,
|
|
318
418
|
}));
|
|
319
419
|
return result.Items || [];
|
|
320
420
|
}
|
|
321
421
|
/**
|
|
322
|
-
* Get items by task group ID
|
|
323
|
-
* Uses task-group-index GSI
|
|
324
|
-
* Per spec/19_WEB_UI.md: for listing tasks in a task group
|
|
422
|
+
* Get items by task group ID for this namespace
|
|
325
423
|
*/
|
|
326
|
-
async getByTaskGroup(taskGroupId) {
|
|
424
|
+
async getByTaskGroup(taskGroupId, targetNamespace) {
|
|
425
|
+
const ns = targetNamespace ?? this.namespace;
|
|
327
426
|
const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
|
|
328
|
-
TableName:
|
|
427
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
329
428
|
IndexName: 'task-group-index',
|
|
330
429
|
KeyConditionExpression: 'task_group_id = :tgid',
|
|
430
|
+
FilterExpression: '#namespace = :namespace',
|
|
431
|
+
ExpressionAttributeNames: {
|
|
432
|
+
'#namespace': 'namespace',
|
|
433
|
+
},
|
|
331
434
|
ExpressionAttributeValues: {
|
|
332
435
|
':tgid': taskGroupId,
|
|
436
|
+
':namespace': ns,
|
|
333
437
|
},
|
|
334
|
-
ScanIndexForward: true,
|
|
438
|
+
ScanIndexForward: true,
|
|
335
439
|
}));
|
|
336
440
|
return result.Items || [];
|
|
337
441
|
}
|
|
338
442
|
/**
|
|
339
|
-
* Get all
|
|
340
|
-
* Per spec/19_WEB_UI.md: for task group list view
|
|
341
|
-
* Note: Uses Scan - consider pagination for large datasets
|
|
443
|
+
* Get all items in a namespace
|
|
342
444
|
*/
|
|
343
|
-
async
|
|
344
|
-
|
|
345
|
-
const result = await this.docClient.send(new lib_dynamodb_1.
|
|
346
|
-
TableName:
|
|
347
|
-
|
|
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,
|
|
348
457
|
}));
|
|
349
|
-
|
|
350
|
-
|
|
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);
|
|
351
465
|
const groupMap = new Map();
|
|
352
466
|
for (const item of items) {
|
|
353
467
|
const existing = groupMap.get(item.task_group_id);
|
|
@@ -368,7 +482,6 @@ class QueueStore {
|
|
|
368
482
|
});
|
|
369
483
|
}
|
|
370
484
|
}
|
|
371
|
-
// Convert to array and sort by created_at (oldest first)
|
|
372
485
|
const groups = [];
|
|
373
486
|
for (const [taskGroupId, data] of groupMap) {
|
|
374
487
|
groups.push({
|
|
@@ -381,21 +494,73 @@ class QueueStore {
|
|
|
381
494
|
groups.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
382
495
|
return groups;
|
|
383
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
|
+
}
|
|
384
550
|
/**
|
|
385
551
|
* Delete item (for testing)
|
|
386
552
|
*/
|
|
387
553
|
async deleteItem(taskId) {
|
|
388
554
|
await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
|
|
389
|
-
TableName:
|
|
390
|
-
Key: {
|
|
555
|
+
TableName: exports.QUEUE_TABLE_NAME,
|
|
556
|
+
Key: {
|
|
557
|
+
namespace: this.namespace,
|
|
558
|
+
task_id: taskId
|
|
559
|
+
},
|
|
391
560
|
}));
|
|
392
561
|
}
|
|
393
562
|
/**
|
|
394
563
|
* Mark stale RUNNING tasks as ERROR
|
|
395
|
-
* Per spec: fail-closed - don't leave tasks in "limbo"
|
|
396
|
-
*
|
|
397
|
-
* @param maxAgeMs - Max age in milliseconds for RUNNING tasks
|
|
398
|
-
* @returns Number of tasks marked as ERROR
|
|
399
564
|
*/
|
|
400
565
|
async recoverStaleTasks(maxAgeMs = 5 * 60 * 1000) {
|
|
401
566
|
const runningTasks = await this.getByStatus('RUNNING');
|
|
@@ -404,12 +569,125 @@ class QueueStore {
|
|
|
404
569
|
for (const task of runningTasks) {
|
|
405
570
|
const taskAge = now - new Date(task.updated_at).getTime();
|
|
406
571
|
if (taskAge > maxAgeMs) {
|
|
407
|
-
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`);
|
|
408
573
|
recovered++;
|
|
409
574
|
}
|
|
410
575
|
}
|
|
411
576
|
return recovered;
|
|
412
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
|
+
}
|
|
413
691
|
/**
|
|
414
692
|
* Close the client connection
|
|
415
693
|
*/
|