codeply-cli 0.4.0 → 0.5.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/lib/agent.mjs +12 -4
- package/lib/bots.js +590 -0
- package/lib/cloud-browser.mjs +143 -0
- package/lib/cloud.mjs +483 -86
- package/lib/native-tools.mjs +2 -1
- package/lib/oauth-connectors.js +51 -9
- package/lib/tools.mjs +40 -12
- package/package.json +1 -1
package/lib/cloud.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export const WORKFLOW_PATH = `.github/workflows/${WORKFLOW_FILE}`;
|
|
|
36
36
|
export const MIRROR_PREFIX = 'craft-workspace-';
|
|
37
37
|
export const MIRROR_DESCRIPTION = 'Craft workspace mirror: a private backup that Craft cloud runs work in.';
|
|
38
38
|
export const SESSIONS_BRANCH = 'craft-sessions';
|
|
39
|
-
export const ENGINE = 'codeply-cli@0.
|
|
39
|
+
export const ENGINE = 'codeply-cli@0.5';
|
|
40
40
|
export const taskBranch = (id) => `craft/task-${id}`;
|
|
41
41
|
export const checkName = (id) => `craft ${id}`;
|
|
42
42
|
|
|
@@ -100,6 +100,9 @@ jobs:
|
|
|
100
100
|
CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
|
|
101
101
|
CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
|
|
102
102
|
CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
|
|
103
|
+
CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
|
|
104
|
+
CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
|
|
105
|
+
CRAFT_BASE: \${{ github.ref_name }}
|
|
103
106
|
CRAFT_PROMPT: \${{ inputs.prompt }}
|
|
104
107
|
CRAFT_MODE: \${{ inputs.mode }}
|
|
105
108
|
CRAFT_TASK_ID: \${{ inputs.task_id }}
|
|
@@ -333,6 +336,8 @@ const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: s
|
|
|
333
336
|
export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
|
|
334
337
|
const p = getProject(cwd, home);
|
|
335
338
|
if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
|
|
339
|
+
// A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
|
|
340
|
+
if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
|
|
336
341
|
const snap = await snapshot({ cwd, home });
|
|
337
342
|
if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
|
|
338
343
|
const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
|
|
@@ -411,28 +416,111 @@ export async function checkToken(api) {
|
|
|
411
416
|
return { ok: true, login: r.json.login };
|
|
412
417
|
}
|
|
413
418
|
|
|
414
|
-
|
|
415
|
-
|
|
419
|
+
// ─── Targets: the project's own GitHub repo, or a private mirror ─────────
|
|
420
|
+
|
|
421
|
+
/** owner/name of the project's GitHub `origin`, or null. */
|
|
422
|
+
export async function githubOrigin(cwd) {
|
|
423
|
+
try {
|
|
424
|
+
const url = await git(cwd, ['remote', 'get-url', 'origin']);
|
|
425
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
|
|
426
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
427
|
+
} catch { return null; }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The repos this login can push to (for the phone's and the app's repo picker). */
|
|
431
|
+
export async function listMyRepos(api) {
|
|
432
|
+
const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
|
|
433
|
+
return (r.json || []).filter((x) => x.permissions && x.permissions.push)
|
|
434
|
+
.map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
|
|
438
|
+
export async function installWorkflowInRepo({ api, repo, base }) {
|
|
439
|
+
const files = branchFiles(api, repo, base);
|
|
440
|
+
const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
441
|
+
const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
|
|
442
|
+
if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
|
|
443
|
+
const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
|
|
444
|
+
if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
|
|
449
|
+
export async function setProjectEnv({ api, repo, envText }) {
|
|
450
|
+
const text = String(envText || '').trim();
|
|
451
|
+
if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
|
|
452
|
+
const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
|
|
453
|
+
await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
|
|
454
|
+
return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* One-click setup. With `target: 'repo'` (default when the project has a GitHub
|
|
459
|
+
* origin) the cloud works in that repo: new branch per task, merged into the
|
|
460
|
+
* default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
|
|
461
|
+
*/
|
|
462
|
+
export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
|
|
416
463
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
417
464
|
const t = await checkToken(api);
|
|
418
465
|
if (!t.ok) throw new Error(t.error);
|
|
419
466
|
const cfg = cloudModelConfig(model);
|
|
420
467
|
if (cfg.error) throw new Error(cfg.error);
|
|
421
|
-
const
|
|
422
|
-
|
|
468
|
+
const origin = target === 'mirror' ? null : await githubOrigin(cwd);
|
|
469
|
+
let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
|
|
470
|
+
if (origin) {
|
|
471
|
+
const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
|
|
472
|
+
if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
|
|
473
|
+
if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
|
|
474
|
+
repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
|
|
475
|
+
updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
|
|
476
|
+
await installWorkflowInRepo({ api, repo, base });
|
|
477
|
+
} else {
|
|
478
|
+
({ repo, created } = await ensureMirror({ api, cwd, home }));
|
|
479
|
+
updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
|
|
480
|
+
skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
|
|
481
|
+
}
|
|
423
482
|
await configureModel({ api, repo, model });
|
|
483
|
+
if (envText != null) await setProjectEnv({ api, repo, envText });
|
|
424
484
|
updateProject(cwd, home, (p) => ({ ...p, modelSig: cfg.sig, modelName: model.name || model.model, enabled: true }));
|
|
425
|
-
return { repo, created, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped
|
|
485
|
+
return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
|
|
426
486
|
}
|
|
427
487
|
|
|
428
488
|
// ─── Runs ────────────────────────────────────────────────────────────────
|
|
429
489
|
|
|
430
490
|
const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
431
491
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
492
|
+
const LIVE_FRESH_MS = 60000;
|
|
493
|
+
|
|
494
|
+
/** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
|
|
495
|
+
export async function liveRunner(api, repo, sessionId) {
|
|
496
|
+
if (!sessionId) return null;
|
|
497
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
|
|
498
|
+
const live = f && f.json;
|
|
499
|
+
if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
|
|
500
|
+
if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
|
|
501
|
+
return live;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function dispatch(api, repo, base, task, retryMs) {
|
|
505
|
+
let lastErr = null;
|
|
506
|
+
// Right after the workflow file first lands, GitHub can take a few seconds to know it.
|
|
507
|
+
for (let i = 0; i < 10; i++) {
|
|
508
|
+
try {
|
|
509
|
+
await api('POST', `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: base || 'main', inputs: { prompt: task.prompt, mode: task.mode, task_id: task.id, session_id: task.sessionId } });
|
|
510
|
+
return;
|
|
511
|
+
} catch (e) {
|
|
512
|
+
lastErr = e;
|
|
513
|
+
if (e.status !== 404 && e.status !== 422) break;
|
|
514
|
+
await sleep(retryMs);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
throw lastErr;
|
|
518
|
+
}
|
|
432
519
|
|
|
433
520
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
521
|
+
* Start a cloud task. A runner already up for this chat gets it through the
|
|
522
|
+
* queue at once; otherwise a new runner is dispatched. Mirror projects push
|
|
523
|
+
* the latest snapshot first.
|
|
436
524
|
*/
|
|
437
525
|
export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
|
|
438
526
|
const p = getProject(cwd, home);
|
|
@@ -442,7 +530,6 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
|
|
|
442
530
|
if (prompt.length > MAX_PROMPT) throw new Error(`That task is too long for a cloud run (${prompt.length} characters, the limit is ${MAX_PROMPT}).`);
|
|
443
531
|
mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
|
|
444
532
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
445
|
-
// A different model (or key) than last time goes to the mirror first.
|
|
446
533
|
if (model) {
|
|
447
534
|
const cfg = cloudModelConfig(model);
|
|
448
535
|
if (cfg.error) throw new Error(cfg.error);
|
|
@@ -451,64 +538,145 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
|
|
|
451
538
|
updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: model.name || model.model }));
|
|
452
539
|
}
|
|
453
540
|
}
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
541
|
+
const base = p.base || 'main';
|
|
542
|
+
let baseSha;
|
|
543
|
+
if (p.kind === 'repo') {
|
|
544
|
+
const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
545
|
+
baseSha = ref.status === 200 ? ref.json.object.sha : null;
|
|
546
|
+
} else {
|
|
547
|
+
baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
|
|
548
|
+
}
|
|
549
|
+
const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
|
|
550
|
+
const live = await liveRunner(api, p.repo, task.sessionId);
|
|
551
|
+
if (live) {
|
|
552
|
+
await branchFiles(api, p.repo).write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode }, `Craft queue ${task.id}`);
|
|
553
|
+
Object.assign(task, { queued: true, enqueuedAt: Date.now() });
|
|
554
|
+
} else {
|
|
555
|
+
await dispatch(api, p.repo, base, task, retryMs);
|
|
468
556
|
}
|
|
469
|
-
if (lastErr) throw lastErr;
|
|
470
557
|
saveTask(cwd, home, task);
|
|
471
558
|
return task;
|
|
472
559
|
}
|
|
473
560
|
|
|
474
|
-
/**
|
|
475
|
-
export async function
|
|
561
|
+
/** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
|
|
562
|
+
export async function readTaskDoc(api, repo, taskId) {
|
|
563
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
|
|
564
|
+
return f && f.json ? f.json : null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
|
|
568
|
+
export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
|
|
476
569
|
const task = getTask(cwd, taskId, home);
|
|
477
570
|
if (!task) throw new Error('Unknown cloud task.');
|
|
478
571
|
if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
|
|
479
572
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
573
|
+
const next = { ...task };
|
|
574
|
+
const doc = await readTaskDoc(api, task.repo, task.id);
|
|
575
|
+
if (doc) {
|
|
576
|
+
const { events = [], status, ...rest } = doc;
|
|
577
|
+
if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
|
|
578
|
+
if (status === 'done' || status === 'failed') {
|
|
579
|
+
const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
|
|
580
|
+
Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
|
|
581
|
+
} else next.status = 'running';
|
|
582
|
+
saveTask(cwd, home, next);
|
|
583
|
+
return { ...next, events };
|
|
584
|
+
}
|
|
585
|
+
if (task.queued) {
|
|
586
|
+
// Handed to a runner that went away before taking it: start a new one.
|
|
587
|
+
if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
|
|
588
|
+
const p = getProject(cwd, home) || {};
|
|
589
|
+
await dispatch(api, task.repo, p.base || 'main', task, retryMs);
|
|
590
|
+
Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
|
|
591
|
+
} else next.status = 'queued';
|
|
592
|
+
saveTask(cwd, home, next);
|
|
593
|
+
return { ...next, events: [] };
|
|
594
|
+
}
|
|
480
595
|
let run = null;
|
|
481
596
|
if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
|
|
482
597
|
else {
|
|
483
598
|
const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
|
|
484
599
|
run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
|
|
485
600
|
}
|
|
486
|
-
const next = { ...task };
|
|
487
601
|
if (!run) {
|
|
488
602
|
// GitHub drops a dispatch it can't start; don't wait forever.
|
|
489
|
-
if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the
|
|
603
|
+
if (Date.now() - task.startedAt > 5 * 60 * 1000) Object.assign(next, { status: 'failed', error: 'GitHub never started the run. Check that Actions is enabled on the repo.' });
|
|
490
604
|
saveTask(cwd, home, next);
|
|
491
|
-
return next;
|
|
605
|
+
return { ...next, events: [] };
|
|
492
606
|
}
|
|
493
607
|
next.runId = run.id;
|
|
494
608
|
next.runUrl = run.html_url;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
let
|
|
500
|
-
try {
|
|
501
|
-
|
|
502
|
-
|
|
609
|
+
if (run.status === 'completed') {
|
|
610
|
+
// Finished without a status file: it died before Craft could report (or an older engine ran).
|
|
611
|
+
const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
|
|
612
|
+
const check = (checks.check_runs || [])[0];
|
|
613
|
+
let data = {};
|
|
614
|
+
try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
|
|
615
|
+
if (check && check.status === 'completed' && data.mode) {
|
|
616
|
+
const { events = [], ...result } = data;
|
|
617
|
+
Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
|
|
618
|
+
saveTask(cwd, home, next);
|
|
619
|
+
return { ...next, events };
|
|
620
|
+
}
|
|
503
621
|
const why = run.conclusion === 'cancelled' ? 'The run was cancelled.' : (await diagnoseRun(api, task.repo, run.id)) || `The run ended (${run.conclusion}) before Craft could report back. The run log has details.`;
|
|
504
622
|
Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
|
|
505
623
|
} else {
|
|
506
624
|
next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
|
|
507
625
|
}
|
|
508
626
|
saveTask(cwd, home, next);
|
|
509
|
-
return next;
|
|
627
|
+
return { ...next, events: [] };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
|
|
631
|
+
export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
|
|
632
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
633
|
+
const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
|
|
634
|
+
if (r.status !== 200 || !r.json || !r.json.content) return null;
|
|
635
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
636
|
+
fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
|
|
637
|
+
return dest;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Before coding: is the local branch behind GitHub (a cloud run merged work
|
|
642
|
+
* into it)? Only for projects whose cloud target is their own repo.
|
|
643
|
+
*/
|
|
644
|
+
export async function checkBehind(cwd) {
|
|
645
|
+
try {
|
|
646
|
+
const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
647
|
+
const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
|
|
648
|
+
if (!upstream) return { ok: false, reason: 'no upstream' };
|
|
649
|
+
await git(cwd, ['fetch', '-q', '--no-tags', upstream.split('/')[0]]);
|
|
650
|
+
const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
|
|
651
|
+
const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
|
|
652
|
+
const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
|
|
653
|
+
return { ok: true, branch, upstream, behind, ahead, dirty, latest };
|
|
654
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
|
|
658
|
+
export async function pullLatest(cwd) {
|
|
659
|
+
const st = await checkBehind(cwd);
|
|
660
|
+
if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
|
|
661
|
+
if (!st.behind) return { pulled: 0 };
|
|
662
|
+
let stashed = false;
|
|
663
|
+
if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
|
|
664
|
+
try {
|
|
665
|
+
await git(cwd, ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit']);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
// A conflicting pull is undone, so the project is never left half-merged.
|
|
668
|
+
const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
|
|
669
|
+
await git(cwd, ['merge', '--abort']).catch(() => {});
|
|
670
|
+
throw new Error(conflicted.length
|
|
671
|
+
? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
|
|
672
|
+
: `Could not pull: ${e.message}`);
|
|
673
|
+
} finally {
|
|
674
|
+
if (stashed) await git(cwd, ['stash', 'pop', '-q']).catch(() => { throw new Error('Pulled, but your own edits clashed with them. They are saved in git stash.'); });
|
|
675
|
+
}
|
|
676
|
+
return { pulled: st.behind };
|
|
510
677
|
}
|
|
511
678
|
|
|
679
|
+
|
|
512
680
|
/**
|
|
513
681
|
* Runs started somewhere else (the phone, while this PC was off) become local
|
|
514
682
|
* tasks, so their changes can be pulled here. Only finished runs are imported.
|
|
@@ -528,6 +696,7 @@ export async function importRemoteTasks({ cwd, token, home = defaultHome(), apiU
|
|
|
528
696
|
if (!check || check.status !== 'completed') continue;
|
|
529
697
|
let result = {};
|
|
530
698
|
try { result = JSON.parse(check.output.text || '{}'); } catch {}
|
|
699
|
+
delete result.events;
|
|
531
700
|
const task = {
|
|
532
701
|
id, prompt: result.prompt || '(started elsewhere)', mode: result.mode || 'Build', sessionId: result.sessionId || '', baseSha: result.baseSha || run.head_sha,
|
|
533
702
|
repo: p.repo, runId: run.id, runUrl: run.html_url, status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null,
|
|
@@ -592,79 +761,286 @@ function renderProgress(lines, note) {
|
|
|
592
761
|
return `${note}\n\n${shown.join('\n')}`.slice(0, MAX_OUTPUT);
|
|
593
762
|
}
|
|
594
763
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
764
|
+
// ─── The run as a chat: every step the agent took, published live ─────────
|
|
765
|
+
// Cloud chats look like local ones (commands, edits with their + and - lines,
|
|
766
|
+
// narration, the "what actually happened" card), so the runner records the
|
|
767
|
+
// same events the desktop gets from a local run and puts them in the check
|
|
768
|
+
// run's text, where the PC and the phone read them while it works.
|
|
769
|
+
|
|
770
|
+
const EVENT_BUDGET = 58000;
|
|
771
|
+
const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n...` : s);
|
|
772
|
+
|
|
773
|
+
/** One agent event as a small record; tool arguments kept the way the chat shows them. */
|
|
774
|
+
export function recordEvent(ev) {
|
|
775
|
+
if (ev.type === 'text') return { t: 'text', text: clipText(ev.text, 8000), interim: !!ev.interim };
|
|
776
|
+
if (ev.type === 'reasoning') return { t: 'reasoning', text: clipText(ev.text, 2000), ms: ev.ms };
|
|
777
|
+
if (ev.type === 'notice') return { t: 'notice', level: ev.level || 'info', text: clipText(ev.text, 1000) };
|
|
778
|
+
if (ev.type === 'tool_end') {
|
|
779
|
+
const a = ev.args || {};
|
|
780
|
+
let args;
|
|
781
|
+
if (ev.name === 'write_file') args = { path: a.path };
|
|
782
|
+
else if (ev.name === 'apply_patch') args = { files: (ev.meta && ev.meta.files) || [] };
|
|
783
|
+
else {
|
|
784
|
+
args = {};
|
|
785
|
+
for (const [k, v] of Object.entries(a)) {
|
|
786
|
+
if (typeof v === 'string') args[k] = clipText(v, k === 'search' || k === 'replace' ? 3000 : 1500);
|
|
787
|
+
else if (typeof v === 'number' || typeof v === 'boolean') args[k] = v;
|
|
788
|
+
else if (Array.isArray(v)) args[k] = v.slice(0, 50);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const m = ev.meta || {};
|
|
792
|
+
return {
|
|
793
|
+
t: 'tool', name: ev.name, ok: ev.ok, summary: clipText(ev.summary || '', 300), args,
|
|
794
|
+
...(typeof m.exitCode === 'number' ? { exitCode: m.exitCode } : {}),
|
|
795
|
+
...(typeof m.added === 'number' ? { added: m.added } : {}),
|
|
796
|
+
...(typeof m.removed === 'number' ? { removed: m.removed } : {}),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
if (ev.type === 'done' && Array.isArray(ev.actions)) {
|
|
800
|
+
const changed = ev.actions.filter((x) => x.ok && ['write_file', 'edit_file', 'apply_patch', 'fetch_image'].includes(x.tool))
|
|
801
|
+
.flatMap((x) => (Array.isArray(x.files) && x.files.length ? x.files.map((f) => ({ ...x, label: f })) : [x]));
|
|
802
|
+
const checks = ev.actions.filter((x) => ['run', 'browser_check'].includes(x.tool));
|
|
803
|
+
if (!changed.length && !checks.length) return null;
|
|
804
|
+
return {
|
|
805
|
+
t: 'summary', files: [...new Set(changed.map((x) => x.label))].slice(0, 30),
|
|
806
|
+
checks: [...new Map(checks.map((x) => [`${x.tool}|${x.label}`, x])).values()].slice(-8).map((x) => ({ tool: x.tool, label: x.label, ok: x.ok, exitCode: x.exitCode })),
|
|
807
|
+
unverified: ev.unverifiedFiles || [],
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
603
811
|
}
|
|
604
812
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
for (
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
if (r.status < 300) return;
|
|
813
|
+
/** Fit the event list in the check run's text: shorten diffs, drop thinking, then the oldest steps. */
|
|
814
|
+
export function packEvents(events, budget = EVENT_BUDGET) {
|
|
815
|
+
let list = events.map((e) => ({ ...e }));
|
|
816
|
+
const size = () => JSON.stringify(list).length;
|
|
817
|
+
if (size() <= budget) return list;
|
|
818
|
+
list = list.map((e) => (e.t === 'reasoning' ? { ...e, text: clipText(e.text, 200) } : e));
|
|
819
|
+
for (const n of [800, 200]) {
|
|
820
|
+
if (size() <= budget) return list;
|
|
821
|
+
list = list.map((e) => (e.t === 'tool' && e.args ? { ...e, args: Object.fromEntries(Object.entries(e.args).map(([k, v]) => [k, typeof v === 'string' ? clipText(v, n) : v])) } : e));
|
|
615
822
|
}
|
|
823
|
+
let dropped = 0;
|
|
824
|
+
while (size() > budget && list.length > 1) { list.shift(); dropped++; }
|
|
825
|
+
if (dropped) list.unshift({ t: 'notice', level: 'info', text: `${dropped} earlier step${dropped === 1 ? '' : 's'} are only in the run log.` });
|
|
826
|
+
return list;
|
|
616
827
|
}
|
|
617
828
|
|
|
829
|
+
// ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
|
|
830
|
+
|
|
831
|
+
export const SESSION_PATHS = {
|
|
832
|
+
history: (sid) => `sessions/${sid}.json`,
|
|
833
|
+
task: (id) => `tasks/${id}.json`,
|
|
834
|
+
live: (sid) => `live/${sid}.json`,
|
|
835
|
+
queue: (sid) => `queue/${sid}`,
|
|
836
|
+
shot: (id, n) => `shots/${id}/${n}.png`,
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
/** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
|
|
840
|
+
export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
|
|
841
|
+
const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
|
|
842
|
+
const read = async (p) => {
|
|
843
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
844
|
+
if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
|
|
845
|
+
let json = null;
|
|
846
|
+
try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
|
|
847
|
+
return { json, sha: r.json.sha, size: r.json.size };
|
|
848
|
+
};
|
|
849
|
+
const list = async (p) => {
|
|
850
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
851
|
+
return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
|
|
852
|
+
};
|
|
853
|
+
const put = async (p, content, message) => {
|
|
854
|
+
for (let i = 0; i < 4; i++) {
|
|
855
|
+
const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
856
|
+
const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
|
|
857
|
+
const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
|
|
858
|
+
if (r.status < 300) return true;
|
|
859
|
+
await sleep(300 + i * 400);
|
|
860
|
+
}
|
|
861
|
+
return false;
|
|
862
|
+
};
|
|
863
|
+
const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
|
|
864
|
+
const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
|
|
865
|
+
const remove = async (p, sha) => {
|
|
866
|
+
for (let i = 0; i < 3; i++) {
|
|
867
|
+
const s = sha || (await read(p) || {}).sha;
|
|
868
|
+
if (!s) return;
|
|
869
|
+
const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
|
|
870
|
+
if (r.status < 300 || r.status === 404) return;
|
|
871
|
+
sha = null;
|
|
872
|
+
await sleep(300);
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
const ensure = async (baseSha) => {
|
|
876
|
+
const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
|
|
877
|
+
if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
|
|
878
|
+
};
|
|
879
|
+
return { read, list, write, writeBytes, remove, ensure };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function loadHistory(files, sessionId) {
|
|
883
|
+
if (!sessionId) return { messages: [] };
|
|
884
|
+
const f = await files.read(SESSION_PATHS.history(sessionId));
|
|
885
|
+
return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function saveHistory(files, sessionId, messages) {
|
|
889
|
+
if (!sessionId) return;
|
|
890
|
+
await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
|
|
894
|
+
export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
|
|
895
|
+
|
|
618
896
|
/**
|
|
619
|
-
* `codeply cloud runner
|
|
620
|
-
*
|
|
897
|
+
* `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
|
|
898
|
+
* (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
|
|
899
|
+
* message in queue/<session>, so a follow-up starts at once instead of waiting
|
|
900
|
+
* for a new runner. Each task:
|
|
901
|
+
* - runs like a local run, with a real browser (screenshots) when Chrome is there
|
|
902
|
+
* - publishes its steps live to tasks/<id>.json (and the first one's check run)
|
|
903
|
+
* - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
|
|
904
|
+
* into the base branch (CRAFT_MERGE=false keeps it as a branch only)
|
|
905
|
+
* @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
|
|
621
906
|
*/
|
|
622
|
-
export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs =
|
|
907
|
+
export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs = 4000, idleMs, pollMs = 3000, browserImpl }) {
|
|
623
908
|
const repo = env.GITHUB_REPOSITORY;
|
|
624
|
-
const
|
|
625
|
-
const mode = ['Build', 'Plan', 'Ask'].includes(env.CRAFT_MODE) ? env.CRAFT_MODE : 'Build';
|
|
626
|
-
const prompt = String(env.CRAFT_PROMPT || '').slice(0, MAX_PROMPT);
|
|
909
|
+
const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
627
910
|
const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
628
911
|
const headSha = env.GITHUB_SHA;
|
|
629
|
-
if (!repo || !token || !
|
|
912
|
+
if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
|
|
630
913
|
const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
|
|
631
914
|
const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
|
|
632
915
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
916
|
+
const files = branchFiles(api, repo);
|
|
917
|
+
const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
|
|
918
|
+
const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
|
|
919
|
+
const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
|
|
920
|
+
await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
|
|
633
921
|
|
|
634
|
-
|
|
922
|
+
// The project's environment for running and testing: never committed.
|
|
923
|
+
const envFile = path.join(cwd, '.env');
|
|
924
|
+
if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
|
|
925
|
+
try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
|
|
926
|
+
}
|
|
927
|
+
const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
|
|
928
|
+
|
|
929
|
+
const heartbeat = (busy, until) => files.write(SESSION_PATHS.live(sessionId), { runId: env.GITHUB_RUN_ID || null, busy, at: Date.now(), until }, `Craft live ${sessionId}`).catch(() => {});
|
|
930
|
+
|
|
931
|
+
let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
|
|
932
|
+
const results = [];
|
|
933
|
+
try {
|
|
934
|
+
while (task) {
|
|
935
|
+
if (sessionId) await heartbeat(true, Date.now() + 60 * 60 * 1000);
|
|
936
|
+
results.push(await runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat: sessionId ? () => heartbeat(true, Date.now() + 60 * 60 * 1000) : null }));
|
|
937
|
+
if (!sessionId || idle <= 0 || setupError) break;
|
|
938
|
+
task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
|
|
939
|
+
}
|
|
940
|
+
} finally {
|
|
941
|
+
if (sessionId) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
|
|
942
|
+
if (browser && browser.close) await browser.close().catch(() => {});
|
|
943
|
+
}
|
|
944
|
+
const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
|
|
945
|
+
return { ...last, results };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
|
|
949
|
+
async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
|
|
950
|
+
const deadline = Date.now() + idle;
|
|
951
|
+
let beat = 0;
|
|
952
|
+
while (Date.now() < deadline) {
|
|
953
|
+
if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
|
|
954
|
+
const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
955
|
+
for (const it of items) {
|
|
956
|
+
const f = await files.read(it.path).catch(() => null);
|
|
957
|
+
await files.remove(it.path, f && f.sha).catch(() => {});
|
|
958
|
+
const t = f && f.json;
|
|
959
|
+
if (!t || !t.id) continue;
|
|
960
|
+
// Picked up by another runner already (it was re-dispatched): skip.
|
|
961
|
+
if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
|
|
962
|
+
return { ...t, sessionId, dispatched: false };
|
|
963
|
+
}
|
|
964
|
+
await sleep(pollMs);
|
|
965
|
+
}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat }) {
|
|
970
|
+
const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
|
|
971
|
+
const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
|
|
972
|
+
const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
|
|
973
|
+
const sessionId = task.sessionId || '';
|
|
974
|
+
const startedAt = Date.now();
|
|
975
|
+
const events = [];
|
|
976
|
+
const lines = [];
|
|
977
|
+
|
|
978
|
+
// The dispatched task also gets a check run: it is how a failed start is diagnosed.
|
|
979
|
+
const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
|
|
635
980
|
name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
|
|
636
981
|
output: { title: 'Starting', summary: 'Starting...' },
|
|
637
|
-
})).json;
|
|
982
|
+
}).catch(() => ({ json: null }))).json : null;
|
|
983
|
+
|
|
984
|
+
const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
|
|
638
985
|
const finish = async (ok, title, summary, result) => {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
986
|
+
const baseLen = JSON.stringify(result).length;
|
|
987
|
+
const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
|
|
988
|
+
await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
|
|
989
|
+
if (check) {
|
|
990
|
+
await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
|
|
991
|
+
status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
|
|
992
|
+
output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
|
|
993
|
+
}).catch((e) => log(`Could not finish the check run: ${e.message}`));
|
|
994
|
+
}
|
|
995
|
+
return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
|
|
644
996
|
};
|
|
645
997
|
|
|
646
998
|
if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
|
|
647
999
|
if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
|
|
648
1000
|
|
|
649
|
-
const lines = [];
|
|
650
1001
|
let lastPush = 0; let pending = null;
|
|
651
1002
|
const pushProgress = async (note, force = false) => {
|
|
652
1003
|
if (!force && Date.now() - lastPush < updateMs) return;
|
|
653
1004
|
lastPush = Date.now();
|
|
654
|
-
|
|
1005
|
+
const packed = packEvents(events);
|
|
1006
|
+
pending = Promise.all([
|
|
1007
|
+
files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
|
|
1008
|
+
check ? api('PATCH', `/repos/${repo}/check-runs/${check.id}`, { output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packed }) } }).catch(() => {}) : null,
|
|
1009
|
+
heartbeat ? heartbeat() : null,
|
|
1010
|
+
]);
|
|
655
1011
|
await pending;
|
|
656
1012
|
};
|
|
657
1013
|
|
|
1014
|
+
// Screenshots go next to the status, where the PC and the phone read them.
|
|
1015
|
+
let shots = 0; let pendingShot = null;
|
|
1016
|
+
const agentBrowser = browser ? async (url, opts) => {
|
|
1017
|
+
const r = await browser(url, opts);
|
|
1018
|
+
if (r && r.ok && r.screenshotPath) {
|
|
1019
|
+
try {
|
|
1020
|
+
const rel = SESSION_PATHS.shot(taskId, ++shots);
|
|
1021
|
+
if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
|
|
1022
|
+
} catch {}
|
|
1023
|
+
}
|
|
1024
|
+
return r;
|
|
1025
|
+
} : undefined;
|
|
1026
|
+
|
|
658
1027
|
try {
|
|
659
|
-
|
|
1028
|
+
// Where this task starts (a later message in the same runner starts after the earlier merge).
|
|
1029
|
+
const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
|
|
1030
|
+
const hist = await loadHistory(files, sessionId);
|
|
660
1031
|
await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
|
|
661
1032
|
const approve = ciApprove(cwd);
|
|
662
1033
|
let answer = ''; let steps = 0; let failure = '';
|
|
663
|
-
const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud,
|
|
664
|
-
for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, signal: new AbortController().signal, route, maxSteps })) {
|
|
1034
|
+
const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud, in a fresh copy of the repository on a Linux machine. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. You can install dependencies, run the app and tests, and check pages with browser_check. Do not commit or push; Craft does that when you finish.)`;
|
|
1035
|
+
for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps })) {
|
|
1036
|
+
const rec = recordEvent(ev);
|
|
1037
|
+
if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
|
|
1038
|
+
// The final answer arrives as the reply; everything else is part of the chat's steps.
|
|
1039
|
+
if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
|
|
665
1040
|
if (ev.type === 'text' && !ev.interim) answer = ev.text;
|
|
666
|
-
else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev));
|
|
1041
|
+
else if (ev.type === 'tool_end') { steps++; lines.push(describeTool(ev)); }
|
|
667
1042
|
else if (ev.type === 'error') failure = ev.error;
|
|
1043
|
+
if (rec) await pushProgress(`Working: ${steps} step${steps === 1 ? '' : 's'} so far.`);
|
|
668
1044
|
if (ev.type === 'done' || ev.type === 'error' || ev.type === 'aborted') break;
|
|
669
1045
|
}
|
|
670
1046
|
if (pending) await pending;
|
|
@@ -672,27 +1048,48 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
|
|
|
672
1048
|
|
|
673
1049
|
if (mode === 'Plan') {
|
|
674
1050
|
const dir = path.join(cwd, '.codeply', 'plans');
|
|
675
|
-
const
|
|
676
|
-
if (
|
|
1051
|
+
const plans = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => b.t - a.t) : [];
|
|
1052
|
+
if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
|
|
677
1053
|
}
|
|
678
1054
|
|
|
679
|
-
let branch = null; let sha = null; let
|
|
1055
|
+
let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
|
|
680
1056
|
if (mode === 'Build') {
|
|
681
|
-
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}
|
|
1057
|
+
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
|
|
682
1058
|
const staged = await git(cwd, ['diff', '--cached', '--name-only']);
|
|
683
1059
|
if (staged) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
await git(cwd, [
|
|
1060
|
+
changed = staged.split('\n').filter(Boolean);
|
|
1061
|
+
// Per-file + and - lines, for the chat's record of what was pushed.
|
|
1062
|
+
stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
|
|
1063
|
+
const [add, del, ...name] = l.split('\t');
|
|
1064
|
+
return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
|
|
1065
|
+
});
|
|
1066
|
+
branch = cloudBranchName(prompt, taskId);
|
|
1067
|
+
const subject = prompt.split('\n')[0].slice(0, 60);
|
|
1068
|
+
await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
|
|
687
1069
|
sha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
688
1070
|
await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
|
|
1071
|
+
// Into the base branch too, so the next run (and the PC's pull) starts from it.
|
|
1072
|
+
if (merge) {
|
|
1073
|
+
const m = await api('POST', `/repos/${repo}/merges`, { base, head: branch, commit_message: `Merge ${branch}: ${subject}` }, { allow: [204, 404, 409] }).catch((e) => ({ status: 0, error: e.message }));
|
|
1074
|
+
merged = m.status === 201 || m.status === 204
|
|
1075
|
+
? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
|
|
1076
|
+
: { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
|
|
1077
|
+
if (merged.ok) {
|
|
1078
|
+
// Keep working from the merged base for the next message in this chat.
|
|
1079
|
+
await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
|
|
1080
|
+
await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
|
|
1084
|
+
const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
|
|
1085
|
+
events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
|
|
689
1086
|
}
|
|
690
1087
|
}
|
|
691
1088
|
|
|
692
|
-
await saveHistory(
|
|
1089
|
+
await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
|
|
693
1090
|
.catch((e) => log(`Could not save the chat: ${e.message}`));
|
|
694
|
-
const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0,
|
|
695
|
-
const title =
|
|
1091
|
+
const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files: changed, stats: stats.slice(0, 100), merged, steps, baseSha: taskBase };
|
|
1092
|
+
const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
|
|
696
1093
|
return finish(true, title, answer || title, result);
|
|
697
1094
|
} catch (e) {
|
|
698
1095
|
return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });
|