@exaudeus/workrail 3.43.0 → 3.45.0
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/dist/console-ui/assets/{index-Sb57DW4B.js → index-BpanIvmi.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/daemon/workflow-runner.d.ts +16 -2
- package/dist/daemon/workflow-runner.js +98 -0
- package/dist/manifest.json +41 -25
- package/dist/trigger/adapters/github-queue-poller.d.ts +34 -0
- package/dist/trigger/adapters/github-queue-poller.js +200 -0
- package/dist/trigger/github-queue-config.d.ts +18 -0
- package/dist/trigger/github-queue-config.js +155 -0
- package/dist/trigger/notification-service.d.ts +1 -1
- package/dist/trigger/notification-service.js +4 -0
- package/dist/trigger/polling-scheduler.d.ts +1 -0
- package/dist/trigger/polling-scheduler.js +185 -6
- package/dist/trigger/trigger-router.js +8 -0
- package/dist/trigger/trigger-store.js +35 -2
- package/dist/trigger/types.d.ts +18 -0
- package/dist/v2/usecases/console-routes.js +3 -0
- package/docs/design/design-candidates-stuck-escalation.md +183 -0
- package/docs/design/design-review-findings-stuck-escalation.md +93 -0
- package/docs/design/implementation-plan-stuck-escalation.md +172 -0
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.WORKRAIL_CONFIG_PATH = void 0;
|
|
37
|
+
exports.loadQueueConfig = loadQueueConfig;
|
|
38
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const os = __importStar(require("node:os"));
|
|
41
|
+
const result_js_1 = require("../runtime/result.js");
|
|
42
|
+
exports.WORKRAIL_CONFIG_PATH = path.join(os.homedir(), '.workrail', 'config.json');
|
|
43
|
+
async function loadQueueConfig(configPath = exports.WORKRAIL_CONFIG_PATH, env = process.env) {
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = await fs.readFile(configPath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
const error = e;
|
|
50
|
+
if (error.code === 'ENOENT') {
|
|
51
|
+
return (0, result_js_1.ok)(null);
|
|
52
|
+
}
|
|
53
|
+
return (0, result_js_1.err)(`Failed to read config file at ${configPath}: ${error.message ?? String(e)}`);
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return (0, result_js_1.err)(`Failed to parse config JSON at ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
63
|
+
return (0, result_js_1.err)(`Config at ${configPath} is not a JSON object`);
|
|
64
|
+
}
|
|
65
|
+
const config = parsed;
|
|
66
|
+
if (!('queue' in config)) {
|
|
67
|
+
return (0, result_js_1.ok)(null);
|
|
68
|
+
}
|
|
69
|
+
const queue = config['queue'];
|
|
70
|
+
if (typeof queue !== 'object' || queue === null) {
|
|
71
|
+
return (0, result_js_1.err)('config.queue is not an object');
|
|
72
|
+
}
|
|
73
|
+
const q = queue;
|
|
74
|
+
const rawType = q['type'];
|
|
75
|
+
if (typeof rawType !== 'string') {
|
|
76
|
+
return (0, result_js_1.err)('config.queue.type is required and must be a string');
|
|
77
|
+
}
|
|
78
|
+
const VALID_TYPES = new Set(['assignee', 'label', 'mention', 'query']);
|
|
79
|
+
if (!VALID_TYPES.has(rawType)) {
|
|
80
|
+
return (0, result_js_1.err)(`config.queue.type must be one of: assignee, label, mention, query. Got: "${rawType}"`);
|
|
81
|
+
}
|
|
82
|
+
const rawRepo = q['repo'];
|
|
83
|
+
if (typeof rawRepo !== 'string' || !rawRepo.trim()) {
|
|
84
|
+
return (0, result_js_1.err)('config.queue.repo is required and must be a non-empty string');
|
|
85
|
+
}
|
|
86
|
+
const rawToken = q['token'];
|
|
87
|
+
if (typeof rawToken !== 'string' || !rawToken.trim()) {
|
|
88
|
+
return (0, result_js_1.err)('config.queue.token is required and must be a non-empty string');
|
|
89
|
+
}
|
|
90
|
+
let resolvedToken;
|
|
91
|
+
if (rawToken.startsWith('$')) {
|
|
92
|
+
const envVarName = rawToken.slice(1);
|
|
93
|
+
const envValue = env[envVarName];
|
|
94
|
+
if (!envValue) {
|
|
95
|
+
return (0, result_js_1.err)(`config.queue.token references env var $${envVarName} which is unset or empty`);
|
|
96
|
+
}
|
|
97
|
+
resolvedToken = envValue;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
resolvedToken = rawToken.trim();
|
|
101
|
+
}
|
|
102
|
+
const rawPollInterval = q['pollIntervalSeconds'];
|
|
103
|
+
let pollIntervalSeconds = 300;
|
|
104
|
+
if (rawPollInterval !== undefined) {
|
|
105
|
+
if (typeof rawPollInterval !== 'number' || !Number.isInteger(rawPollInterval) || rawPollInterval <= 0) {
|
|
106
|
+
return (0, result_js_1.err)('config.queue.pollIntervalSeconds must be a positive integer');
|
|
107
|
+
}
|
|
108
|
+
pollIntervalSeconds = rawPollInterval;
|
|
109
|
+
}
|
|
110
|
+
const rawMaxConcurrent = q['maxTotalConcurrentSessions'] ?? q['maxConcurrentSelf'];
|
|
111
|
+
let maxTotalConcurrentSessions = 1;
|
|
112
|
+
if (rawMaxConcurrent !== undefined) {
|
|
113
|
+
if (typeof rawMaxConcurrent !== 'number' || !Number.isInteger(rawMaxConcurrent) || rawMaxConcurrent <= 0) {
|
|
114
|
+
return (0, result_js_1.err)('config.queue.maxTotalConcurrentSessions must be a positive integer');
|
|
115
|
+
}
|
|
116
|
+
maxTotalConcurrentSessions = rawMaxConcurrent;
|
|
117
|
+
}
|
|
118
|
+
const rawExcludeLabels = q['excludeLabels'];
|
|
119
|
+
let excludeLabels = [];
|
|
120
|
+
if (rawExcludeLabels !== undefined) {
|
|
121
|
+
if (!Array.isArray(rawExcludeLabels) || !rawExcludeLabels.every(l => typeof l === 'string')) {
|
|
122
|
+
return (0, result_js_1.err)('config.queue.excludeLabels must be an array of strings');
|
|
123
|
+
}
|
|
124
|
+
excludeLabels = rawExcludeLabels;
|
|
125
|
+
}
|
|
126
|
+
const rawUser = q['user'];
|
|
127
|
+
const user = typeof rawUser === 'string' && rawUser.trim() ? rawUser.trim() : undefined;
|
|
128
|
+
const rawName = q['name'];
|
|
129
|
+
const labelName = typeof rawName === 'string' && rawName.trim() ? rawName.trim() : undefined;
|
|
130
|
+
const rawHandle = q['handle'];
|
|
131
|
+
const handle = typeof rawHandle === 'string' && rawHandle.trim() ? rawHandle.trim() : undefined;
|
|
132
|
+
const rawSearch = q['search'];
|
|
133
|
+
const search = typeof rawSearch === 'string' && rawSearch.trim() ? rawSearch.trim() : undefined;
|
|
134
|
+
const rawWorkOnAll = q['workOnAll'];
|
|
135
|
+
const workOnAll = rawWorkOnAll === true;
|
|
136
|
+
const rawBotName = q['botName'];
|
|
137
|
+
const botName = typeof rawBotName === 'string' && rawBotName.trim() ? rawBotName.trim() : undefined;
|
|
138
|
+
const rawBotEmail = q['botEmail'];
|
|
139
|
+
const botEmail = typeof rawBotEmail === 'string' && rawBotEmail.trim() ? rawBotEmail.trim() : undefined;
|
|
140
|
+
return (0, result_js_1.ok)({
|
|
141
|
+
type: rawType,
|
|
142
|
+
...(user !== undefined ? { user } : {}),
|
|
143
|
+
...(labelName !== undefined ? { name: labelName } : {}),
|
|
144
|
+
...(handle !== undefined ? { handle } : {}),
|
|
145
|
+
...(search !== undefined ? { search } : {}),
|
|
146
|
+
...(workOnAll ? { workOnAll } : {}),
|
|
147
|
+
pollIntervalSeconds,
|
|
148
|
+
maxTotalConcurrentSessions,
|
|
149
|
+
excludeLabels,
|
|
150
|
+
repo: rawRepo.trim(),
|
|
151
|
+
token: resolvedToken,
|
|
152
|
+
...(botName !== undefined ? { botName } : {}),
|
|
153
|
+
...(botEmail !== undefined ? { botEmail } : {}),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -21,7 +21,7 @@ export interface NotificationConfig {
|
|
|
21
21
|
export interface NotificationPayload {
|
|
22
22
|
readonly event: 'session_completed';
|
|
23
23
|
readonly workflowId: string;
|
|
24
|
-
readonly outcome: 'success' | 'error' | 'timeout' | 'delivery_failed';
|
|
24
|
+
readonly outcome: 'success' | 'error' | 'timeout' | 'stuck' | 'delivery_failed';
|
|
25
25
|
readonly detail: string;
|
|
26
26
|
readonly goal: string;
|
|
27
27
|
readonly timestamp: string;
|
|
@@ -48,6 +48,8 @@ function buildNotificationBody(result, goal) {
|
|
|
48
48
|
return `Session failed: ${truncated}`;
|
|
49
49
|
case 'timeout':
|
|
50
50
|
return `Session timed out: ${truncated}`;
|
|
51
|
+
case 'stuck':
|
|
52
|
+
return `Session stuck (${result.reason}): ${truncated}`;
|
|
51
53
|
case 'delivery_failed':
|
|
52
54
|
return `Session completed but result delivery failed: ${truncated}`;
|
|
53
55
|
}
|
|
@@ -63,6 +65,8 @@ function buildDetail(result) {
|
|
|
63
65
|
return result.message;
|
|
64
66
|
case 'timeout':
|
|
65
67
|
return result.message;
|
|
68
|
+
case 'stuck':
|
|
69
|
+
return result.message;
|
|
66
70
|
case 'delivery_failed':
|
|
67
71
|
return `stopReason: ${result.stopReason}; deliveryError: ${result.deliveryError}`;
|
|
68
72
|
}
|
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.PollingScheduler = void 0;
|
|
4
37
|
const gitlab_poller_js_1 = require("./adapters/gitlab-poller.js");
|
|
5
38
|
const github_poller_js_1 = require("./adapters/github-poller.js");
|
|
39
|
+
const github_queue_poller_js_1 = require("./adapters/github-queue-poller.js");
|
|
40
|
+
const github_queue_config_js_1 = require("./github-queue-config.js");
|
|
41
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
42
|
+
const os = __importStar(require("node:os"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
6
44
|
function isPollingTrigger(trigger) {
|
|
7
45
|
return trigger.pollingSource !== undefined;
|
|
8
46
|
}
|
|
@@ -78,6 +116,9 @@ class PollingScheduler {
|
|
|
78
116
|
case 'github_prs_poll':
|
|
79
117
|
await this.doPollGitHub(trigger, triggerId, pollStartAt, lastPollAt, trigger.pollingSource, 'prs');
|
|
80
118
|
break;
|
|
119
|
+
case 'github_queue_poll':
|
|
120
|
+
await this.doPollGitHubQueue(trigger, triggerId, trigger.pollingSource);
|
|
121
|
+
break;
|
|
81
122
|
default: {
|
|
82
123
|
const _exhaustive = trigger.pollingSource;
|
|
83
124
|
console.warn(`[PollingScheduler] Unknown provider '${String(_exhaustive.provider)}' ` +
|
|
@@ -150,6 +191,111 @@ class PollingScheduler {
|
|
|
150
191
|
console.log(`[PollingScheduler] Dispatched ${newIds.length} new event(s) for trigger '${triggerId}'`);
|
|
151
192
|
}
|
|
152
193
|
}
|
|
194
|
+
async doPollGitHubQueue(trigger, triggerId, source) {
|
|
195
|
+
const cycleStart = Date.now();
|
|
196
|
+
const configResult = await (0, github_queue_config_js_1.loadQueueConfig)();
|
|
197
|
+
if (configResult.kind === 'err') {
|
|
198
|
+
console.warn(`[QueuePoll] Failed to load queue config for trigger '${triggerId}': ${configResult.error}. Skipping cycle.`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const queueConfig = configResult.value;
|
|
202
|
+
if (queueConfig === null)
|
|
203
|
+
return;
|
|
204
|
+
if (queueConfig.type !== 'assignee') {
|
|
205
|
+
console.error(`[QueuePoll] Queue type '${queueConfig.type}' is not implemented. Only 'assignee' is supported. Skipping cycle.`);
|
|
206
|
+
await appendQueuePollLog({ event: 'poll_cycle_complete', triggerId, reason: 'not_implemented', queueType: queueConfig.type, ts: new Date().toISOString() });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const sessionsDir = path.join(os.homedir(), '.workrail', 'daemon-sessions');
|
|
210
|
+
const activeSessions = await countActiveSessions(sessionsDir);
|
|
211
|
+
if (activeSessions >= queueConfig.maxTotalConcurrentSessions) {
|
|
212
|
+
console.log(`[QueuePoll] Skipping cycle: active sessions (${activeSessions}) >= maxTotalConcurrentSessions (${queueConfig.maxTotalConcurrentSessions}).`);
|
|
213
|
+
await appendQueuePollLog({ event: 'poll_cycle_skipped', triggerId, reason: 'max_concurrency_reached', activeSessions, maxTotalConcurrentSessions: queueConfig.maxTotalConcurrentSessions, ts: new Date().toISOString() });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const fetchResult = await (0, github_queue_poller_js_1.pollGitHubQueueIssues)(source, queueConfig, this.fetchFn);
|
|
217
|
+
if (fetchResult.kind === 'err') {
|
|
218
|
+
console.warn(`[QueuePoll] GitHub API error for trigger '${triggerId}': ${fetchResult.error.kind}. Skipping cycle.`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const issues = fetchResult.value;
|
|
222
|
+
console.log(`[QueuePoll] cycle start repo=${source.repo} issues_fetched=${issues.length}`);
|
|
223
|
+
const candidates = [];
|
|
224
|
+
const skipped = [];
|
|
225
|
+
for (const issue of issues) {
|
|
226
|
+
const issueLabels = issue.labels.map((l) => l.name);
|
|
227
|
+
const excludedLabel = queueConfig.excludeLabels.find((el) => issueLabels.includes(el));
|
|
228
|
+
if (excludedLabel) {
|
|
229
|
+
skipped.push({ issue, reason: `excluded_label: ${excludedLabel}` });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (issueLabels.includes('worktrain:in-progress')) {
|
|
233
|
+
skipped.push({ issue, reason: 'active_session_or_in_progress' });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (/sess_[a-z0-9]+/.test(issue.body)) {
|
|
237
|
+
skipped.push({ issue, reason: 'active_session_or_in_progress' });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const idempotencyStatus = await (0, github_queue_poller_js_1.checkIdempotency)(issue.number, sessionsDir);
|
|
241
|
+
if (idempotencyStatus === 'active') {
|
|
242
|
+
skipped.push({ issue, reason: 'active_session' });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const maturity = (0, github_queue_poller_js_1.inferMaturity)(issue.body);
|
|
246
|
+
candidates.push({ issue, maturity });
|
|
247
|
+
}
|
|
248
|
+
const MATURITY_RANK = { ready: 0, specced: 1, idea: 2 };
|
|
249
|
+
candidates.sort((a, b) => {
|
|
250
|
+
const rankDiff = (MATURITY_RANK[a.maturity] ?? 2) - (MATURITY_RANK[b.maturity] ?? 2);
|
|
251
|
+
return rankDiff !== 0 ? rankDiff : a.issue.number - b.issue.number;
|
|
252
|
+
});
|
|
253
|
+
for (const { issue, reason } of skipped) {
|
|
254
|
+
console.log(`[QueuePoll] skipped #${issue.number} "${issue.title}" reason=${reason}`);
|
|
255
|
+
await appendQueuePollLog({ event: 'task_skipped', issueNumber: issue.number, title: issue.title, reason, ts: new Date().toISOString() });
|
|
256
|
+
}
|
|
257
|
+
if (candidates.length === 0) {
|
|
258
|
+
console.log('[QueuePoll] No actionable issues found in this poll cycle.');
|
|
259
|
+
await appendQueuePollLog({ event: 'poll_cycle_complete', selected: 0, skipped: skipped.length, elapsed: Date.now() - cycleStart, ts: new Date().toISOString() });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const top = candidates[0];
|
|
263
|
+
const upstreamSpecUrl = extractUpstreamSpecUrl(top.issue.body);
|
|
264
|
+
const taskCandidate = {
|
|
265
|
+
issueNumber: top.issue.number,
|
|
266
|
+
title: top.issue.title,
|
|
267
|
+
body: top.issue.body,
|
|
268
|
+
url: top.issue.url,
|
|
269
|
+
inferredMaturity: top.maturity,
|
|
270
|
+
...(upstreamSpecUrl !== undefined ? { upstreamSpecUrl } : {}),
|
|
271
|
+
queueConfigType: queueConfig.type,
|
|
272
|
+
};
|
|
273
|
+
const workflowTrigger = {
|
|
274
|
+
workflowId: trigger.workflowId,
|
|
275
|
+
goal: top.issue.title,
|
|
276
|
+
workspacePath: trigger.workspacePath,
|
|
277
|
+
context: { taskCandidate },
|
|
278
|
+
...(trigger.referenceUrls !== undefined ? { referenceUrls: trigger.referenceUrls } : {}),
|
|
279
|
+
...(trigger.agentConfig !== undefined ? { agentConfig: trigger.agentConfig } : {}),
|
|
280
|
+
...(trigger.soulFile !== undefined ? { soulFile: trigger.soulFile } : {}),
|
|
281
|
+
botIdentity: {
|
|
282
|
+
name: queueConfig.botName ?? 'worktrain',
|
|
283
|
+
email: queueConfig.botEmail ?? 'worktrain@users.noreply.github.com',
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const maturityReason = describeMaturityReason(top.maturity);
|
|
287
|
+
console.log(`[QueuePoll] selected #${top.issue.number} "${top.issue.title}" maturity=${top.maturity} reason="${maturityReason}"`);
|
|
288
|
+
await appendQueuePollLog({ event: 'task_selected', issueNumber: top.issue.number, title: top.issue.title, maturity: top.maturity, reason: maturityReason, ts: new Date().toISOString() });
|
|
289
|
+
this.router.dispatch(workflowTrigger);
|
|
290
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
291
|
+
const { issue, maturity } = candidates[i];
|
|
292
|
+
console.log(`[QueuePoll] skipped #${issue.number} "${issue.title}" reason=lower_priority_${maturity}`);
|
|
293
|
+
await appendQueuePollLog({ event: 'task_skipped', issueNumber: issue.number, title: issue.title, inferredMaturity: maturity, reason: `lower_priority_${maturity}`, ts: new Date().toISOString() });
|
|
294
|
+
}
|
|
295
|
+
const elapsed = Date.now() - cycleStart;
|
|
296
|
+
console.log(`[QueuePoll] cycle complete selected=1 skipped=${skipped.length + candidates.length - 1} elapsed=${elapsed}ms`);
|
|
297
|
+
await appendQueuePollLog({ event: 'poll_cycle_complete', selected: 1, skipped: skipped.length + candidates.length - 1, elapsed, ts: new Date().toISOString() });
|
|
298
|
+
}
|
|
153
299
|
}
|
|
154
300
|
exports.PollingScheduler = PollingScheduler;
|
|
155
301
|
function buildGitLabWorkflowTrigger(trigger, mr) {
|
|
@@ -232,12 +378,12 @@ function interpolateGoalFromPayload(trigger, payload) {
|
|
|
232
378
|
return template.replace(/\{\{([^}]+)\}\}/g, (_, token) => resolved.get(token) ?? trigger.goal);
|
|
233
379
|
}
|
|
234
380
|
function extractDotPath(obj, rawPath) {
|
|
235
|
-
let
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
else if (
|
|
239
|
-
|
|
240
|
-
const segments =
|
|
381
|
+
let dotPath = rawPath.trim();
|
|
382
|
+
if (dotPath.startsWith('$.'))
|
|
383
|
+
dotPath = dotPath.slice(2);
|
|
384
|
+
else if (dotPath.startsWith('$'))
|
|
385
|
+
dotPath = dotPath.slice(1);
|
|
386
|
+
const segments = dotPath.split('.');
|
|
241
387
|
let current = obj;
|
|
242
388
|
for (const segment of segments) {
|
|
243
389
|
if (segment.includes('[') || current === null || typeof current !== 'object') {
|
|
@@ -247,3 +393,36 @@ function extractDotPath(obj, rawPath) {
|
|
|
247
393
|
}
|
|
248
394
|
return current;
|
|
249
395
|
}
|
|
396
|
+
async function countActiveSessions(sessionsDir) {
|
|
397
|
+
try {
|
|
398
|
+
const files = await fs.readdir(sessionsDir);
|
|
399
|
+
return files.filter((f) => f.endsWith('.json')).length;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function appendQueuePollLog(entry) {
|
|
406
|
+
const logPath = path.join(os.homedir(), '.workrail', 'queue-poll.jsonl');
|
|
407
|
+
try {
|
|
408
|
+
await fs.appendFile(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
console.warn(`[QueuePoll] Failed to write queue-poll.jsonl: ${e instanceof Error ? e.message : String(e)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function extractUpstreamSpecUrl(body) {
|
|
415
|
+
const specLineMatch = /upstream_spec:\s*(https?:\/\/\S+)/i.exec(body);
|
|
416
|
+
if (specLineMatch?.[1])
|
|
417
|
+
return specLineMatch[1];
|
|
418
|
+
const firstPara = body.split(/\n\s*\n/)[0] ?? '';
|
|
419
|
+
const urlMatch = /(https?:\/\/\S+)/.exec(firstPara);
|
|
420
|
+
return urlMatch?.[1];
|
|
421
|
+
}
|
|
422
|
+
function describeMaturityReason(maturity) {
|
|
423
|
+
switch (maturity) {
|
|
424
|
+
case 'ready': return 'has upstream spec URL';
|
|
425
|
+
case 'specced': return 'has acceptance criteria or checklist';
|
|
426
|
+
case 'idea': return 'maturity=idea, no upstream spec';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -327,6 +327,10 @@ class TriggerRouter {
|
|
|
327
327
|
console.log(`[TriggerRouter] Workflow failed: triggerId=${trigger.id} ` +
|
|
328
328
|
`workflowId=${trigger.workflowId} error=${result.message} stopReason=${result.stopReason}`);
|
|
329
329
|
}
|
|
330
|
+
else if (result._tag === 'stuck') {
|
|
331
|
+
console.log(`[TriggerRouter] Workflow stuck: triggerId=${trigger.id} ` +
|
|
332
|
+
`workflowId=${trigger.workflowId} reason=${result.reason} message=${result.message}`);
|
|
333
|
+
}
|
|
330
334
|
else {
|
|
331
335
|
(0, assert_never_js_1.assertNever)(result);
|
|
332
336
|
}
|
|
@@ -366,6 +370,10 @@ class TriggerRouter {
|
|
|
366
370
|
console.log(`[TriggerRouter] Dispatch failed: workflowId=${workflowTrigger.workflowId} ` +
|
|
367
371
|
`error=${result.message} stopReason=${result.stopReason}`);
|
|
368
372
|
}
|
|
373
|
+
else if (result._tag === 'stuck') {
|
|
374
|
+
console.log(`[TriggerRouter] Dispatch stuck: workflowId=${workflowTrigger.workflowId} ` +
|
|
375
|
+
`reason=${result.reason} message=${result.message}`);
|
|
376
|
+
}
|
|
369
377
|
else {
|
|
370
378
|
(0, assert_never_js_1.assertNever)(result);
|
|
371
379
|
}
|
|
@@ -41,7 +41,7 @@ const path = __importStar(require("node:path"));
|
|
|
41
41
|
const fs = __importStar(require("node:fs/promises"));
|
|
42
42
|
const result_js_1 = require("../runtime/result.js");
|
|
43
43
|
const types_js_1 = require("./types.js");
|
|
44
|
-
const SUPPORTED_PROVIDERS = new Set(['generic', 'gitlab_poll', 'github_issues_poll', 'github_prs_poll']);
|
|
44
|
+
const SUPPORTED_PROVIDERS = new Set(['generic', 'gitlab_poll', 'github_issues_poll', 'github_prs_poll', 'github_queue_poll']);
|
|
45
45
|
function unquoteYamlScalar(raw) {
|
|
46
46
|
const s = raw.trim();
|
|
47
47
|
if ((s.startsWith('"') && s.endsWith('"')) ||
|
|
@@ -754,10 +754,43 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
|
|
|
754
754
|
};
|
|
755
755
|
}
|
|
756
756
|
}
|
|
757
|
+
else if (provider === 'github_queue_poll') {
|
|
758
|
+
if (!raw.source) {
|
|
759
|
+
return (0, result_js_1.err)({ kind: 'missing_field', field: 'source', triggerId: rawId });
|
|
760
|
+
}
|
|
761
|
+
const queueSrc = raw.source;
|
|
762
|
+
if (!queueSrc.repo?.trim()) {
|
|
763
|
+
return (0, result_js_1.err)({ kind: 'missing_field', field: 'source.repo', triggerId: rawId });
|
|
764
|
+
}
|
|
765
|
+
if (!queueSrc.token?.trim()) {
|
|
766
|
+
return (0, result_js_1.err)({ kind: 'missing_field', field: 'source.token', triggerId: rawId });
|
|
767
|
+
}
|
|
768
|
+
const queueTokenResult = resolveSecret(queueSrc.token.trim(), rawId, env);
|
|
769
|
+
if (queueTokenResult.kind === 'err')
|
|
770
|
+
return queueTokenResult;
|
|
771
|
+
let queuePollIntervalSeconds = 300;
|
|
772
|
+
if (queueSrc.pollIntervalSeconds?.trim()) {
|
|
773
|
+
const asNumber = Number(queueSrc.pollIntervalSeconds.trim());
|
|
774
|
+
if (!Number.isInteger(asNumber) || asNumber <= 0) {
|
|
775
|
+
return (0, result_js_1.err)({
|
|
776
|
+
kind: 'invalid_field_value',
|
|
777
|
+
field: `source.pollIntervalSeconds (must be a positive integer, got: ${queueSrc.pollIntervalSeconds})`,
|
|
778
|
+
triggerId: rawId,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
queuePollIntervalSeconds = asNumber;
|
|
782
|
+
}
|
|
783
|
+
pollingSource = {
|
|
784
|
+
provider: 'github_queue_poll',
|
|
785
|
+
repo: queueSrc.repo.trim(),
|
|
786
|
+
token: queueTokenResult.value,
|
|
787
|
+
pollIntervalSeconds: queuePollIntervalSeconds,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
757
790
|
else if (raw.source) {
|
|
758
791
|
console.warn(`[TriggerStore] WARNING: trigger '${rawId}' has provider='${provider}' but also ` +
|
|
759
792
|
`defines a source: block. The source: block is only used for polling providers ` +
|
|
760
|
-
`(gitlab_poll, github_issues_poll, github_prs_poll). It will be ignored for this trigger.`);
|
|
793
|
+
`(gitlab_poll, github_issues_poll, github_prs_poll, github_queue_poll). It will be ignored for this trigger.`);
|
|
761
794
|
}
|
|
762
795
|
const trigger = {
|
|
763
796
|
id: (0, types_js_1.asTriggerId)(rawId),
|
package/dist/trigger/types.d.ts
CHANGED
|
@@ -34,12 +34,28 @@ export interface GitHubPollingSource {
|
|
|
34
34
|
readonly notLabels: readonly string[];
|
|
35
35
|
readonly labelFilter: readonly string[];
|
|
36
36
|
}
|
|
37
|
+
export interface GitHubQueuePollingSource {
|
|
38
|
+
readonly repo: string;
|
|
39
|
+
readonly token: string;
|
|
40
|
+
readonly pollIntervalSeconds: number;
|
|
41
|
+
}
|
|
42
|
+
export interface TaskCandidate {
|
|
43
|
+
readonly issueNumber: number;
|
|
44
|
+
readonly title: string;
|
|
45
|
+
readonly body: string;
|
|
46
|
+
readonly url: string;
|
|
47
|
+
readonly inferredMaturity: 'idea' | 'specced' | 'ready';
|
|
48
|
+
readonly upstreamSpecUrl?: string;
|
|
49
|
+
readonly queueConfigType: 'assignee' | 'label' | 'mention' | 'query';
|
|
50
|
+
}
|
|
37
51
|
export type PollingSource = (GitLabPollingSource & {
|
|
38
52
|
readonly provider: 'gitlab_poll';
|
|
39
53
|
}) | (GitHubPollingSource & {
|
|
40
54
|
readonly provider: 'github_issues_poll';
|
|
41
55
|
}) | (GitHubPollingSource & {
|
|
42
56
|
readonly provider: 'github_prs_poll';
|
|
57
|
+
}) | (GitHubQueuePollingSource & {
|
|
58
|
+
readonly provider: 'github_queue_poll';
|
|
43
59
|
});
|
|
44
60
|
export interface TriggerDefinition {
|
|
45
61
|
readonly id: TriggerId;
|
|
@@ -55,6 +71,8 @@ export interface TriggerDefinition {
|
|
|
55
71
|
readonly model?: string;
|
|
56
72
|
readonly maxSessionMinutes?: number;
|
|
57
73
|
readonly maxTurns?: number;
|
|
74
|
+
readonly stuckAbortPolicy?: 'abort' | 'notify_only';
|
|
75
|
+
readonly noProgressAbortEnabled?: boolean;
|
|
58
76
|
};
|
|
59
77
|
readonly concurrencyMode: 'serial' | 'parallel';
|
|
60
78
|
readonly callbackUrl?: string;
|
|
@@ -604,6 +604,9 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
604
604
|
else if (result._tag === 'error') {
|
|
605
605
|
console.log(`[ConsoleRoutes] Auto dispatch failed: workflowId=${workflowId} error=${result.message}`);
|
|
606
606
|
}
|
|
607
|
+
else if (result._tag === 'stuck') {
|
|
608
|
+
console.log(`[ConsoleRoutes] Auto dispatch stuck: workflowId=${workflowId} reason=${result.reason} message=${result.message}`);
|
|
609
|
+
}
|
|
607
610
|
else {
|
|
608
611
|
(0, assert_never_js_1.assertNever)(result);
|
|
609
612
|
}
|