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/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.4';
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
- /** One-click setup: mirror repo, first snapshot with the workflow, model key. */
415
- export async function setupCloud({ cwd, token, model, home = defaultHome(), apiUrl, serverUrl, fetchImpl, remoteUrl }) {
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 { repo, created } = await ensureMirror({ api, cwd, home });
422
- const pushed = await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl });
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: pushed.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
- * Push the latest snapshot and start a cloud run.
435
- * @returns {Promise<object>} the task record (also saved in ~/.codeply/cloud.json)
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 snap = await pushSnapshot({ cwd, token, home, serverUrl, remoteUrl });
455
- const task = { id: newTaskId(), prompt, mode, sessionId: String(sessionId || ''), baseSha: snap.sha, repo: p.repo, status: 'starting', startedAt: startedAt || Date.now() };
456
- // Right after the workflow file first lands, GitHub can take a few seconds to know it.
457
- let lastErr = null;
458
- for (let i = 0; i < 10; i++) {
459
- try {
460
- await api('POST', `/repos/${p.repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, { ref: 'main', inputs: { prompt, mode, task_id: task.id, session_id: task.sessionId } });
461
- lastErr = null;
462
- break;
463
- } catch (e) {
464
- lastErr = e;
465
- if (e.status !== 404 && e.status !== 422) break;
466
- await sleep(retryMs);
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
- /** Where a cloud run is, from the workflow run and the runner's check run. */
475
- export async function cloudRunStatus({ cwd, taskId, token, home = defaultHome(), apiUrl, fetchImpl }) {
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 mirror repo.' });
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
- const checks = (await api('GET', `/repos/${task.repo}/commits/${run.head_sha}/check-runs?check_name=${encodeURIComponent(checkName(task.id))}`)).json;
496
- const check = (checks.check_runs || [])[0];
497
- if (check && check.output) next.progress = check.output.summary || '';
498
- // The step-by-step record rides along to the caller but isn't kept in cloud.json.
499
- let events = [];
500
- let data = {};
501
- try { data = JSON.parse((check && check.output && check.output.text) || '{}'); } catch {}
502
- if (Array.isArray(data.events)) events = data.events;
503
- if (check && check.status === 'completed') {
504
- const { events: _drop, ...result } = data;
505
- Object.assign(next, { status: check.conclusion === 'success' ? 'done' : 'failed', result, error: result.error || null, finishedAt: Date.now() });
506
- } else if (run.status === 'completed') {
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
- async function loadHistory(api, repo, sessionId) {
666
- if (!sessionId) return { messages: [], sha: null };
667
- const r = await api('GET', `/repos/${repo}/contents/sessions/${encodeURIComponent(sessionId)}.json?ref=${SESSIONS_BRANCH}`, null, { allow: [404] });
668
- if (r.status !== 200) return { messages: [], sha: null };
669
- try {
670
- const data = JSON.parse(Buffer.from(r.json.content, 'base64').toString('utf8'));
671
- return { messages: Array.isArray(data.messages) ? data.messages : [], sha: r.json.sha };
672
- } catch { return { messages: [], sha: r.json.sha }; }
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(api, repo, sessionId, messages, baseSha) {
888
+ async function saveHistory(files, sessionId, messages) {
676
889
  if (!sessionId) return;
677
- const ref = await api('GET', `/repos/${repo}/git/ref/heads/${SESSIONS_BRANCH}`, null, { allow: [404] });
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`: one cloud task, end to end, inside GitHub Actions.
690
- * @returns {Promise<{status:'done'|'failed', result:object}>}
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 = 5000 }) {
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 taskId = String(env.CRAFT_TASK_ID || '').replace(/[^A-Za-z0-9_-]/g, '');
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 || !taskId || !headSha) return { status: 'failed', result: { error: 'Missing GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_SHA or CRAFT_TASK_ID.' } };
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
- const check = (await api('POST', `/repos/${repo}/check-runs`, {
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
- const events = [];
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
- // The result's fields stay at the top level (older apps read them there); events share the space left.
711
- const base = JSON.stringify(result);
712
- const packed = packEvents(events, Math.max(4000, MAX_OUTPUT - base.length - 200));
713
- await api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
714
- status: 'completed', conclusion: ok ? 'success' : 'failure', completed_at: new Date().toISOString(),
715
- output: { title, summary: String(summary || title).slice(0, MAX_OUTPUT), text: JSON.stringify({ ...result, events: packed }).slice(0, MAX_OUTPUT) },
716
- }).catch((e) => log(`Could not finish the check run: ${e.message}`));
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
- pending = api('PATCH', `/repos/${repo}/check-runs/${check.id}`, {
729
- output: { title: 'Working', summary: renderProgress(lines, note), text: JSON.stringify({ running: true, events: packEvents(events) }) },
730
- }).catch(() => {});
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
- const hist = await loadHistory(api, repo, sessionId);
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, on a copy of the project. Nobody can answer questions mid-run, so make sensible choices and say what you assumed. Do not commit or push; Craft does that when you finish.)`;
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 files = 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) : [];
756
- if (files.length) answer = fs.readFileSync(path.join(dir, files[0].f), 'utf8');
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 files = []; let stats = [];
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
- files = staged.split('\n').filter(Boolean);
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 = taskBranch(taskId);
771
- await git(cwd, [...BOT, 'commit', '-q', '-m', `${prompt.split('\n')[0].slice(0, 60)}\n\nCraft cloud task ${taskId}.`]);
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(api, repo, sessionId, [...hist.messages, { role: 'user', content: prompt }, { role: 'assistant', content: answer || '(no reply)' }].slice(-HISTORY_TURNS * 2), headSha)
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: headSha };
783
- const title = files.length ? `Changed ${files.length} file${files.length === 1 ? '' : 's'}` : mode === 'Build' ? 'No files changed' : 'Answered';
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 });