ework-daemon 0.4.66 → 0.4.68
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 +65 -18
package/package.json
CHANGED
package/src/opencode.ts
CHANGED
|
@@ -321,13 +321,16 @@ function createBackendFor(cfg: Config, runtime: string): RuntimeBackend {
|
|
|
321
321
|
// Wake policy shared by issue_opened and comment_created: blacklist wins,
|
|
322
322
|
// then an explicit login whitelist (which replaces the kind check), then
|
|
323
323
|
// author kind. Issue openers carry no kind and default to human.
|
|
324
|
+
// extraLogins extends the env whitelist with per-project entries fetched
|
|
325
|
+
// from the web config center; kinds still apply to them (bots never wake).
|
|
324
326
|
export function wakePolicySkips(
|
|
325
327
|
d: { nonWakingAuthors: string[]; noWakeLogins: string[]; wakeLogins: string[]; wakeKinds: string[] },
|
|
326
328
|
author: string,
|
|
327
329
|
authorKind: string,
|
|
330
|
+
extraLogins: string[] = [],
|
|
328
331
|
): string | null {
|
|
329
332
|
if ([...d.nonWakingAuthors, ...d.noWakeLogins].includes(author)) return `non-waking author ${author}`;
|
|
330
|
-
if (d.wakeLogins.length > 0 && !d.wakeLogins.includes(author)) return `author ${author} not in wakeLogins`;
|
|
333
|
+
if (d.wakeLogins.length > 0 && ![...d.wakeLogins, ...extraLogins].includes(author)) return `author ${author} not in wakeLogins`;
|
|
331
334
|
if (!d.wakeKinds.includes(authorKind)) return `author kind ${authorKind} not in wakeKinds [${d.wakeKinds.join(",")}]`;
|
|
332
335
|
return null;
|
|
333
336
|
}
|
|
@@ -414,6 +417,7 @@ export class Engine {
|
|
|
414
417
|
private envInitialized = new Set<string>();
|
|
415
418
|
private replyBurstCfg?: { max: number; windowMs: number };
|
|
416
419
|
private replyStamps = new Map<string, number[]>();
|
|
420
|
+
private wakeWhitelistCache = new Map<string, { at: number; logins: string[] }>();
|
|
417
421
|
|
|
418
422
|
private static MAX_INLINE_SIZE = 4000;
|
|
419
423
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
@@ -495,6 +499,32 @@ export class Engine {
|
|
|
495
499
|
} catch { /* non-critical */ }
|
|
496
500
|
}
|
|
497
501
|
|
|
502
|
+
// Per-project wake whitelist from the web config center (admin-managed
|
|
503
|
+
// external GitHub users). Cached 60s; on fetch failure a stale cache is
|
|
504
|
+
// still honored (it was a prior web decision) but an empty first fetch
|
|
505
|
+
// fails closed.
|
|
506
|
+
private async projectWakeLogins(scopeKey: string): Promise<string[]> {
|
|
507
|
+
const hit = this.wakeWhitelistCache.get(scopeKey);
|
|
508
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.logins;
|
|
509
|
+
const parts = scopeKey.split("/");
|
|
510
|
+
const owner = parts[0] ?? "";
|
|
511
|
+
const repo = parts.slice(1).join("/");
|
|
512
|
+
if (!owner || !repo) return [];
|
|
513
|
+
const url = `${this.cfg.gitea.url}/api/v1/wake-logins?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
514
|
+
try {
|
|
515
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(5000), headers: { Authorization: `token ${this.cfg.gitea.token}` } });
|
|
516
|
+
if (!resp.ok) throw new Error(`web returned ${resp.status}`);
|
|
517
|
+
const data = await resp.json() as { logins?: string[] };
|
|
518
|
+
const logins = (Array.isArray(data.logins) ? data.logins : [])
|
|
519
|
+
.map((s) => String(s).trim()).filter(Boolean);
|
|
520
|
+
this.wakeWhitelistCache.set(scopeKey, { at: Date.now(), logins });
|
|
521
|
+
return logins;
|
|
522
|
+
} catch (err) {
|
|
523
|
+
log.warn(`engine: wake whitelist query failed for ${scopeKey}: ${(err as Error).message}${hit ? " — using stale cache" : " — fail-closed"}`);
|
|
524
|
+
return hit?.logins ?? [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
498
528
|
private async webGateAllows(issue: Issue): Promise<{ allowed: boolean; reason: string }> {
|
|
499
529
|
const parts = issue.trackerScopeKey.split("/");
|
|
500
530
|
const owner = parts[0] ?? "";
|
|
@@ -827,7 +857,19 @@ export class Engine {
|
|
|
827
857
|
const wakeAuthor = event.type === "comment_created" ? event.comment?.author : event.type === "issue_opened" ? event.issue?.author : undefined;
|
|
828
858
|
const wakeKind = event.type === "comment_created" ? event.comment?.authorKind ?? "human" : "human";
|
|
829
859
|
if (wakeAuthor) {
|
|
830
|
-
|
|
860
|
+
let skip = wakePolicySkips(this.cfg.daemon, wakeAuthor, wakeKind);
|
|
861
|
+
if (skip && skip.includes("not in wakeLogins")) {
|
|
862
|
+
// GitHub logins are case-insensitive; match the project whitelist that
|
|
863
|
+
// way, then inject the exact author string for the exact-match check.
|
|
864
|
+
const extra = (await this.projectWakeLogins(scopeKey))
|
|
865
|
+
.filter((l) => l.toLowerCase() === wakeAuthor.toLowerCase());
|
|
866
|
+
if (extra.length > 0) {
|
|
867
|
+
skip = wakePolicySkips(this.cfg.daemon, wakeAuthor, wakeKind, [wakeAuthor]);
|
|
868
|
+
if (!skip) {
|
|
869
|
+
log.info(`engine: author ${wakeAuthor} is in project wake whitelist — allowing ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
831
873
|
if (skip) {
|
|
832
874
|
log.info(`engine: ${skip} — skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
833
875
|
return;
|
|
@@ -938,7 +980,7 @@ export class Engine {
|
|
|
938
980
|
// (or hit the 10-min cap), and the user must see pickup feedback immediately.
|
|
939
981
|
// The session link is a placeholder until the backend reports its session id;
|
|
940
982
|
// the onSessionId callback rewrites this comment with the real reference.
|
|
941
|
-
await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue — preparing workspace…`).then(
|
|
983
|
+
await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} 🔄 **${session.name}** picked up this issue — preparing workspace…`).then(
|
|
942
984
|
(c) => this.pickupCommentId.set(k, c.id),
|
|
943
985
|
() => {},
|
|
944
986
|
);
|
|
@@ -1024,12 +1066,13 @@ export class Engine {
|
|
|
1024
1066
|
const instructions = tracker.getTrackerInstructions(ref);
|
|
1025
1067
|
const prompt = this.buildForwardPrompt(
|
|
1026
1068
|
session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
|
|
1027
|
-
comment.author, comment.authorKind, issueData.title, workdir, instructions
|
|
1069
|
+
comment.author, comment.authorKind, issueData.title, workdir, instructions,
|
|
1070
|
+
this.wakeWhitelistCache.get(scopeKey)?.logins ?? []
|
|
1028
1071
|
);
|
|
1029
1072
|
|
|
1030
1073
|
// Immediate ack
|
|
1031
1074
|
{
|
|
1032
|
-
const c = await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n>
|
|
1075
|
+
const c = await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> workdir: ${this.workdirLink(workdir)}`);
|
|
1033
1076
|
if (!session.opencodeSessionId) this.forwardCommentId.set(this.sessionKey(session, issue), c.id);
|
|
1034
1077
|
}
|
|
1035
1078
|
|
|
@@ -1043,7 +1086,7 @@ export class Engine {
|
|
|
1043
1086
|
}
|
|
1044
1087
|
const workdir = await this.resolveWorkdir(session, issue);
|
|
1045
1088
|
|
|
1046
|
-
await tracker.createComment(ref, `[system] 🔄 **${session.name}** joined the conversation.`);
|
|
1089
|
+
await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} 🔄 **${session.name}** joined the conversation.`);
|
|
1047
1090
|
|
|
1048
1091
|
const instructions = tracker.getTrackerInstructions(ref);
|
|
1049
1092
|
const payloadClone = this.cloneUrls.get(`${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
@@ -1078,9 +1121,10 @@ export class Engine {
|
|
|
1078
1121
|
const instructions = tracker.getTrackerInstructions(ref);
|
|
1079
1122
|
const prompt = this.buildForwardPrompt(
|
|
1080
1123
|
session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
|
|
1081
|
-
comment.author, comment.authorKind, issueData.title, workdir, instructions
|
|
1124
|
+
comment.author, comment.authorKind, issueData.title, workdir, instructions,
|
|
1125
|
+
this.wakeWhitelistCache.get(scopeKey)?.logins ?? []
|
|
1082
1126
|
);
|
|
1083
|
-
await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n>
|
|
1127
|
+
await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> workdir: ${this.workdirLink(workdir)}`);
|
|
1084
1128
|
await this.enqueueOrRun(session, issue, prompt, comment.id, model);
|
|
1085
1129
|
}
|
|
1086
1130
|
} finally {
|
|
@@ -1392,13 +1436,13 @@ export class Engine {
|
|
|
1392
1436
|
this.pickupCommentId.delete(k);
|
|
1393
1437
|
this.forwardCommentId.delete(k);
|
|
1394
1438
|
await tracker
|
|
1395
|
-
.editComment(ref, pickupId, `[system] 🔄 **${session.name}** picked up this issue.\n>
|
|
1439
|
+
.editComment(ref, pickupId, `[system] 🏷 ${this.sessionRef(session)} 🔄 **${session.name}** picked up this issue.\n> workdir: ${this.workdirLink(workdir)}`)
|
|
1396
1440
|
.catch((err) => log.error(`engine: failed to rewrite pickup comment for ${k}:`, (err as Error).message));
|
|
1397
1441
|
}
|
|
1398
1442
|
const forwardId = this.forwardCommentId.get(k);
|
|
1399
1443
|
if (forwardId) {
|
|
1400
1444
|
this.forwardCommentId.delete(k);
|
|
1401
|
-
const body = `[system] ✓ Message forwarded to **${session.name}**${this.running.has(k) ? " (running)" : ""}.\n>
|
|
1445
|
+
const body = `[system] 🏷 ${this.sessionRef(session)} ✓ Message forwarded to **${session.name}**${this.running.has(k) ? " (running)" : ""}.\n> workdir: ${this.workdirLink(workdir)}`;
|
|
1402
1446
|
await tracker.editComment(ref, forwardId, body).catch(() => { /* cosmetic rewrite */ });
|
|
1403
1447
|
}
|
|
1404
1448
|
}
|
|
@@ -1563,7 +1607,7 @@ export class Engine {
|
|
|
1563
1607
|
log.error(`engine: empty model response for ${k} after ${emptyRound} retries, reporting error`);
|
|
1564
1608
|
this.emptyResponseRounds.delete(k);
|
|
1565
1609
|
this.nudgeRounds.delete(k);
|
|
1566
|
-
await tracker.createComment(ref, `[system] ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
|
|
1610
|
+
await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} ❌ **${session.name}** 模型返回空响应(0 token),已重试 ${emptyRound} 次。请检查模型配置或稍后重试。`).catch(() => {});
|
|
1567
1611
|
void tracker.updateStatus(ref, "failed", "empty model response");
|
|
1568
1612
|
} else {
|
|
1569
1613
|
const nudgeRound = this.nudgeRounds.get(k) ?? 0;
|
|
@@ -1582,7 +1626,7 @@ export class Engine {
|
|
|
1582
1626
|
log.info(`engine: no [bot] reply for ${k} (promptTime=${started ?? "unknown"}), marking done (nudge exhausted or process failed)`);
|
|
1583
1627
|
this.nudgeRounds.delete(k);
|
|
1584
1628
|
const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
|
|
1585
|
-
await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
|
|
1629
|
+
await tracker.createComment(ref, `[system] 🏷 ${this.sessionRef(session)} ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
|
|
1586
1630
|
void tracker.updateStatus(ref, "failed", detail);
|
|
1587
1631
|
}
|
|
1588
1632
|
}
|
|
@@ -1698,11 +1742,13 @@ export class Engine {
|
|
|
1698
1742
|
// Wake-policy whitelist mirrors the dispatch decision: an author outside
|
|
1699
1743
|
// wakeLogins cannot start work, but their comments still enter a running
|
|
1700
1744
|
// session's prompt — flag them so the model treats their text as data.
|
|
1701
|
-
|
|
1745
|
+
// The project whitelist (case-insensitive) counts as trusted: vetting a
|
|
1746
|
+
// user is an explicit operator trust decision.
|
|
1747
|
+
private isTrustedAuthor(login: string, extraTrusted: string[] = []): boolean {
|
|
1702
1748
|
const d = this.cfg.daemon;
|
|
1703
1749
|
if ([...d.nonWakingAuthors, ...d.noWakeLogins].includes(login)) return false;
|
|
1704
|
-
if (d.wakeLogins.length
|
|
1705
|
-
return
|
|
1750
|
+
if (d.wakeLogins.length === 0) return true;
|
|
1751
|
+
return [...d.wakeLogins, ...extraTrusted].some((l) => l.toLowerCase() === login.toLowerCase());
|
|
1706
1752
|
}
|
|
1707
1753
|
|
|
1708
1754
|
private buildForwardPrompt(
|
|
@@ -1712,10 +1758,11 @@ export class Engine {
|
|
|
1712
1758
|
authorKind: string | undefined,
|
|
1713
1759
|
issueTitle: string,
|
|
1714
1760
|
workdir: string,
|
|
1715
|
-
instructions: { issueRef: string }
|
|
1761
|
+
instructions: { issueRef: string },
|
|
1762
|
+
extraTrusted: string[] = [],
|
|
1716
1763
|
): string {
|
|
1717
1764
|
const who = authorKind === "bot" ? `@${commentUser} (bot)` : `@${commentUser} (user)`;
|
|
1718
|
-
const trusted = this.isTrustedAuthor(commentUser);
|
|
1765
|
+
const trusted = this.isTrustedAuthor(commentUser, extraTrusted);
|
|
1719
1766
|
return [
|
|
1720
1767
|
`[SYSTEM FORWARD] User ${who}${trusted ? "" : " (unverified outside user)"} posted a new comment on ${instructions.issueRef} "${issueTitle}".`,
|
|
1721
1768
|
trusted
|
|
@@ -1975,7 +2022,7 @@ export class Engine {
|
|
|
1975
2022
|
|
|
1976
2023
|
const ref = this.sessionToRef(session, issue);
|
|
1977
2024
|
const duration = this.formatDuration(now - started);
|
|
1978
|
-
const body = `[system] ⏳ **${session.name}** processing, running for ${duration}...`;
|
|
2025
|
+
const body = `[system] 🏷 ${this.sessionRef(session)} ⏳ **${session.name}** processing, running for ${duration}...`;
|
|
1979
2026
|
|
|
1980
2027
|
const existingId = this.progressCommentId.get(k);
|
|
1981
2028
|
try {
|