@runuai/host 0.7.1 → 0.8.1
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/lib/agent-cli.ts +40 -0
- package/lib/github-tokens.ts +57 -1
- package/lib/orchestrator.ts +30 -1
- package/package.json +1 -1
- package/src/main.ts +14 -7
package/lib/agent-cli.ts
CHANGED
|
@@ -239,6 +239,41 @@ async function main() {
|
|
|
239
239
|
out(action + " #" + t.shortId + ": " + t.text);
|
|
240
240
|
break;
|
|
241
241
|
}
|
|
242
|
+
case "preview add": {
|
|
243
|
+
const name = pos[0] || flags.name;
|
|
244
|
+
const port = Number(pos[1] || flags.port);
|
|
245
|
+
if (!name || !Number.isInteger(port)) { console.error("uai: preview add <name> <containerPort>"); process.exit(1); }
|
|
246
|
+
const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
|
|
247
|
+
out("preview '" + r.name + "' on port " + r.containerPort + " — enable it in the task's preview menu to get a URL");
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "project create": {
|
|
251
|
+
const name = flags.name || pos.join(" ");
|
|
252
|
+
if (!name || !flags.repo) { console.error("uai: project create --name <n> --repo <git-url> [--prompt <p>]"); process.exit(1); }
|
|
253
|
+
const p = (await api("POST", "/api/agent/projects", { name, repoUrl: flags.repo, defaultPrompt: flags.prompt || undefined })).project;
|
|
254
|
+
out("created project " + p.slug + " (" + p.id + ")");
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "task search": {
|
|
258
|
+
const q = pos.join(" ") || flags.q || "";
|
|
259
|
+
if (!q) { console.error("uai: task search <query>"); process.exit(1); }
|
|
260
|
+
const hits = (await api("GET", "/api/agent/history?q=" + encodeURIComponent(q))).messages || [];
|
|
261
|
+
if (!hits.length) { out("no matches in your past tasks"); break; }
|
|
262
|
+
out(hits.map((h) => "[" + h.taskName + "] @" + h.author + ": " + h.snippet).join("\\n"));
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case "task read": {
|
|
266
|
+
const id = pos[0] || flags.id;
|
|
267
|
+
if (!id) { console.error("uai: task read <taskId> (see 'uai task history')"); process.exit(1); }
|
|
268
|
+
const chat = await api("GET", "/api/agent/history?taskId=" + encodeURIComponent(id));
|
|
269
|
+
out("# " + chat.taskName + "\\n" + (chat.messages || []).map((m) => "@" + m.author + ": " + m.snippet).join("\\n"));
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case "task history": {
|
|
273
|
+
const tasks = (await api("GET", "/api/agent/history")).tasks || [];
|
|
274
|
+
out(tasks.length ? tasks.map((t) => t.taskId + " " + t.taskName + " (" + t.status + ")").join("\\n") : "no past tasks");
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
242
277
|
case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
|
|
243
278
|
case "react heart": case "react check": case "react x": {
|
|
244
279
|
const r = await api("POST", "/api/agent/react", { emoji: action, msg: flags.msg || undefined });
|
|
@@ -258,6 +293,11 @@ async function main() {
|
|
|
258
293
|
" uai todo list",
|
|
259
294
|
" uai todo add <text>",
|
|
260
295
|
" uai todo claim|done|reopen|unclaim <#id>",
|
|
296
|
+
" uai preview add <name> <containerPort>",
|
|
297
|
+
" uai project create --name <n> --repo <git-url> [--prompt <p>]",
|
|
298
|
+
" uai task search <query> (your own past tasks)",
|
|
299
|
+
" uai task history (list your past tasks)",
|
|
300
|
+
" uai task read <taskId> (full chat of a past task you were on)",
|
|
261
301
|
" uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
|
|
262
302
|
" uai whoami",
|
|
263
303
|
].join("\\n"));
|
package/lib/github-tokens.ts
CHANGED
|
@@ -56,6 +56,11 @@ export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string
|
|
|
56
56
|
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
57
57
|
.run();
|
|
58
58
|
notifyGithubChange();
|
|
59
|
+
// A reconnect must reach ALREADY-RUNNING containers, not just new tasks:
|
|
60
|
+
// each container's gh config is written once, at task setup, so a fresh
|
|
61
|
+
// grant otherwise sits unused until /retry-gh. Re-inject into the user's
|
|
62
|
+
// live tasks now. Fire-and-forget — never blocks the token store.
|
|
63
|
+
reinjectRunningTasks(frame.userId);
|
|
59
64
|
return { ok: true };
|
|
60
65
|
} catch (err) {
|
|
61
66
|
return { ok: false, error: err instanceof Error ? err.message : "store failed" };
|
|
@@ -170,6 +175,20 @@ function activeTaskIdsForUser(userId: string): string[] {
|
|
|
170
175
|
.map((r) => r.taskId);
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Re-mint + inject a (re)connected token into the user's live tasks — so a
|
|
180
|
+
* reconnect on Account applies to running containers, not only new tasks. Clear
|
|
181
|
+
* any armed retry chain first (a fresh grant must not be swallowed by an outage
|
|
182
|
+
* backoff, and `setupTaskGithub`'s duplicate-chain guard would no-op it) then
|
|
183
|
+
* run setup fresh. Fire-and-forget per task; best-effort by construction.
|
|
184
|
+
*/
|
|
185
|
+
export function reinjectRunningTasks(userId: string): void {
|
|
186
|
+
for (const taskId of activeTaskIdsForUser(userId)) {
|
|
187
|
+
clearRefresh(taskId);
|
|
188
|
+
void setupTaskGithub(taskId, userId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
// --- access-token exchange --------------------------------------------------
|
|
174
193
|
|
|
175
194
|
function cloudHttpBase(): string {
|
|
@@ -434,6 +453,37 @@ function isRevokedTokenError(reason: string): boolean {
|
|
|
434
453
|
return /revoked|invalid_grant|bad_refresh/i.test(reason);
|
|
435
454
|
}
|
|
436
455
|
|
|
456
|
+
/**
|
|
457
|
+
* True when the failure names a genuinely bad credential — GitHub answered the
|
|
458
|
+
* validation with 401/403 ("Bad credentials"), so the token really is wrong and
|
|
459
|
+
* only a reconnect fixes it. Distinct from a 5xx blip (see below).
|
|
460
|
+
*/
|
|
461
|
+
function isBadCredentialError(reason: string): boolean {
|
|
462
|
+
return /validating token: HTTP 40[13]\b|Bad credentials|401 Unauthorized|403 Forbidden/i.test(
|
|
463
|
+
reason,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* True when the failure is a GitHub-side / network blip that a retry will heal
|
|
469
|
+
* — the API returned 5xx (as happens when `gh auth login` validates the token
|
|
470
|
+
* against api.github.com during an outage), a rate-limit, or the fetch itself
|
|
471
|
+
* failed. Such a failure must NOT be surfaced as "authentication expired —
|
|
472
|
+
* reconnect": the token is fine, GitHub is briefly unavailable. A genuine bad
|
|
473
|
+
* credential (401/403) is explicitly excluded so it still routes to reconnect.
|
|
474
|
+
*/
|
|
475
|
+
export function isTransientGithubError(reason: string): boolean {
|
|
476
|
+
if (isBadCredentialError(reason)) return false;
|
|
477
|
+
return (
|
|
478
|
+
/HTTP 5\d\d|Service Unavailable|Bad Gateway|Gateway Time-?out|server error|rate limit|too many requests|secondary rate|unavailable|unreachable/i.test(
|
|
479
|
+
reason,
|
|
480
|
+
) ||
|
|
481
|
+
/\bfetch failed\b|timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|EAI_AGAIN|socket hang up|\bnetwork\b/i.test(
|
|
482
|
+
reason,
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
437
487
|
async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
438
488
|
try {
|
|
439
489
|
const tok = await requestAccessToken(userId);
|
|
@@ -602,10 +652,16 @@ export async function setupTaskGithub(
|
|
|
602
652
|
authExpiredHandler?.(taskId, userId, reason);
|
|
603
653
|
return false;
|
|
604
654
|
}
|
|
655
|
+
// A retry chain already armed for this task means a sibling entry point
|
|
656
|
+
// (task-up and channel-ensure both call setup) already reported this and
|
|
657
|
+
// owns recovery — don't post a duplicate note or start a competing chain.
|
|
658
|
+
// `/retry-gh` clears the chain first, so a manual retry is never swallowed.
|
|
659
|
+
if (attempt === 0 && retryTimers.has(taskId)) return false;
|
|
605
660
|
// Transient: self-heal with bounded backoff. Post the chat note only on
|
|
606
661
|
// the FIRST failure of a chain — each retry re-enters this catch, and six
|
|
607
662
|
// "gh auth" notes for one outage is noise. Exhaustion posts its own note
|
|
608
|
-
// (in scheduleGithubRetry).
|
|
663
|
+
// (in scheduleGithubRetry). The handler classifies the reason (transient
|
|
664
|
+
// 5xx blip vs. genuine expiry) and words the note accordingly.
|
|
609
665
|
if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
|
|
610
666
|
scheduleGithubRetry(taskId, userId, attempt, deps);
|
|
611
667
|
return false;
|
package/lib/orchestrator.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from "./agents/types";
|
|
35
35
|
import { ACTIVE_STATUSES } from "./task-status";
|
|
36
36
|
import { getHostTask, upsertHostTask } from "./runtime-state";
|
|
37
|
-
import { setupTaskGithub } from "./github-tokens";
|
|
37
|
+
import { clearRefresh, setupTaskGithub } from "./github-tokens";
|
|
38
38
|
import { setupTaskGitIdentity } from "./git-identity";
|
|
39
39
|
import { dockerCli } from "./docker-exec";
|
|
40
40
|
import {
|
|
@@ -571,6 +571,9 @@ class Orchestrator {
|
|
|
571
571
|
return;
|
|
572
572
|
}
|
|
573
573
|
this.emitSystemNote(taskId, "gh: retrying authentication…");
|
|
574
|
+
// Cancel any armed auto-retry chain so this manual attempt runs fresh
|
|
575
|
+
// (setup's duplicate-chain guard would otherwise no-op it).
|
|
576
|
+
clearRefresh(taskId);
|
|
574
577
|
const ok = await setupTaskGithub(taskId, owner);
|
|
575
578
|
this.emitSystemNote(
|
|
576
579
|
taskId,
|
|
@@ -1230,6 +1233,15 @@ export function buildSystemPreamble(
|
|
|
1230
1233
|
(agent.permissions?.includes("todo.write")
|
|
1231
1234
|
? ", `todo list|add|claim|done`"
|
|
1232
1235
|
: "") +
|
|
1236
|
+
(agent.permissions?.includes("previews.write")
|
|
1237
|
+
? ", `preview add <name> <port>`"
|
|
1238
|
+
: "") +
|
|
1239
|
+
(agent.permissions?.includes("projects.write")
|
|
1240
|
+
? ", `project create --name --repo`"
|
|
1241
|
+
: "") +
|
|
1242
|
+
(agent.permissions?.includes("tasks.history")
|
|
1243
|
+
? ", `task search <query>` / `task read <id>`"
|
|
1244
|
+
: "") +
|
|
1233
1245
|
", `react <heart|check|x> [--msg #id]`.",
|
|
1234
1246
|
"**Reacting:** `uai react check` is a lightweight ack of the message",
|
|
1235
1247
|
"you were last handed — use it to acknowledge an instruction or",
|
|
@@ -1271,6 +1283,23 @@ export function buildSystemPreamble(
|
|
|
1271
1283
|
"watches the same list in the task UI.",
|
|
1272
1284
|
]
|
|
1273
1285
|
: []),
|
|
1286
|
+
...(agent.permissions?.includes("tasks.history")
|
|
1287
|
+
? [
|
|
1288
|
+
"**Your task history:** you can recall your OWN past work —",
|
|
1289
|
+
`\`node ${CONTAINER_CLI_PATH} task search <query>\` searches the`,
|
|
1290
|
+
"chat of every past task you were on (not the human's tasks —",
|
|
1291
|
+
"yours), and `task read <taskId>` pulls a full transcript. Use",
|
|
1292
|
+
"it like a broader memory: before reinventing something, check",
|
|
1293
|
+
"whether you've solved it before.",
|
|
1294
|
+
]
|
|
1295
|
+
: []),
|
|
1296
|
+
...(agent.permissions?.includes("previews.write")
|
|
1297
|
+
? [
|
|
1298
|
+
"**Previews:** started a dev server? `preview add <name>",
|
|
1299
|
+
"<containerPort>` exposes it so the human can open it (they",
|
|
1300
|
+
"enable it from the task's preview menu).",
|
|
1301
|
+
]
|
|
1302
|
+
: []),
|
|
1274
1303
|
"",
|
|
1275
1304
|
]
|
|
1276
1305
|
: []),
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { getHostTask } from "../lib/runtime-state";
|
|
|
26
26
|
import { getOrchestrator } from "../lib/orchestrator";
|
|
27
27
|
import {
|
|
28
28
|
connectedUserIds,
|
|
29
|
+
isTransientGithubError,
|
|
29
30
|
onConnectClear,
|
|
30
31
|
onConnectSet,
|
|
31
32
|
onGithubChange,
|
|
@@ -135,14 +136,20 @@ interface PausableSource {
|
|
|
135
136
|
|
|
136
137
|
console.log(`[host-agent] starting host ${hostId}`);
|
|
137
138
|
migrateHostDb();
|
|
138
|
-
//
|
|
139
|
-
//
|
|
139
|
+
// Surface a task's GitHub setup failure in its channel (ADR-027). Classify the
|
|
140
|
+
// reason first: a GitHub-side 5xx / network blip (e.g. `gh auth login`
|
|
141
|
+
// validating the token during a github.com outage) is transient and self-heals
|
|
142
|
+
// on the bounded retry — telling the user their auth "expired" and to reconnect
|
|
143
|
+
// would be wrong and alarming. Only a genuine expiry/revocation asks for a
|
|
144
|
+
// reconnect. Both cases keep retrying in the background regardless.
|
|
140
145
|
setAuthExpiredHandler((taskId, _userId, reason) => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const note = isTransientGithubError(reason)
|
|
147
|
+
? `GitHub is temporarily unavailable — ${reason}. Uai is retrying ` +
|
|
148
|
+
`automatically; no action needed unless this persists (then run ` +
|
|
149
|
+
`/retry-gh).`
|
|
150
|
+
: `gh authentication expired (reason: ${reason}). Reconnect GitHub on ` +
|
|
151
|
+
`Account, then run /retry-gh in this task to restore.`;
|
|
152
|
+
getOrchestrator().emitSystemNote(taskId, note);
|
|
146
153
|
});
|
|
147
154
|
// Best-effort: build the standard image + asdf volume if missing. Logs and
|
|
148
155
|
// continues on failure (e.g. docker unavailable) so the host still boots.
|