ework-daemon 0.4.14 → 0.4.16
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/package.json +1 -1
- package/src/opencode.ts +151 -40
- package/src/sync/engine.ts +129 -0
- package/src/trackers/gitea-tracker.ts +9 -0
- package/src/trackers/types.ts +34 -0
package/package.json
CHANGED
package/src/opencode.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
3
4
|
import { join, resolve, isAbsolute } from "path";
|
|
4
5
|
import { homedir } from "os";
|
|
@@ -21,9 +22,7 @@ interface TrackerRegistry {
|
|
|
21
22
|
* the coordination layer.
|
|
22
23
|
*/
|
|
23
24
|
export interface TakeoverStrategy {
|
|
24
|
-
|
|
25
|
-
acquireWorkdir(session: OpSession, issue: Issue): Promise<string>;
|
|
26
|
-
/** Return an opencode session id to resume, or null for a fresh session. */
|
|
25
|
+
acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string>;
|
|
27
26
|
resumeOpenCodeSession(session: OpSession): Promise<string | null>;
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -92,6 +91,68 @@ export async function runHookScript(script: string | undefined, workdir: string,
|
|
|
92
91
|
}
|
|
93
92
|
}
|
|
94
93
|
|
|
94
|
+
const SYSTEM_PREFIX = "[system]";
|
|
95
|
+
const RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Determine whether any non-system bot reply exists that is causally after
|
|
99
|
+
* `promptTime` (when provided) or within a 5-minute absolute window (fallback).
|
|
100
|
+
* Exported for unit testing — the causal vs absolute distinction is the P0
|
|
101
|
+
* correctness fix for preempt/nudge false-done detection.
|
|
102
|
+
*/
|
|
103
|
+
export function hasRecentBotReply(
|
|
104
|
+
comments: TrackerComment[],
|
|
105
|
+
isBotUser: (author: string) => boolean,
|
|
106
|
+
promptTime?: number,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (promptTime) {
|
|
109
|
+
return comments.some(c => {
|
|
110
|
+
if (!isBotUser(c.author) || c.body.startsWith(SYSTEM_PREFIX)) return false;
|
|
111
|
+
if (!c.createdAt) return false;
|
|
112
|
+
return new Date(c.createdAt).getTime() > promptTime;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
return comments.some(c => {
|
|
117
|
+
if (!isBotUser(c.author) || c.body.startsWith(SYSTEM_PREFIX)) return false;
|
|
118
|
+
if (!c.createdAt) return true;
|
|
119
|
+
const age = now - new Date(c.createdAt).getTime();
|
|
120
|
+
return age < RECENT_BOT_REPLY_THRESHOLD_MS;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Query the opencode SQLite DB for a session's assistant-message output tokens.
|
|
126
|
+
* Returns `{hasOutput: true}` (safe default) when the DB can't be opened or
|
|
127
|
+
* the session is undefined — this means the retry path is only triggered when
|
|
128
|
+
* we have POSITIVE evidence of 0-token output.
|
|
129
|
+
* Exported for unit testing.
|
|
130
|
+
*/
|
|
131
|
+
export async function checkSessionOutput(
|
|
132
|
+
dbPath: string,
|
|
133
|
+
opencodeSessionId: string | undefined,
|
|
134
|
+
): Promise<{ hasOutput: boolean; tokenCount: number }> {
|
|
135
|
+
if (!opencodeSessionId) return { hasOutput: true, tokenCount: 0 };
|
|
136
|
+
let db: Database;
|
|
137
|
+
try {
|
|
138
|
+
db = new Database(dbPath, { readonly: true });
|
|
139
|
+
} catch {
|
|
140
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const row = db.prepare(
|
|
144
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
|
|
145
|
+
"FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
|
|
146
|
+
).get(opencodeSessionId) as { n: number; tokens: number } | null;
|
|
147
|
+
if (!row) return { hasOutput: true, tokenCount: 0 };
|
|
148
|
+
return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
|
|
149
|
+
} catch {
|
|
150
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
151
|
+
} finally {
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
95
156
|
/**
|
|
96
157
|
* Default TakeoverStrategy: deterministic per-issue workdir under
|
|
97
158
|
* `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
|
|
@@ -101,7 +162,7 @@ export async function runHookScript(script: string | undefined, workdir: string,
|
|
|
101
162
|
export class RecloneStrategy implements TakeoverStrategy {
|
|
102
163
|
constructor(private cfg: Config) {}
|
|
103
164
|
|
|
104
|
-
async acquireWorkdir(session: OpSession, issue: Issue): Promise<string> {
|
|
165
|
+
async acquireWorkdir(session: OpSession, issue: Issue, cloneUrl?: string, env?: Record<string, string>): Promise<string> {
|
|
105
166
|
if (session.workdir) {
|
|
106
167
|
let dir = session.workdir;
|
|
107
168
|
if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
|
|
@@ -122,21 +183,20 @@ export class RecloneStrategy implements TakeoverStrategy {
|
|
|
122
183
|
try {
|
|
123
184
|
const entries = readdirSync(dir);
|
|
124
185
|
if (entries.length === 0) {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// opencode binary path instead of the missing cwd. Fall back to
|
|
132
|
-
// `git init` so the workdir is at least a valid git repo the agent
|
|
133
|
-
// can work in.
|
|
186
|
+
const url = cloneUrl ?? `${this.cfg.gitea.url.replace(/\/$/, "")}/${owner}/${repo}.git`;
|
|
187
|
+
const credHelper = process.env.WORK_GIT_CREDENTIAL_HELPER;
|
|
188
|
+
const gitArgs = ["git"];
|
|
189
|
+
if (credHelper) gitArgs.push("-c", `credential.helper=${credHelper}`);
|
|
190
|
+
gitArgs.push("clone", url, dir);
|
|
191
|
+
const r = Bun.spawnSync({ cmd: gitArgs, stdout: "ignore", stderr: "pipe", env: { ...process.env, ...env } });
|
|
134
192
|
if (r.exitCode !== 0) {
|
|
135
193
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
136
194
|
if (existsSync(dir) && readdirSync(dir).length === 0) {
|
|
137
195
|
Bun.spawnSync({ cmd: ["git", "init", dir], stdout: "ignore", stderr: "ignore" });
|
|
138
196
|
}
|
|
139
|
-
|
|
197
|
+
const stderrBuf = r.stderr as Uint8Array | undefined;
|
|
198
|
+
const stderrText = stderrBuf ? new TextDecoder().decode(stderrBuf).slice(0, 500) : "";
|
|
199
|
+
log.warn(`acquireWorkdir: git clone failed (exit ${r.exitCode}) for ${url}${stderrText ? `: ${stderrText}` : ""}; fell back to empty workdir`);
|
|
140
200
|
}
|
|
141
201
|
}
|
|
142
202
|
} catch {
|
|
@@ -227,6 +287,7 @@ export class Engine {
|
|
|
227
287
|
private progressCommentId = new Map<string, string>();
|
|
228
288
|
|
|
229
289
|
private nudgeRounds = new Map<string, number>();
|
|
290
|
+
private emptyResponseRounds = new Map<string, number>();
|
|
230
291
|
private processExitNudgeRounds = new Map<string, number>();
|
|
231
292
|
private stuckNudgeRounds = new Map<string, number>();
|
|
232
293
|
private currentPrompt = new Map<string, string>();
|
|
@@ -241,9 +302,12 @@ export class Engine {
|
|
|
241
302
|
private observerTimer?: ReturnType<typeof setInterval>;
|
|
242
303
|
|
|
243
304
|
private groupConfigs = new Map<string, GroupConfig>();
|
|
305
|
+
private cloneUrls = new Map<string, string>();
|
|
306
|
+
private senders = new Map<string, string>();
|
|
244
307
|
|
|
245
308
|
private static MAX_INLINE_SIZE = 4000;
|
|
246
309
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
310
|
+
private static MAX_EMPTY_RESPONSE_ROUNDS = 1;
|
|
247
311
|
private static MAX_STUCK_NUDGE_ROUNDS = 1;
|
|
248
312
|
private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
|
|
249
313
|
private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
|
|
@@ -334,20 +398,35 @@ export class Engine {
|
|
|
334
398
|
mkdirSync(dir, { recursive: true });
|
|
335
399
|
return dir;
|
|
336
400
|
}
|
|
337
|
-
|
|
401
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
402
|
+
const cloneUrl = this.cloneUrls.get(issueMapKey);
|
|
403
|
+
const owner = String(issue.trackerScope["owner"] ?? issue.trackerScopeKey.split("/")[0] ?? "");
|
|
404
|
+
const repo = String(issue.trackerScope["repo"] ?? issue.trackerScopeKey.split("/").slice(-1)[0] ?? "");
|
|
405
|
+
const sender = this.senders.get(issueMapKey);
|
|
406
|
+
const env: Record<string, string> = {
|
|
407
|
+
EWORK_OWNER: owner,
|
|
408
|
+
EWORK_REPO: repo,
|
|
409
|
+
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
410
|
+
};
|
|
411
|
+
if (sender) env.EWORK_SENDER = sender;
|
|
412
|
+
return this.takeover.acquireWorkdir(session, issue, cloneUrl, env);
|
|
338
413
|
}
|
|
339
414
|
|
|
340
415
|
private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
|
|
341
416
|
const parts = issue.trackerScopeKey.split("/");
|
|
342
417
|
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
|
|
343
418
|
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
|
|
344
|
-
|
|
419
|
+
const issueMapKey = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`;
|
|
420
|
+
const sender = this.senders.get(issueMapKey);
|
|
421
|
+
const env: Record<string, string> = {
|
|
345
422
|
EWORK_OWNER: String(owner),
|
|
346
423
|
EWORK_REPO: String(repo),
|
|
347
424
|
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
348
425
|
EWORK_SESSION: session.name,
|
|
349
426
|
EWORK_WORKDIR: workdir,
|
|
350
427
|
};
|
|
428
|
+
if (sender) env.EWORK_SENDER = sender;
|
|
429
|
+
return env;
|
|
351
430
|
}
|
|
352
431
|
|
|
353
432
|
private workdirPathFor(session: OpSession, issue: Issue): string {
|
|
@@ -415,32 +494,26 @@ export class Engine {
|
|
|
415
494
|
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
|
|
416
495
|
}
|
|
417
496
|
|
|
418
|
-
/** System comments are posted by the daemon itself (acks, progress, reports). They are NOT AI replies. */
|
|
419
|
-
private static SYSTEM_PREFIX = "[system]";
|
|
420
|
-
private static RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
|
|
421
|
-
|
|
422
497
|
private isSystemComment(comment: TrackerComment): boolean {
|
|
423
|
-
return comment.body.startsWith(
|
|
498
|
+
return comment.body.startsWith(SYSTEM_PREFIX);
|
|
424
499
|
}
|
|
425
500
|
|
|
426
501
|
private countAIReplies(comments: TrackerComment[], tracker: IssueTracker): number {
|
|
427
502
|
return comments.filter(c => tracker.isBotUser(c.author) && !this.isSystemComment(c)).length;
|
|
428
503
|
}
|
|
429
504
|
|
|
430
|
-
private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker): boolean {
|
|
431
|
-
|
|
432
|
-
return comments.some(c => {
|
|
433
|
-
if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
|
|
434
|
-
if (!c.createdAt) return true; // no timestamp — assume recent to avoid false nudges
|
|
435
|
-
const age = now - new Date(c.createdAt).getTime();
|
|
436
|
-
return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
|
|
437
|
-
});
|
|
505
|
+
private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker, promptTime?: number): boolean {
|
|
506
|
+
return hasRecentBotReply(comments, (a) => tracker.isBotUser(a), promptTime);
|
|
438
507
|
}
|
|
439
508
|
|
|
440
509
|
private lastBotReply(comments: TrackerComment[], tracker: IssueTracker): TrackerComment | undefined {
|
|
441
510
|
return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
|
|
442
511
|
}
|
|
443
512
|
|
|
513
|
+
private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
|
|
514
|
+
return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
|
|
515
|
+
}
|
|
516
|
+
|
|
444
517
|
// ─── Event Dispatch ───
|
|
445
518
|
|
|
446
519
|
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
@@ -448,8 +521,15 @@ export class Engine {
|
|
|
448
521
|
const tracker = this.getTracker(ref.trackerType);
|
|
449
522
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
450
523
|
|
|
524
|
+
const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
451
525
|
if (groupConfig) {
|
|
452
|
-
this.groupConfigs.set(
|
|
526
|
+
this.groupConfigs.set(issueMapKey, groupConfig);
|
|
527
|
+
}
|
|
528
|
+
if (event.cloneUrl) {
|
|
529
|
+
this.cloneUrls.set(issueMapKey, event.cloneUrl);
|
|
530
|
+
}
|
|
531
|
+
if (event.sender) {
|
|
532
|
+
this.senders.set(issueMapKey, event.sender);
|
|
453
533
|
}
|
|
454
534
|
|
|
455
535
|
switch (event.type) {
|
|
@@ -689,6 +769,8 @@ export class Engine {
|
|
|
689
769
|
}
|
|
690
770
|
}
|
|
691
771
|
if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
|
|
772
|
+
this.cloneUrls.delete(gcKey);
|
|
773
|
+
this.senders.delete(gcKey);
|
|
692
774
|
|
|
693
775
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
694
776
|
}
|
|
@@ -1005,17 +1087,21 @@ export class Engine {
|
|
|
1005
1087
|
log.info(`engine: finishRun aborted (superseded) for ${k}`);
|
|
1006
1088
|
return;
|
|
1007
1089
|
}
|
|
1008
|
-
const hasRecent = this.hasRecentBotReply(commentsNow, tracker);
|
|
1090
|
+
const hasRecent = this.hasRecentBotReply(commentsNow, tracker, started ?? undefined);
|
|
1009
1091
|
|
|
1010
1092
|
if (hasRecent) {
|
|
1011
|
-
|
|
1093
|
+
const matched = commentsNow.find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c) && c.createdAt && new Date(c.createdAt).getTime() > (started ?? 0));
|
|
1094
|
+
log.info(`engine: [bot] reply found for ${k} after prompt (comment ${matched?.id ?? "?"} createdAt ${matched?.createdAt ?? "?"}), marking done`);
|
|
1012
1095
|
this.nudgeRounds.delete(k);
|
|
1096
|
+
this.emptyResponseRounds.delete(k);
|
|
1013
1097
|
await this.persistRuntimeState(session.id);
|
|
1014
1098
|
} else {
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1099
|
+
const sessionOutput = await this.checkSessionOutput(session.opencodeSessionId);
|
|
1100
|
+
const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
|
|
1101
|
+
|
|
1102
|
+
if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
1103
|
+
log.warn(`engine: empty model response for ${k} (0 tokens, round ${emptyRound + 1}/${Engine.MAX_EMPTY_RESPONSE_ROUNDS}), retrying`);
|
|
1104
|
+
this.emptyResponseRounds.set(k, emptyRound + 1);
|
|
1019
1105
|
this.currentPrompt.delete(k);
|
|
1020
1106
|
await this.persistRuntimeState(session.id);
|
|
1021
1107
|
|
|
@@ -1025,10 +1111,31 @@ export class Engine {
|
|
|
1025
1111
|
await this.dequeueOrIdle(k, session, issue, nudgeMsg);
|
|
1026
1112
|
return;
|
|
1027
1113
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1114
|
+
|
|
1115
|
+
if (!sessionOutput.hasOutput && emptyRound >= Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
1116
|
+
log.error(`engine: empty model response for ${k} after ${emptyRound} retries, reporting error`);
|
|
1117
|
+
this.emptyResponseRounds.delete(k);
|
|
1118
|
+
this.nudgeRounds.delete(k);
|
|
1119
|
+
await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
|
|
1120
|
+
} else {
|
|
1121
|
+
const nudgeRound = this.nudgeRounds.get(k) ?? 0;
|
|
1122
|
+
if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
|
|
1123
|
+
log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
|
|
1124
|
+
this.nudgeRounds.set(k, nudgeRound + 1);
|
|
1125
|
+
this.currentPrompt.delete(k);
|
|
1126
|
+
await this.persistRuntimeState(session.id);
|
|
1127
|
+
|
|
1128
|
+
const instructions = tracker.getTrackerInstructions(ref);
|
|
1129
|
+
const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
|
|
1130
|
+
const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt, undefined, undefined, this.currentModel.get(k));
|
|
1131
|
+
await this.dequeueOrIdle(k, session, issue, nudgeMsg);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), marking done (nudge exhausted or process failed)`);
|
|
1135
|
+
this.nudgeRounds.delete(k);
|
|
1136
|
+
const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
|
|
1137
|
+
await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
|
|
1138
|
+
}
|
|
1032
1139
|
}
|
|
1033
1140
|
|
|
1034
1141
|
// Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
|
|
@@ -1619,5 +1726,9 @@ export class Engine {
|
|
|
1619
1726
|
this.stuckNudgeRounds.clear();
|
|
1620
1727
|
this.currentPrompt.clear();
|
|
1621
1728
|
this.generation.clear();
|
|
1729
|
+
this.groupConfigs.clear();
|
|
1730
|
+
this.cloneUrls.clear();
|
|
1731
|
+
this.senders.clear();
|
|
1732
|
+
this.emptyResponseRounds.clear();
|
|
1622
1733
|
}
|
|
1623
1734
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { PollingTracker, SyncExternalIssue, SyncExternalComment, TrackerRef, PollResult } from "../trackers/types";
|
|
2
|
+
import { log } from "../logger";
|
|
3
|
+
|
|
4
|
+
const PROVENANCE_RE = /<!--\s*sync:(\w+):([^\s:]+)(?::([^\s]+))?\s*-->/;
|
|
5
|
+
|
|
6
|
+
export function withProvenance(sourceType: string, externalId: string, body: string): string {
|
|
7
|
+
const marker = `<!-- sync:${sourceType}:${externalId} -->`;
|
|
8
|
+
return body.endsWith("\n") ? `${body}${marker}` : `${body}\n${marker}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function extractProvenance(body: string): { sourceType: string; externalId: string; commentId?: string } | null {
|
|
12
|
+
const m = body.match(PROVENANCE_RE);
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
return { sourceType: m[1]!, externalId: m[2]!, commentId: m[3] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isSyncMirrored(body: string, sourceType: string): boolean {
|
|
18
|
+
const p = extractProvenance(body);
|
|
19
|
+
return p?.sourceType === sourceType;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncEngineOptions {
|
|
23
|
+
tracker: PollingTracker;
|
|
24
|
+
scope: Record<string, string>;
|
|
25
|
+
webUrl: string;
|
|
26
|
+
webToken: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
repo: string;
|
|
29
|
+
pollIntervalMs: number;
|
|
30
|
+
cursorFile: string;
|
|
31
|
+
botLogin: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CursorState {
|
|
35
|
+
issueCursor: string | null;
|
|
36
|
+
commentCursors: Record<string, string | null>;
|
|
37
|
+
issueMap: Record<string, number>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadCursors(path: string): Promise<CursorState> {
|
|
41
|
+
try {
|
|
42
|
+
const f = Bun.file(path);
|
|
43
|
+
if (await f.exists()) return await f.json();
|
|
44
|
+
} catch { /* first run */ }
|
|
45
|
+
return { issueCursor: null, commentCursors: {}, issueMap: {} };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveCursors(path: string, state: CursorState): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await Bun.write(path, JSON.stringify(state, null, 2));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
log.warn(`sync: failed to save cursors: ${(e as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SyncEngine {
|
|
57
|
+
private opts: SyncEngineOptions;
|
|
58
|
+
private running = false;
|
|
59
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
60
|
+
|
|
61
|
+
constructor(opts: SyncEngineOptions) {
|
|
62
|
+
this.opts = opts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async pollOnce(): Promise<void> {
|
|
66
|
+
const state = await loadCursors(this.opts.cursorFile);
|
|
67
|
+
const { tracker, scope, webUrl, webToken, owner, repo } = this.opts;
|
|
68
|
+
|
|
69
|
+
const issueResult: PollResult<SyncExternalIssue> = await tracker.listChangedIssues(scope, state.issueCursor);
|
|
70
|
+
for (const ext of issueResult.items) {
|
|
71
|
+
const existingLocal = state.issueMap[ext.externalId];
|
|
72
|
+
if (ext.state === "open" && !existingLocal) {
|
|
73
|
+
const body = withProvenance(tracker.type, ext.externalId, ext.body);
|
|
74
|
+
const resp = await fetch(`${webUrl}/api/v1/repos/${owner}/${repo}/issues`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json", Authorization: `token ${webToken}` },
|
|
77
|
+
body: JSON.stringify({ title: ext.title, body, assignee: ext.author }),
|
|
78
|
+
});
|
|
79
|
+
if (resp.ok) {
|
|
80
|
+
const data = await resp.json() as { number: number };
|
|
81
|
+
state.issueMap[ext.externalId] = data.number;
|
|
82
|
+
log.info(`sync: created issue #${data.number} from ${tracker.type}:${ext.externalId}`);
|
|
83
|
+
} else {
|
|
84
|
+
log.warn(`sync: create issue failed: ${resp.status} ${await resp.text()}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
state.issueCursor = issueResult.nextCursor ?? state.issueCursor;
|
|
89
|
+
|
|
90
|
+
for (const [extId, issueNum] of Object.entries(state.issueMap)) {
|
|
91
|
+
const ref: TrackerRef = { trackerType: tracker.type, scope, issueId: String(issueNum) };
|
|
92
|
+
const cCursor = state.commentCursors[extId] ?? null;
|
|
93
|
+
const cResult: PollResult<SyncExternalComment> = await tracker.listChangedComments(ref, cCursor);
|
|
94
|
+
for (const c of cResult.items) {
|
|
95
|
+
if (tracker.isBotUser(c.author)) continue;
|
|
96
|
+
const body = withProvenance(tracker.type, extId, c.body);
|
|
97
|
+
const resp = await fetch(`${webUrl}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json", Authorization: `token ${webToken}` },
|
|
100
|
+
body: JSON.stringify({ body }),
|
|
101
|
+
});
|
|
102
|
+
if (resp.ok) {
|
|
103
|
+
log.info(`sync: created comment on issue #${issueNum} from ${tracker.type}:${extId}:${c.externalId}`);
|
|
104
|
+
} else {
|
|
105
|
+
log.warn(`sync: create comment failed: ${resp.status}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
state.commentCursors[extId] = cResult.nextCursor ?? cCursor;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await saveCursors(this.opts.cursorFile, state);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
start(): void {
|
|
115
|
+
if (this.running) return;
|
|
116
|
+
this.running = true;
|
|
117
|
+
log.info(`sync: poll loop started (interval=${this.opts.pollIntervalMs}ms, source=${this.opts.tracker.type})`);
|
|
118
|
+
this.pollOnce().catch(e => log.error(`sync: poll error: ${(e as Error).message}`));
|
|
119
|
+
this.timer = setInterval(() => {
|
|
120
|
+
this.pollOnce().catch(e => log.error(`sync: poll error: ${(e as Error).message}`));
|
|
121
|
+
}, this.opts.pollIntervalMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stop(): void {
|
|
125
|
+
this.running = false;
|
|
126
|
+
if (this.timer) clearInterval(this.timer);
|
|
127
|
+
log.info("sync: poll loop stopped");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -119,6 +119,9 @@ export class GiteaTracker implements IssueTracker {
|
|
|
119
119
|
const repoName = repository.name as string;
|
|
120
120
|
if (!repoOwner || !repoName) return null;
|
|
121
121
|
|
|
122
|
+
const cloneUrl = typeof repository.clone_url === "string" ? repository.clone_url : undefined;
|
|
123
|
+
const sender = (payload.sender as Record<string, string>)?.login;
|
|
124
|
+
|
|
122
125
|
// ework-web extension (non-Gitea field, ignored by strict Gitea consumers).
|
|
123
126
|
// Empty/missing = no model override; engine omits --model.
|
|
124
127
|
const modelRaw = repository.ework_model;
|
|
@@ -143,6 +146,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
143
146
|
author: issueUser?.login ?? "",
|
|
144
147
|
},
|
|
145
148
|
model,
|
|
149
|
+
cloneUrl,
|
|
150
|
+
sender,
|
|
146
151
|
};
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -163,6 +168,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
163
168
|
author: commentUser?.login ?? "",
|
|
164
169
|
},
|
|
165
170
|
model,
|
|
171
|
+
cloneUrl,
|
|
172
|
+
sender,
|
|
166
173
|
};
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -177,6 +184,8 @@ export class GiteaTracker implements IssueTracker {
|
|
|
177
184
|
author: issueUser?.login ?? "",
|
|
178
185
|
},
|
|
179
186
|
model,
|
|
187
|
+
cloneUrl,
|
|
188
|
+
sender,
|
|
180
189
|
};
|
|
181
190
|
}
|
|
182
191
|
|
package/src/trackers/types.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface TrackerEvent {
|
|
|
33
33
|
// global default). Empty/undefined = no override; engine omits --model
|
|
34
34
|
// and lets opencode pick per its own opencode.json + env.
|
|
35
35
|
model?: string;
|
|
36
|
+
// Real clone URL from the upstream tracker (e.g. Gitea repository.clone_url).
|
|
37
|
+
// When present, RecloneStrategy uses this instead of the ework shim URL.
|
|
38
|
+
cloneUrl?: string;
|
|
39
|
+
// Webhook sender (who triggered the event). Forwarded to the daemon's
|
|
40
|
+
// task context so the AI knows who it's responding to.
|
|
41
|
+
sender?: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** Tracker-agnostic comment */
|
|
@@ -135,6 +141,34 @@ export interface IssueTracker {
|
|
|
135
141
|
isBotUser(userIdentifier: string): boolean;
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
// ─── Polling Tracker (Sync Source) ───
|
|
145
|
+
|
|
146
|
+
export interface SyncExternalIssue {
|
|
147
|
+
externalId: string;
|
|
148
|
+
title: string;
|
|
149
|
+
body: string;
|
|
150
|
+
state: "open" | "closed";
|
|
151
|
+
author: string;
|
|
152
|
+
updatedAt: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SyncExternalComment {
|
|
156
|
+
externalId: string;
|
|
157
|
+
body: string;
|
|
158
|
+
author: string;
|
|
159
|
+
createdAt: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface PollResult<T> {
|
|
163
|
+
items: T[];
|
|
164
|
+
nextCursor: string | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface PollingTracker extends IssueTracker {
|
|
168
|
+
listChangedIssues(scope: Record<string, string>, cursor: string | null): Promise<PollResult<SyncExternalIssue>>;
|
|
169
|
+
listChangedComments(ref: TrackerRef, cursor: string | null): Promise<PollResult<SyncExternalComment>>;
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
// ─── Runtime Key Format ───
|
|
139
173
|
|
|
140
174
|
/**
|