codeply-cli 0.4.1 → 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 +404 -95
- 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,68 +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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
}
|
|
507
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.`;
|
|
508
622
|
Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
|
|
509
623
|
} else {
|
|
510
624
|
next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
|
|
511
625
|
}
|
|
512
626
|
saveTask(cwd, home, next);
|
|
513
|
-
return { ...next, events };
|
|
627
|
+
return { ...next, events: [] };
|
|
514
628
|
}
|
|
515
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 };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
516
680
|
/**
|
|
517
681
|
* Runs started somewhere else (the phone, while this PC was off) become local
|
|
518
682
|
* tasks, so their changes can be pulled here. Only finished runs are imported.
|
|
@@ -662,83 +826,215 @@ export function packEvents(events, budget = EVENT_BUDGET) {
|
|
|
662
826
|
return list;
|
|
663
827
|
}
|
|
664
828
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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 : [] };
|
|
673
886
|
}
|
|
674
887
|
|
|
675
|
-
async function saveHistory(
|
|
888
|
+
async function saveHistory(files, sessionId, messages) {
|
|
676
889
|
if (!sessionId) return;
|
|
677
|
-
|
|
678
|
-
if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${SESSIONS_BRANCH}`, sha: baseSha }, { allow: [422] });
|
|
679
|
-
const file = `sessions/${encodeURIComponent(sessionId)}.json`;
|
|
680
|
-
const body = (sha) => ({ message: `Chat ${sessionId}`, branch: SESSIONS_BRANCH, content: Buffer.from(JSON.stringify({ sessionId, updatedAt: new Date().toISOString(), messages }, null, 1)).toString('base64'), ...(sha ? { sha } : {}) });
|
|
681
|
-
for (let i = 0; i < 3; i++) {
|
|
682
|
-
const cur = await api('GET', `/repos/${repo}/contents/${file}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
|
|
683
|
-
const r = await api('PUT', `/repos/${repo}/contents/${file}`, body(cur.status === 200 ? cur.json.sha : null), { allow: [409, 422] });
|
|
684
|
-
if (r.status < 300) return;
|
|
685
|
-
}
|
|
890
|
+
await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
|
|
686
891
|
}
|
|
687
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
|
+
|
|
688
896
|
/**
|
|
689
|
-
* `codeply cloud runner
|
|
690
|
-
*
|
|
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[]}>}
|
|
691
906
|
*/
|
|
692
|
-
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 }) {
|
|
693
908
|
const repo = env.GITHUB_REPOSITORY;
|
|
694
|
-
const
|
|
695
|
-
const mode = ['Build', 'Plan', 'Ask'].includes(env.CRAFT_MODE) ? env.CRAFT_MODE : 'Build';
|
|
696
|
-
const prompt = String(env.CRAFT_PROMPT || '').slice(0, MAX_PROMPT);
|
|
909
|
+
const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
697
910
|
const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
698
911
|
const headSha = env.GITHUB_SHA;
|
|
699
|
-
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: [] };
|
|
700
913
|
const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
|
|
701
914
|
const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
|
|
702
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}`));
|
|
921
|
+
|
|
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
|
+
}
|
|
703
968
|
|
|
704
|
-
|
|
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`, {
|
|
705
980
|
name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
|
|
706
981
|
output: { title: 'Starting', summary: 'Starting...' },
|
|
707
|
-
})).json;
|
|
708
|
-
|
|
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 });
|
|
709
985
|
const finish = async (ok, title, summary, result) => {
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
+
}
|
|
717
995
|
return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
|
|
718
996
|
};
|
|
719
997
|
|
|
720
998
|
if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
|
|
721
999
|
if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
|
|
722
1000
|
|
|
723
|
-
const lines = [];
|
|
724
1001
|
let lastPush = 0; let pending = null;
|
|
725
1002
|
const pushProgress = async (note, force = false) => {
|
|
726
1003
|
if (!force && Date.now() - lastPush < updateMs) return;
|
|
727
1004
|
lastPush = Date.now();
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
]);
|
|
731
1011
|
await pending;
|
|
732
1012
|
};
|
|
733
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
|
+
|
|
734
1027
|
try {
|
|
735
|
-
|
|
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);
|
|
736
1031
|
await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
|
|
737
1032
|
const approve = ciApprove(cwd);
|
|
738
1033
|
let answer = ''; let steps = 0; let failure = '';
|
|
739
|
-
const userMessage = `${prompt}\n\n(You are running unattended in Craft Cloud,
|
|
740
|
-
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 })) {
|
|
741
1036
|
const rec = recordEvent(ev);
|
|
1037
|
+
if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
|
|
742
1038
|
// The final answer arrives as the reply; everything else is part of the chat's steps.
|
|
743
1039
|
if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
|
|
744
1040
|
if (ev.type === 'text' && !ev.interim) answer = ev.text;
|
|
@@ -752,35 +1048,48 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
|
|
|
752
1048
|
|
|
753
1049
|
if (mode === 'Plan') {
|
|
754
1050
|
const dir = path.join(cwd, '.codeply', 'plans');
|
|
755
|
-
const
|
|
756
|
-
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');
|
|
757
1053
|
}
|
|
758
1054
|
|
|
759
|
-
let branch = null; let sha = null; let
|
|
1055
|
+
let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
|
|
760
1056
|
if (mode === 'Build') {
|
|
761
|
-
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}
|
|
1057
|
+
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
|
|
762
1058
|
const staged = await git(cwd, ['diff', '--cached', '--name-only']);
|
|
763
1059
|
if (staged) {
|
|
764
|
-
|
|
1060
|
+
changed = staged.split('\n').filter(Boolean);
|
|
765
1061
|
// Per-file + and - lines, for the chat's record of what was pushed.
|
|
766
1062
|
stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
|
|
767
1063
|
const [add, del, ...name] = l.split('\t');
|
|
768
1064
|
return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
|
|
769
1065
|
});
|
|
770
|
-
branch =
|
|
771
|
-
|
|
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}.`]);
|
|
772
1069
|
sha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
773
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
|
+
}
|
|
774
1083
|
const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
|
|
775
1084
|
const minus = stats.reduce((n, s) => n + (s.removed || 0), 0);
|
|
776
|
-
events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus });
|
|
1085
|
+
events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
|
|
777
1086
|
}
|
|
778
1087
|
}
|
|
779
1088
|
|
|
780
|
-
await saveHistory(
|
|
1089
|
+
await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
|
|
781
1090
|
.catch((e) => log(`Could not save the chat: ${e.message}`));
|
|
782
|
-
const result = { mode, prompt: prompt.slice(0, 4000), sessionId, answer: answer.slice(0, 20000), branch, sha, files, stats: stats.slice(0, 100), steps, baseSha:
|
|
783
|
-
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';
|
|
784
1093
|
return finish(true, title, answer || title, result);
|
|
785
1094
|
} catch (e) {
|
|
786
1095
|
return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });
|