ework-daemon 0.4.67 → 0.4.69

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/opencode.ts +67 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.67",
3
+ "version": "0.4.69",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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
- const skip = wakePolicySkips(this.cfg.daemon, wakeAuthor, wakeKind);
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;
@@ -1024,7 +1066,8 @@ 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
@@ -1078,7 +1121,8 @@ 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
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);
@@ -1414,7 +1458,10 @@ export class Engine {
1414
1458
 
1415
1459
  this.processes.set(k, handle);
1416
1460
  this.lastOutputAt.set(k, Date.now());
1417
- if (!this.startedAt.has(k)) this.startedAt.set(k, Date.now());
1461
+ // Budget is strictly per-run: a replacement spawn must never inherit the
1462
+ // previous run's start timestamp (observed: a fresh pid killed 30s after
1463
+ // spawn because the 3h watchdog still held the superseded run's start).
1464
+ this.startedAt.set(k, Date.now());
1418
1465
  this.currentPrompt.set(k, msg.content);
1419
1466
 
1420
1467
  await this.store.updateSession(session.id, { opencodePid: handle.pid });
@@ -1698,11 +1745,13 @@ export class Engine {
1698
1745
  // Wake-policy whitelist mirrors the dispatch decision: an author outside
1699
1746
  // wakeLogins cannot start work, but their comments still enter a running
1700
1747
  // session's prompt — flag them so the model treats their text as data.
1701
- private isTrustedAuthor(login: string): boolean {
1748
+ // The project whitelist (case-insensitive) counts as trusted: vetting a
1749
+ // user is an explicit operator trust decision.
1750
+ private isTrustedAuthor(login: string, extraTrusted: string[] = []): boolean {
1702
1751
  const d = this.cfg.daemon;
1703
1752
  if ([...d.nonWakingAuthors, ...d.noWakeLogins].includes(login)) return false;
1704
- if (d.wakeLogins.length > 0) return d.wakeLogins.includes(login);
1705
- return true;
1753
+ if (d.wakeLogins.length === 0) return true;
1754
+ return [...d.wakeLogins, ...extraTrusted].some((l) => l.toLowerCase() === login.toLowerCase());
1706
1755
  }
1707
1756
 
1708
1757
  private buildForwardPrompt(
@@ -1712,10 +1761,11 @@ export class Engine {
1712
1761
  authorKind: string | undefined,
1713
1762
  issueTitle: string,
1714
1763
  workdir: string,
1715
- instructions: { issueRef: string }
1764
+ instructions: { issueRef: string },
1765
+ extraTrusted: string[] = [],
1716
1766
  ): string {
1717
1767
  const who = authorKind === "bot" ? `@${commentUser} (bot)` : `@${commentUser} (user)`;
1718
- const trusted = this.isTrustedAuthor(commentUser);
1768
+ const trusted = this.isTrustedAuthor(commentUser, extraTrusted);
1719
1769
  return [
1720
1770
  `[SYSTEM FORWARD] User ${who}${trusted ? "" : " (unverified outside user)"} posted a new comment on ${instructions.issueRef} "${issueTitle}".`,
1721
1771
  trusted
@@ -1927,7 +1977,13 @@ export class Engine {
1927
1977
  log.warn(`engine: run exceeded max runtime (${hrs}h) on ${k}, stopping`);
1928
1978
  await tracker.createComment(this.sessionToRef(session, issue), `[system] ⏹ **${session.name}** run exceeded ${hrs}h — stopped. Reply again on the issue to continue.`).catch(() => { /* best-effort */ });
1929
1979
  this.nudgeRounds.set(k, Engine.MAX_NUDGE_ROUNDS);
1930
- this.forceStop(k);
1980
+ await this.forceStop(k);
1981
+ // forceStop clears daemon-side state but intentionally skips
1982
+ // finishRun (stopping flag), so without this the web ai_status
1983
+ // stays "processing" forever — the exact stuck state users see
1984
+ // after a capped run. Capped runs may have delivered partial
1985
+ // work, so "completed" (not "failed") is the honest terminal.
1986
+ void tracker.updateStatus(this.sessionToRef(session, issue), "completed");
1931
1987
  continue;
1932
1988
  }
1933
1989