codeply-cli 0.4.1 → 0.5.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.mjs +12 -4
- package/lib/bots.js +1002 -0
- package/lib/cloud-browser.mjs +143 -0
- package/lib/cloud.mjs +443 -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,13 +36,14 @@ 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
|
|
|
43
43
|
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
44
44
|
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
45
45
|
const MAX_PROMPT = 20000;
|
|
46
|
+
const MAX_BOT_PROMPT = 30000;
|
|
46
47
|
const MAX_OUTPUT = 60000;
|
|
47
48
|
const HISTORY_TURNS = 40;
|
|
48
49
|
|
|
@@ -100,6 +101,9 @@ jobs:
|
|
|
100
101
|
CODEPLY_MODEL: \${{ vars.CODEPLY_MODEL }}
|
|
101
102
|
CODEPLY_BASE_URL: \${{ vars.CODEPLY_BASE_URL }}
|
|
102
103
|
CRAFT_ENGINE: \${{ vars.CRAFT_ENGINE }}
|
|
104
|
+
CRAFT_ENV: \${{ secrets.CRAFT_ENV }}
|
|
105
|
+
CRAFT_MERGE: \${{ vars.CRAFT_MERGE }}
|
|
106
|
+
CRAFT_BASE: \${{ github.ref_name }}
|
|
103
107
|
CRAFT_PROMPT: \${{ inputs.prompt }}
|
|
104
108
|
CRAFT_MODE: \${{ inputs.mode }}
|
|
105
109
|
CRAFT_TASK_ID: \${{ inputs.task_id }}
|
|
@@ -333,6 +337,8 @@ const authFor = (token, serverUrl = 'https://github.com') => ({ token, server: s
|
|
|
333
337
|
export async function pushSnapshot({ cwd, token, home = defaultHome(), serverUrl = 'https://github.com', remoteUrl }) {
|
|
334
338
|
const p = getProject(cwd, home);
|
|
335
339
|
if (!p || !p.repo) throw new Error('Cloud is not set up for this project yet.');
|
|
340
|
+
// A project that works in its own GitHub repo is never snapshotted: that would overwrite its real main.
|
|
341
|
+
if (p.kind === 'repo') throw new Error(`${p.repo} is this project's own repo; Craft does not back up over it.`);
|
|
336
342
|
const snap = await snapshot({ cwd, home });
|
|
337
343
|
if (snap.changed || !p.lastPush || p.lastPush.sha !== snap.sha) {
|
|
338
344
|
const url = remoteUrl || `${serverUrl.replace(/\/$/, '')}/${p.repo}.git`;
|
|
@@ -411,30 +417,113 @@ export async function checkToken(api) {
|
|
|
411
417
|
return { ok: true, login: r.json.login };
|
|
412
418
|
}
|
|
413
419
|
|
|
414
|
-
|
|
415
|
-
|
|
420
|
+
// ─── Targets: the project's own GitHub repo, or a private mirror ─────────
|
|
421
|
+
|
|
422
|
+
/** owner/name of the project's GitHub `origin`, or null. */
|
|
423
|
+
export async function githubOrigin(cwd) {
|
|
424
|
+
try {
|
|
425
|
+
const url = await git(cwd, ['remote', 'get-url', 'origin']);
|
|
426
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url);
|
|
427
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
428
|
+
} catch { return null; }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The repos this login can push to (for the phone's and the app's repo picker). */
|
|
432
|
+
export async function listMyRepos(api) {
|
|
433
|
+
const r = await api('GET', '/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100');
|
|
434
|
+
return (r.json || []).filter((x) => x.permissions && x.permissions.push)
|
|
435
|
+
.map((x) => ({ repo: x.full_name, private: x.private, base: x.default_branch, mirror: x.description === MIRROR_DESCRIPTION, pushedAt: x.pushed_at }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Commit craft-cloud.yml to the repo's default branch (only when missing or out of date). */
|
|
439
|
+
export async function installWorkflowInRepo({ api, repo, base }) {
|
|
440
|
+
const files = branchFiles(api, repo, base);
|
|
441
|
+
const cur = await api('GET', `/repos/${repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
442
|
+
const want = Buffer.from(CLOUD_WORKFLOW).toString('base64');
|
|
443
|
+
if (cur.status === 200 && String(cur.json.content || '').replace(/\s/g, '') === want) return false;
|
|
444
|
+
const ok = await files.writeBytes(WORKFLOW_PATH, Buffer.from(CLOUD_WORKFLOW), 'Add Craft Cloud workflow');
|
|
445
|
+
if (!ok) throw new Error(`Could not add ${WORKFLOW_PATH} to ${repo}. GitHub needs the workflow permission for that.`);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The project's environment for running and testing (KEY=value lines), as the CRAFT_ENV secret. */
|
|
450
|
+
export async function setProjectEnv({ api, repo, envText }) {
|
|
451
|
+
const text = String(envText || '').trim();
|
|
452
|
+
if (!text) { await api('DELETE', `/repos/${repo}/actions/secrets/CRAFT_ENV`, null, { allow: [404] }); return 0; }
|
|
453
|
+
const pk = (await api('GET', `/repos/${repo}/actions/secrets/public-key`)).json;
|
|
454
|
+
await api('PUT', `/repos/${repo}/actions/secrets/CRAFT_ENV`, { encrypted_value: sealSecret(pk.key, text), key_id: pk.key_id });
|
|
455
|
+
return text.split('\n').filter((l) => /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/.test(l)).length;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* One-click setup. With `target: 'repo'` (default when the project has a GitHub
|
|
460
|
+
* origin) the cloud works in that repo: new branch per task, merged into the
|
|
461
|
+
* default branch, and the PC pulls. Otherwise a private mirror holds snapshots.
|
|
462
|
+
*/
|
|
463
|
+
export async function setupCloud({ cwd, token, model, target, envText, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
|
|
416
464
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
417
465
|
const t = await checkToken(api);
|
|
418
466
|
if (!t.ok) throw new Error(t.error);
|
|
419
467
|
const cfg = cloudModelConfig(model);
|
|
420
468
|
if (cfg.error) throw new Error(cfg.error);
|
|
421
|
-
const
|
|
422
|
-
|
|
469
|
+
const origin = target === 'mirror' ? null : await githubOrigin(cwd);
|
|
470
|
+
let repo; let created = false; let skipped = []; let base = 'main'; let kind = 'mirror';
|
|
471
|
+
if (origin) {
|
|
472
|
+
const r = await api('GET', `/repos/${origin}`, null, { allow: [404] });
|
|
473
|
+
if (r.status !== 200) throw new Error(`Craft can't open ${origin} with this GitHub login.`);
|
|
474
|
+
if (!r.json.permissions || !r.json.permissions.push) throw new Error(`This GitHub login can't push to ${origin}.`);
|
|
475
|
+
repo = origin; base = r.json.default_branch || 'main'; kind = 'repo';
|
|
476
|
+
updateProject(cwd, home, (p) => ({ ...p, repo, base, kind }));
|
|
477
|
+
await installWorkflowInRepo({ api, repo, base });
|
|
478
|
+
} else {
|
|
479
|
+
({ repo, created } = await ensureMirror({ api, cwd, home }));
|
|
480
|
+
updateProject(cwd, home, (p) => ({ ...p, base: 'main', kind: 'mirror' }));
|
|
481
|
+
skipped = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).skipped;
|
|
482
|
+
}
|
|
423
483
|
await configureModel({ api, repo, model });
|
|
484
|
+
if (envText != null) await setProjectEnv({ api, repo, envText });
|
|
424
485
|
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
|
|
486
|
+
return { repo, created, kind, base, url: `${(serverUrl || 'https://github.com').replace(/\/$/, '')}/${repo}`, skipped };
|
|
426
487
|
}
|
|
427
488
|
|
|
428
489
|
// ─── Runs ────────────────────────────────────────────────────────────────
|
|
429
490
|
|
|
430
491
|
const newTaskId = () => `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
431
492
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
493
|
+
const LIVE_FRESH_MS = 60000;
|
|
494
|
+
|
|
495
|
+
/** Is a runner up for this chat right now (idle and waiting, or busy and will get to it)? */
|
|
496
|
+
export async function liveRunner(api, repo, sessionId) {
|
|
497
|
+
if (!sessionId) return null;
|
|
498
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.live(sessionId)).catch(() => null);
|
|
499
|
+
const live = f && f.json;
|
|
500
|
+
if (!live || Date.now() - (live.at || 0) > LIVE_FRESH_MS) return null;
|
|
501
|
+
if (!live.busy && (live.until || 0) < Date.now() + 8000) return null;
|
|
502
|
+
return live;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function dispatch(api, repo, base, task, retryMs) {
|
|
506
|
+
let lastErr = null;
|
|
507
|
+
// Right after the workflow file first lands, GitHub can take a few seconds to know it.
|
|
508
|
+
for (let i = 0; i < 10; i++) {
|
|
509
|
+
try {
|
|
510
|
+
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 } });
|
|
511
|
+
return;
|
|
512
|
+
} catch (e) {
|
|
513
|
+
lastErr = e;
|
|
514
|
+
if (e.status !== 404 && e.status !== 422) break;
|
|
515
|
+
await sleep(retryMs);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
throw lastErr;
|
|
519
|
+
}
|
|
432
520
|
|
|
433
521
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
522
|
+
* Start a cloud task. A runner already up for this chat gets it through the
|
|
523
|
+
* queue at once; otherwise a new runner is dispatched. Mirror projects push
|
|
524
|
+
* the latest snapshot first.
|
|
436
525
|
*/
|
|
437
|
-
export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
|
|
526
|
+
export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessionId = '', bot = null, kind = 'code', model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl, retryMs = 3000, startedAt }) {
|
|
438
527
|
const p = getProject(cwd, home);
|
|
439
528
|
if (!p || !p.repo) throw new Error('Set up cloud runs for this project first.');
|
|
440
529
|
prompt = String(prompt || '').trim();
|
|
@@ -442,7 +531,6 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
|
|
|
442
531
|
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
532
|
mode = ['Build', 'Plan', 'Ask'].includes(mode) ? mode : 'Build';
|
|
444
533
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
445
|
-
// A different model (or key) than last time goes to the mirror first.
|
|
446
534
|
if (model) {
|
|
447
535
|
const cfg = cloudModelConfig(model);
|
|
448
536
|
if (cfg.error) throw new Error(cfg.error);
|
|
@@ -451,68 +539,155 @@ export async function startCloudRun({ cwd, token, prompt, mode = 'Build', sessio
|
|
|
451
539
|
updateProject(cwd, home, (q) => ({ ...q, modelSig: cfg.sig, modelName: model.name || model.model }));
|
|
452
540
|
}
|
|
453
541
|
}
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
542
|
+
const base = p.base || 'main';
|
|
543
|
+
let baseSha;
|
|
544
|
+
if (p.kind === 'repo') {
|
|
545
|
+
const ref = await api('GET', `/repos/${p.repo}/git/ref/heads/${encodeURIComponent(base)}`, null, { allow: [404] });
|
|
546
|
+
baseSha = ref.status === 200 ? ref.json.object.sha : null;
|
|
547
|
+
} else {
|
|
548
|
+
baseSha = (await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl })).sha;
|
|
549
|
+
}
|
|
550
|
+
const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
|
|
551
|
+
const b = cleanBot(bot);
|
|
552
|
+
const extra = { ...(b ? { bot: b } : {}), ...(cleanKind(kind) === 'task' ? { kind: 'task' } : {}) };
|
|
553
|
+
if (b) task.bot = { name: b.name };
|
|
554
|
+
if (extra.kind) task.kind = 'task';
|
|
555
|
+
const files = branchFiles(api, p.repo);
|
|
556
|
+
const live = await liveRunner(api, p.repo, task.sessionId);
|
|
557
|
+
if (live) {
|
|
558
|
+
await files.write(`${SESSION_PATHS.queue(task.sessionId)}/${Date.now()}-${task.id}.json`, { id: task.id, prompt, mode, ...extra }, `Craft queue ${task.id}`);
|
|
559
|
+
Object.assign(task, { queued: true, enqueuedAt: Date.now() });
|
|
560
|
+
} else {
|
|
561
|
+
// A bot's prompt is too big for a workflow input: the runner reads it from craft-sessions.
|
|
562
|
+
if (Object.keys(extra).length) {
|
|
563
|
+
if (baseSha) await files.ensure(baseSha).catch(() => {});
|
|
564
|
+
if (!(await files.write(SESSION_PATHS.request(task.id), extra, `Craft request ${task.id}`))) throw new Error('Could not hand the bot to the cloud. Try again.');
|
|
467
565
|
}
|
|
566
|
+
await dispatch(api, p.repo, base, task, retryMs);
|
|
468
567
|
}
|
|
469
|
-
if (lastErr) throw lastErr;
|
|
470
568
|
saveTask(cwd, home, task);
|
|
471
569
|
return task;
|
|
472
570
|
}
|
|
473
571
|
|
|
474
|
-
/**
|
|
475
|
-
export async function
|
|
572
|
+
/** A task's live status document (tasks/<id>.json), or null before the runner has written it. */
|
|
573
|
+
export async function readTaskDoc(api, repo, taskId) {
|
|
574
|
+
const f = await branchFiles(api, repo).read(SESSION_PATHS.task(taskId)).catch(() => null);
|
|
575
|
+
return f && f.json ? f.json : null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Where a cloud task is: its status file first, then the workflow run (for starts and failed starts). */
|
|
579
|
+
export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl, retryMs = 3000 }) {
|
|
476
580
|
const task = getTask(cwd, taskId, home);
|
|
477
581
|
if (!task) throw new Error('Unknown cloud task.');
|
|
478
582
|
if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') return task;
|
|
479
583
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
584
|
+
const next = { ...task };
|
|
585
|
+
const doc = await readTaskDoc(api, task.repo, task.id);
|
|
586
|
+
if (doc) {
|
|
587
|
+
const { events = [], status, ...rest } = doc;
|
|
588
|
+
if (doc.runId) { next.runId = doc.runId; next.runUrl = `https://github.com/${task.repo}/actions/runs/${doc.runId}`; }
|
|
589
|
+
if (status === 'done' || status === 'failed') {
|
|
590
|
+
const { id: _i, startedAt: _s, updatedAt: _u, runId: _r, ...result } = rest;
|
|
591
|
+
Object.assign(next, { status, result, error: result.error || null, finishedAt: Date.now(), queued: false });
|
|
592
|
+
} else next.status = 'running';
|
|
593
|
+
saveTask(cwd, home, next);
|
|
594
|
+
return { ...next, events };
|
|
595
|
+
}
|
|
596
|
+
if (task.queued) {
|
|
597
|
+
// Handed to a runner that went away before taking it: start a new one.
|
|
598
|
+
if (Date.now() - task.enqueuedAt > 30000 && !(await liveRunner(api, task.repo, task.sessionId))) {
|
|
599
|
+
const p = getProject(cwd, home) || {};
|
|
600
|
+
await dispatch(api, task.repo, p.base || 'main', task, retryMs);
|
|
601
|
+
Object.assign(next, { queued: false, status: 'starting', redispatchedAt: Date.now() });
|
|
602
|
+
} else next.status = 'queued';
|
|
603
|
+
saveTask(cwd, home, next);
|
|
604
|
+
return { ...next, events: [] };
|
|
605
|
+
}
|
|
480
606
|
let run = null;
|
|
481
607
|
if (task.runId) run = (await api('GET', `/repos/${task.repo}/actions/runs/${task.runId}`)).json;
|
|
482
608
|
else {
|
|
483
609
|
const list = (await api('GET', `/repos/${task.repo}/actions/workflows/${WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=30`)).json;
|
|
484
610
|
run = (list.workflow_runs || []).find((r) => r.display_title === checkName(task.id)) || null;
|
|
485
611
|
}
|
|
486
|
-
const next = { ...task };
|
|
487
612
|
if (!run) {
|
|
488
613
|
// 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
|
|
614
|
+
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
615
|
saveTask(cwd, home, next);
|
|
491
|
-
return next;
|
|
616
|
+
return { ...next, events: [] };
|
|
492
617
|
}
|
|
493
618
|
next.runId = run.id;
|
|
494
619
|
next.runUrl = run.html_url;
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
620
|
+
if (run.status === 'completed') {
|
|
621
|
+
// Finished without a status file: it died before Craft could report (or an older engine ran).
|
|
622
|
+
const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
|
|
623
|
+
const check = (checks.check_runs || [])[0];
|
|
624
|
+
let data = {};
|
|
625
|
+
try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
|
|
626
|
+
if (check && check.status === 'completed' && data.mode) {
|
|
627
|
+
const { events = [], ...result } = data;
|
|
628
|
+
Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
|
|
629
|
+
saveTask(cwd, home, next);
|
|
630
|
+
return { ...next, events };
|
|
631
|
+
}
|
|
507
632
|
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
633
|
Object.assign(next, { status: run.conclusion === 'cancelled' ? 'cancelled' : 'failed', error: why, finishedAt: Date.now() });
|
|
509
634
|
} else {
|
|
510
635
|
next.status = run.status === 'queued' || run.status === 'waiting' || run.status === 'pending' ? 'queued' : 'running';
|
|
511
636
|
}
|
|
512
637
|
saveTask(cwd, home, next);
|
|
513
|
-
return { ...next, events };
|
|
638
|
+
return { ...next, events: [] };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Download a cloud screenshot (shots/<task>/<n>.png on craft-sessions) to `dest`. */
|
|
642
|
+
export async function downloadShot({ token, repo, path: rel, dest, apiUrl, fetchImpl }) {
|
|
643
|
+
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
644
|
+
const r = await api('GET', `/repos/${repo}/contents/${rel.split('/').map(encodeURIComponent).join('/')}?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
|
|
645
|
+
if (r.status !== 200 || !r.json || !r.json.content) return null;
|
|
646
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
647
|
+
fs.writeFileSync(dest, Buffer.from(r.json.content, 'base64'));
|
|
648
|
+
return dest;
|
|
514
649
|
}
|
|
515
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Before coding: is the local branch behind GitHub (a cloud run merged work
|
|
653
|
+
* into it)? Only for projects whose cloud target is their own repo.
|
|
654
|
+
*/
|
|
655
|
+
export async function checkBehind(cwd) {
|
|
656
|
+
try {
|
|
657
|
+
const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
658
|
+
const upstream = await git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => null);
|
|
659
|
+
if (!upstream) return { ok: false, reason: 'no upstream' };
|
|
660
|
+
await git(cwd, ['fetch', '-q', '--no-tags', upstream.split('/')[0]]);
|
|
661
|
+
const [behind, ahead] = (await git(cwd, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).split(/\s+/).map(Number);
|
|
662
|
+
const dirty = !!(await git(cwd, ['status', '--porcelain', '--untracked-files=no']));
|
|
663
|
+
const latest = behind ? (await git(cwd, ['log', '-1', '--format=%s', upstream]).catch(() => '')) : '';
|
|
664
|
+
return { ok: true, branch, upstream, behind, ahead, dirty, latest };
|
|
665
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Pull what GitHub has (a fast-forward when possible, else a merge; stashes local edits around it). */
|
|
669
|
+
export async function pullLatest(cwd) {
|
|
670
|
+
const st = await checkBehind(cwd);
|
|
671
|
+
if (!st.ok) throw new Error(`Can't pull: ${st.reason}.`);
|
|
672
|
+
if (!st.behind) return { pulled: 0 };
|
|
673
|
+
let stashed = false;
|
|
674
|
+
if (st.dirty) { await git(cwd, ['stash', 'push', '-q', '-m', 'craft: before pulling cloud changes']); stashed = true; }
|
|
675
|
+
try {
|
|
676
|
+
await git(cwd, ['-c', 'user.name=Codeply Craft', '-c', 'user.email=craft-bot@users.noreply.github.com', 'pull', '-q', '--no-rebase', '--no-edit']);
|
|
677
|
+
} catch (e) {
|
|
678
|
+
// A conflicting pull is undone, so the project is never left half-merged.
|
|
679
|
+
const conflicted = (await git(cwd, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).split('\n').filter(Boolean);
|
|
680
|
+
await git(cwd, ['merge', '--abort']).catch(() => {});
|
|
681
|
+
throw new Error(conflicted.length
|
|
682
|
+
? `GitHub's changes clash with your commits in ${conflicted.join(', ')}, so nothing was pulled. Commit or undo those, then pull again.`
|
|
683
|
+
: `Could not pull: ${e.message}`);
|
|
684
|
+
} finally {
|
|
685
|
+
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.'); });
|
|
686
|
+
}
|
|
687
|
+
return { pulled: st.behind };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
516
691
|
/**
|
|
517
692
|
* Runs started somewhere else (the phone, while this PC was off) become local
|
|
518
693
|
* tasks, so their changes can be pulled here. Only finished runs are imported.
|
|
@@ -662,83 +837,240 @@ export function packEvents(events, budget = EVENT_BUDGET) {
|
|
|
662
837
|
return list;
|
|
663
838
|
}
|
|
664
839
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
840
|
+
// ─── Files on the craft-sessions branch (chat memory, status, queue, shots) ──
|
|
841
|
+
|
|
842
|
+
export const SESSION_PATHS = {
|
|
843
|
+
history: (sid) => `sessions/${sid}.json`,
|
|
844
|
+
task: (id) => `tasks/${id}.json`,
|
|
845
|
+
live: (sid) => `live/${sid}.json`,
|
|
846
|
+
queue: (sid) => `queue/${sid}`,
|
|
847
|
+
shot: (id, n) => `shots/${id}/${n}.png`,
|
|
848
|
+
// What does not fit in the workflow's inputs (a bot's prompt), written before the dispatch.
|
|
849
|
+
request: (id) => `requests/${id}.json`,
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* A task's optional bot: { name, prompt }, the full prompt the PC built
|
|
854
|
+
* (bots.js buildBotPrompt). Anything else, or an empty prompt, is no bot.
|
|
855
|
+
*/
|
|
856
|
+
export function cleanBot(bot) {
|
|
857
|
+
if (!bot || typeof bot !== 'object') return null;
|
|
858
|
+
const prompt = String(bot.prompt || '').trim().slice(0, MAX_BOT_PROMPT);
|
|
859
|
+
if (!prompt) return null;
|
|
860
|
+
return { name: String(bot.name || 'Bot').trim().slice(0, 80) || 'Bot', prompt };
|
|
861
|
+
}
|
|
862
|
+
/** 'task' is a job for a bot (not about the repo): its final reply is what the user hears. */
|
|
863
|
+
const cleanKind = (k) => (k === 'task' ? 'task' : 'code');
|
|
864
|
+
|
|
865
|
+
/** Small read / write / delete / list helpers for one branch, with retries for concurrent writes. */
|
|
866
|
+
export function branchFiles(api, repo, branch = SESSIONS_BRANCH) {
|
|
867
|
+
const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
|
|
868
|
+
const read = async (p) => {
|
|
869
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
870
|
+
if (r.status !== 200 || !r.json || Array.isArray(r.json)) return null;
|
|
871
|
+
let json = null;
|
|
872
|
+
try { json = JSON.parse(Buffer.from(r.json.content || '', 'base64').toString('utf8')); } catch {}
|
|
873
|
+
return { json, sha: r.json.sha, size: r.json.size };
|
|
874
|
+
};
|
|
875
|
+
const list = async (p) => {
|
|
876
|
+
const r = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
877
|
+
return r.status === 200 && Array.isArray(r.json) ? r.json.map((x) => ({ name: x.name, path: x.path, sha: x.sha })) : [];
|
|
878
|
+
};
|
|
879
|
+
const put = async (p, content, message) => {
|
|
880
|
+
for (let i = 0; i < 4; i++) {
|
|
881
|
+
const cur = await api('GET', `/repos/${repo}/contents/${enc(p)}?ref=${branch}`, null, { allow: [404] });
|
|
882
|
+
const sha = cur.status === 200 && cur.json && !Array.isArray(cur.json) ? cur.json.sha : undefined;
|
|
883
|
+
const r = await api('PUT', `/repos/${repo}/contents/${enc(p)}`, { message, branch, content, ...(sha ? { sha } : {}) }, { allow: [409, 422] });
|
|
884
|
+
if (r.status < 300) return true;
|
|
885
|
+
await sleep(300 + i * 400);
|
|
886
|
+
}
|
|
887
|
+
return false;
|
|
888
|
+
};
|
|
889
|
+
const write = (p, obj, message = `Craft ${p}`) => put(p, Buffer.from(JSON.stringify(obj)).toString('base64'), message);
|
|
890
|
+
const writeBytes = (p, buf, message = `Craft ${p}`) => put(p, Buffer.from(buf).toString('base64'), message);
|
|
891
|
+
const remove = async (p, sha) => {
|
|
892
|
+
for (let i = 0; i < 3; i++) {
|
|
893
|
+
const s = sha || (await read(p) || {}).sha;
|
|
894
|
+
if (!s) return;
|
|
895
|
+
const r = await api('DELETE', `/repos/${repo}/contents/${enc(p)}`, { message: `Craft ${p}`, branch, sha: s }, { allow: [404, 409, 422] });
|
|
896
|
+
if (r.status < 300 || r.status === 404) return;
|
|
897
|
+
sha = null;
|
|
898
|
+
await sleep(300);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const ensure = async (baseSha) => {
|
|
902
|
+
const ref = await api('GET', `/repos/${repo}/git/ref/heads/${branch}`, null, { allow: [404] });
|
|
903
|
+
if (ref.status === 404) await api('POST', `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha: baseSha }, { allow: [422] });
|
|
904
|
+
};
|
|
905
|
+
return { read, list, write, writeBytes, remove, ensure };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function loadHistory(files, sessionId) {
|
|
909
|
+
if (!sessionId) return { messages: [] };
|
|
910
|
+
const f = await files.read(SESSION_PATHS.history(sessionId));
|
|
911
|
+
return { messages: f && f.json && Array.isArray(f.json.messages) ? f.json.messages : [] };
|
|
673
912
|
}
|
|
674
913
|
|
|
675
|
-
async function saveHistory(
|
|
914
|
+
async function saveHistory(files, sessionId, messages) {
|
|
676
915
|
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
|
-
}
|
|
916
|
+
await files.write(SESSION_PATHS.history(sessionId), { sessionId, updatedAt: new Date().toISOString(), messages }, `Chat ${sessionId}`);
|
|
686
917
|
}
|
|
687
918
|
|
|
919
|
+
const branchSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').slice(0, 6).join('-').slice(0, 40) || 'change';
|
|
920
|
+
export const cloudBranchName = (prompt, id) => `craft/${branchSlug(prompt)}-${String(id).slice(-6)}`;
|
|
921
|
+
|
|
688
922
|
/**
|
|
689
|
-
* `codeply cloud runner
|
|
690
|
-
*
|
|
923
|
+
* `codeply cloud runner`, inside GitHub Actions. Runs the dispatched task, then
|
|
924
|
+
* (for a chat) stays up to CRAFT_IDLE_MS (2 minutes) waiting for the next
|
|
925
|
+
* message in queue/<session>, so a follow-up starts at once instead of waiting
|
|
926
|
+
* for a new runner. Each task:
|
|
927
|
+
* - runs like a local run, with a real browser (screenshots) when Chrome is there
|
|
928
|
+
* - publishes its steps live to tasks/<id>.json (and the first one's check run)
|
|
929
|
+
* - commits Build work to craft/<what-it-did>-<id>, pushes it, and merges it
|
|
930
|
+
* into the base branch (CRAFT_MERGE=false keeps it as a branch only)
|
|
931
|
+
* @returns {Promise<{status:'done'|'failed', result:object, results:object[]}>}
|
|
691
932
|
*/
|
|
692
|
-
export async function runCloudRunner({ env = process.env, cwd = process.cwd(), token, route, setupError = null, fetchImpl = fetch, runAgentImpl = runAgent, log = () => {}, maxSteps = 80, updateMs =
|
|
933
|
+
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
934
|
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);
|
|
935
|
+
const firstId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
697
936
|
const sessionId = String(env.CRAFT_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
698
937
|
const headSha = env.GITHUB_SHA;
|
|
699
|
-
if (!repo || !token || !
|
|
938
|
+
if (!repo || !token || !firstId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' }, results: [] };
|
|
700
939
|
const apiUrl = env.GITHUB_API_URL || 'https://api.github.com';
|
|
701
940
|
const serverUrl = (env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/$/, '');
|
|
702
941
|
const api = githubApi({ token, apiUrl, fetchImpl });
|
|
942
|
+
const files = branchFiles(api, repo);
|
|
943
|
+
const idle = idleMs != null ? idleMs : Number(env.CRAFT_IDLE_MS || 120000);
|
|
944
|
+
const base = env.CRAFT_BASE || env.GITHUB_REF_NAME || 'main';
|
|
945
|
+
const merge = String(env.CRAFT_MERGE || 'true') !== 'false';
|
|
946
|
+
await files.ensure(headSha).catch((e) => log(`Could not create ${SESSIONS_BRANCH}: ${e.message}`));
|
|
947
|
+
|
|
948
|
+
// The project's environment for running and testing: never committed.
|
|
949
|
+
const envFile = path.join(cwd, '.env');
|
|
950
|
+
if (env.CRAFT_ENV && String(env.CRAFT_ENV).trim()) {
|
|
951
|
+
try { if (!fs.existsSync(envFile)) fs.writeFileSync(envFile, `${String(env.CRAFT_ENV).trim()}\n`); } catch (e) { log(`Could not write .env: ${e.message}`); }
|
|
952
|
+
}
|
|
953
|
+
const browser = browserImpl !== undefined ? browserImpl : (await import('./cloud-browser.mjs')).makeCloudBrowser();
|
|
954
|
+
|
|
955
|
+
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(() => {});
|
|
956
|
+
|
|
957
|
+
let task = { id: firstId, prompt: String(env.CRAFT_PROMPT || ''), mode: env.CRAFT_MODE, sessionId, dispatched: true };
|
|
958
|
+
// A bot (and the task kind) comes next to the dispatch, on craft-sessions.
|
|
959
|
+
const req = await files.read(SESSION_PATHS.request(firstId)).catch(() => null);
|
|
960
|
+
if (req && req.json) {
|
|
961
|
+
task = { ...task, bot: req.json.bot, kind: req.json.kind };
|
|
962
|
+
await files.remove(SESSION_PATHS.request(firstId), req.sha).catch(() => {});
|
|
963
|
+
}
|
|
964
|
+
const results = [];
|
|
965
|
+
try {
|
|
966
|
+
while (task) {
|
|
967
|
+
if (sessionId) await heartbeat(true, Date.now() + 60 * 60 * 1000);
|
|
968
|
+
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 }));
|
|
969
|
+
if (!sessionId || idle <= 0 || setupError) break;
|
|
970
|
+
task = await waitForNext({ files, sessionId, idle, pollMs, heartbeat });
|
|
971
|
+
}
|
|
972
|
+
} finally {
|
|
973
|
+
if (sessionId) await files.remove(SESSION_PATHS.live(sessionId)).catch(() => {});
|
|
974
|
+
if (browser && browser.close) await browser.close().catch(() => {});
|
|
975
|
+
}
|
|
976
|
+
const last = results[results.length - 1] || { status: 'failed', result: { error: 'Nothing ran.' } };
|
|
977
|
+
return { ...last, results };
|
|
978
|
+
}
|
|
703
979
|
|
|
704
|
-
|
|
980
|
+
/** Idle: the next queued message for this chat, or null after `idle` ms of nothing. */
|
|
981
|
+
async function waitForNext({ files, sessionId, idle, pollMs, heartbeat }) {
|
|
982
|
+
const deadline = Date.now() + idle;
|
|
983
|
+
let beat = 0;
|
|
984
|
+
while (Date.now() < deadline) {
|
|
985
|
+
if (Date.now() - beat > 20000) { await heartbeat(false, deadline); beat = Date.now(); }
|
|
986
|
+
const items = (await files.list(SESSION_PATHS.queue(sessionId)).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
|
|
987
|
+
for (const it of items) {
|
|
988
|
+
const f = await files.read(it.path).catch(() => null);
|
|
989
|
+
await files.remove(it.path, f && f.sha).catch(() => {});
|
|
990
|
+
const t = f && f.json;
|
|
991
|
+
if (!t || !t.id) continue;
|
|
992
|
+
// Picked up by another runner already (it was re-dispatched): skip.
|
|
993
|
+
if (await files.read(SESSION_PATHS.task(t.id)).catch(() => null)) continue;
|
|
994
|
+
return { ...t, sessionId, dispatched: false };
|
|
995
|
+
}
|
|
996
|
+
await sleep(pollMs);
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function runOneTask({ task, env, cwd, api, files, repo, token, serverUrl, headSha, route, setupError, runAgentImpl, log, maxSteps, updateMs, browser, base, merge, heartbeat }) {
|
|
1002
|
+
const taskId = String(task.id).replace(/[^A-Za-z0-9_-]/g, '');
|
|
1003
|
+
const mode = ['Build', 'Plan', 'Ask'].includes(task.mode) ? task.mode : 'Build';
|
|
1004
|
+
const prompt = String(task.prompt || '').slice(0, MAX_PROMPT);
|
|
1005
|
+
const sessionId = task.sessionId || '';
|
|
1006
|
+
const bot = cleanBot(task.bot);
|
|
1007
|
+
const kind = cleanKind(task.kind);
|
|
1008
|
+
const startedAt = Date.now();
|
|
1009
|
+
const events = [];
|
|
1010
|
+
const lines = [];
|
|
1011
|
+
|
|
1012
|
+
// The dispatched task also gets a check run: it is how a failed start is diagnosed.
|
|
1013
|
+
const check = task.dispatched ? (await api('POST', `/repos/${repo}/check-runs`, {
|
|
705
1014
|
name: checkName(taskId), head_sha: headSha, status: 'in_progress', started_at: new Date().toISOString(),
|
|
706
1015
|
output: { title: 'Starting', summary: 'Starting...' },
|
|
707
|
-
})).json;
|
|
708
|
-
|
|
1016
|
+
}).catch(() => ({ json: null }))).json : null;
|
|
1017
|
+
|
|
1018
|
+
const statusDoc = (status, extra = {}) => ({ id: taskId, status, mode, prompt: prompt.slice(0, 4000), sessionId, ...(kind === 'task' ? { kind } : {}), ...(bot ? { bot: { name: bot.name } } : {}), startedAt, updatedAt: Date.now(), runId: env.GITHUB_RUN_ID || null, ...extra });
|
|
709
1019
|
const finish = async (ok, title, summary, result) => {
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1020
|
+
const baseLen = JSON.stringify(result).length;
|
|
1021
|
+
const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - baseLen - 200));
|
|
1022
|
+
await files.write(SESSION_PATHS.task(taskId), statusDoc(ok ? 'done' : 'failed', { ...result, events: packEvents(events, 400000) }), `Craft task ${taskId}: ${title}`).catch(() => {});
|
|
1023
|
+
if (check) {
|
|
1024
|
+
await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
|
|
1025
|
+
status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
|
|
1026
|
+
output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
|
|
1027
|
+
}).catch((e) => log(`Could not finish the check run: ${e.message}`));
|
|
1028
|
+
}
|
|
717
1029
|
return { status: ok ? 'done' : 'failed', result: { ...result, events: packed } };
|
|
718
1030
|
};
|
|
719
1031
|
|
|
720
1032
|
if (setupError) return finish(false, 'Could not start', setupError, { error: setupError, mode });
|
|
721
1033
|
if (!prompt) return finish(false, 'Could not start', 'The task was empty.', { error: 'The task was empty.', mode });
|
|
722
1034
|
|
|
723
|
-
const lines = [];
|
|
724
1035
|
let lastPush = 0; let pending = null;
|
|
725
1036
|
const pushProgress = async (note, force = false) => {
|
|
726
1037
|
if (!force && Date.now() - lastPush < updateMs) return;
|
|
727
1038
|
lastPush = Date.now();
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1039
|
+
const packed = packEvents(events);
|
|
1040
|
+
pending = Promise.all([
|
|
1041
|
+
files.write(SESSION_PATHS.task(taskId), statusDoc('running', { events: packEvents(events, 400000) }), `Craft task ${taskId}`).catch(() => {}),
|
|
1042
|
+
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,
|
|
1043
|
+
heartbeat ? heartbeat() : null,
|
|
1044
|
+
]);
|
|
731
1045
|
await pending;
|
|
732
1046
|
};
|
|
733
1047
|
|
|
1048
|
+
// Screenshots go next to the status, where the PC and the phone read them.
|
|
1049
|
+
let shots = 0; let pendingShot = null;
|
|
1050
|
+
const agentBrowser = browser ? async (url, opts) => {
|
|
1051
|
+
const r = await browser(url, opts);
|
|
1052
|
+
if (r && r.ok && r.screenshotPath) {
|
|
1053
|
+
try {
|
|
1054
|
+
const rel = SESSION_PATHS.shot(taskId, ++shots);
|
|
1055
|
+
if (await files.writeBytes(rel, fs.readFileSync(r.screenshotPath), `Craft screenshot ${taskId}`)) pendingShot = rel;
|
|
1056
|
+
} catch {}
|
|
1057
|
+
}
|
|
1058
|
+
return r;
|
|
1059
|
+
} : undefined;
|
|
1060
|
+
|
|
734
1061
|
try {
|
|
735
|
-
|
|
1062
|
+
// Where this task starts (a later message in the same runner starts after the earlier merge).
|
|
1063
|
+
const taskBase = await git(cwd, ['rev-parse', 'HEAD']).catch(() => headSha);
|
|
1064
|
+
const hist = await loadHistory(files, sessionId);
|
|
736
1065
|
await pushProgress(hist.messages.length ? `Continuing the chat (${hist.messages.length / 2} earlier turns).` : 'Working on it.', true);
|
|
737
1066
|
const approve = ciApprove(cwd);
|
|
738
1067
|
let answer = ''; let steps = 0; let failure = '';
|
|
739
|
-
const userMessage =
|
|
740
|
-
|
|
1068
|
+
const userMessage = kind === 'task'
|
|
1069
|
+
? `${prompt}\n\n(You are running unattended in Codeply Cloud on a Linux machine, not on the user's PC: you cannot reach their local files or desktop apps. Use the shell, the web and this repository. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Your final reply is read out to the user: say plainly what you did or found, in a few short sentences, no markdown. Do not commit or push; Craft does that when you finish.)`
|
|
1070
|
+
: `${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.)`;
|
|
1071
|
+
for await (const ev of runAgentImpl({ userMessage, history: hist.messages.slice(-HISTORY_TURNS), mode, cwd, approve, browser: agentBrowser, signal: new AbortController().signal, route, maxSteps, ...(bot ? { botPrompt: bot.prompt } : {}) })) {
|
|
741
1072
|
const rec = recordEvent(ev);
|
|
1073
|
+
if (rec && rec.t === 'tool' && ev.name === 'browser_check' && pendingShot) { rec.screenshot = pendingShot; pendingShot = null; }
|
|
742
1074
|
// The final answer arrives as the reply; everything else is part of the chat's steps.
|
|
743
1075
|
if (rec && !(rec.t === 'text' && !rec.interim)) events.push(rec);
|
|
744
1076
|
if (ev.type === 'text' && !ev.interim) answer = ev.text;
|
|
@@ -750,37 +1082,53 @@ export async function runCloudRunner({ env = process.env, cwd = process.cwd(), t
|
|
|
750
1082
|
if (pending) await pending;
|
|
751
1083
|
if (failure && !answer) throw new Error(failure);
|
|
752
1084
|
|
|
1085
|
+
// The bot's own last words, before a plan file replaces the answer: what the phone speaks.
|
|
1086
|
+
const reply = answer;
|
|
753
1087
|
if (mode === 'Plan') {
|
|
754
1088
|
const dir = path.join(cwd, '.codeply', 'plans');
|
|
755
|
-
const
|
|
756
|
-
if (
|
|
1089
|
+
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) : [];
|
|
1090
|
+
if (plans.length) answer = fs.readFileSync(path.join(dir, plans[0].f), 'utf8');
|
|
757
1091
|
}
|
|
758
1092
|
|
|
759
|
-
let branch = null; let sha = null; let
|
|
1093
|
+
let branch = null; let sha = null; let changed = []; let stats = []; let merged = null;
|
|
760
1094
|
if (mode === 'Build') {
|
|
761
|
-
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}
|
|
1095
|
+
await git(cwd, ['add', '-A', '--', '.', ':(exclude).codeply', `:(exclude)${WORKFLOW_PATH}`, ':(exclude).env']);
|
|
762
1096
|
const staged = await git(cwd, ['diff', '--cached', '--name-only']);
|
|
763
1097
|
if (staged) {
|
|
764
|
-
|
|
1098
|
+
changed = staged.split('\n').filter(Boolean);
|
|
765
1099
|
// Per-file + and - lines, for the chat's record of what was pushed.
|
|
766
1100
|
stats = (await git(cwd, ['diff', '--cached', '--numstat'])).split('\n').filter(Boolean).map((l) => {
|
|
767
1101
|
const [add, del, ...name] = l.split('\t');
|
|
768
1102
|
return { file: name.join('\t'), added: add === '-' ? null : Number(add), removed: del === '-' ? null : Number(del) };
|
|
769
1103
|
});
|
|
770
|
-
branch =
|
|
771
|
-
|
|
1104
|
+
branch = cloudBranchName(prompt, taskId);
|
|
1105
|
+
const subject = prompt.split('\n')[0].slice(0, 60);
|
|
1106
|
+
await git(cwd, [...BOT, 'commit', '-q', '-m', `${subject}\n\nCraft cloud task ${taskId}.`]);
|
|
772
1107
|
sha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
773
1108
|
await git(cwd, ['push', '-q', 'origin', `HEAD:refs/heads/${branch}`], { auth: authFor(token, serverUrl) });
|
|
1109
|
+
// Into the base branch too, so the next run (and the PC's pull) starts from it.
|
|
1110
|
+
if (merge) {
|
|
1111
|
+
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 }));
|
|
1112
|
+
merged = m.status === 201 || m.status === 204
|
|
1113
|
+
? { ok: true, base, sha: m.json && m.json.sha ? m.json.sha : sha }
|
|
1114
|
+
: { ok: false, base, reason: m.status === 409 ? `it conflicts with newer changes on ${base}` : (m.error || `GitHub answered ${m.status}`) };
|
|
1115
|
+
if (merged.ok) {
|
|
1116
|
+
// Keep working from the merged base for the next message in this chat.
|
|
1117
|
+
await git(cwd, ['fetch', '-q', 'origin', base], { auth: authFor(token, serverUrl) }).catch(() => {});
|
|
1118
|
+
await git(cwd, ['reset', '-q', '--hard', 'FETCH_HEAD']).catch(() => {});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
774
1121
|
const plus = stats.reduce((n, s) => n + (s.added || 0), 0);
|
|
775
1122
|
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 });
|
|
1123
|
+
events.push({ t: 'pushed', branch, sha: sha.slice(0, 7), files: stats.slice(0, 50), added: plus, removed: minus, merged });
|
|
777
1124
|
}
|
|
778
1125
|
}
|
|
779
1126
|
|
|
780
|
-
await saveHistory(
|
|
1127
|
+
await saveHistory(files, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2))
|
|
781
1128
|
.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
|
-
|
|
1129
|
+
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,
|
|
1130
|
+
...(bot || kind === 'task' ? { reply: reply.slice(0, 20000) } : {}) };
|
|
1131
|
+
const title = changed.length ? `Changed ${changed.length} file${changed.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
|
|
784
1132
|
return finish(true, title, answer || title, result);
|
|
785
1133
|
} catch (e) {
|
|
786
1134
|
return finish(false, 'Failed', `Something went wrong: ${e.message}`, { error: e.message, mode, steps: lines.length });
|