@agilemonster/mcp-server 1.0.16 → 1.0.21
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/README.md +27 -1
- package/bin/mcp.js +0 -0
- package/bin/orchestrator.js +0 -0
- package/bin/worker.js +0 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +119 -101
- package/dist/deploy-memory.d.ts +122 -0
- package/dist/deploy-memory.js +192 -0
- package/dist/deploy-profile.d.ts +151 -0
- package/dist/deploy-profile.js +993 -0
- package/dist/deploy-runner.d.ts +144 -0
- package/dist/deploy-runner.js +1108 -0
- package/dist/guidelines.d.ts +1 -1
- package/dist/guidelines.js +6 -0
- package/dist/ide-config.d.ts +44 -0
- package/dist/ide-config.js +250 -0
- package/dist/orchestrator.d.ts +1 -0
- package/dist/orchestrator.js +18 -165
- package/dist/redact.d.ts +15 -0
- package/dist/redact.js +58 -0
- package/dist/runtime.d.ts +64 -0
- package/dist/runtime.js +293 -0
- package/dist/server.d.ts +16 -3
- package/dist/server.js +1427 -547
- package/dist/types.d.ts +29 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { composeArgs, isRunnableCommand, methodLabel, normalizeMethod, pushPathsMatch, requiresConfirmation, resolveDeployProfile, } from './deploy-profile.js';
|
|
5
|
+
import { loadMemory, recordAttempt } from './deploy-memory.js';
|
|
6
|
+
import { ensureUserPath, exec, execShell, log, sleep, which } from './runtime.js';
|
|
7
|
+
const FAILURE_TABLE = [
|
|
8
|
+
[
|
|
9
|
+
/cannot connect to the docker daemon|is the docker daemon running|docker daemon is not running|error during connect|docker desktop is not running/i,
|
|
10
|
+
'docker_daemon_down',
|
|
11
|
+
'Docker is installed but not running. Start Docker Desktop (or OrbStack) and reply "retry".',
|
|
12
|
+
true,
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
/no configuration file provided|no such file or directory.*compose|compose.*not found|can't find a suitable configuration file/i,
|
|
16
|
+
'compose_missing',
|
|
17
|
+
'No Compose file was found where the deploy command expects one.',
|
|
18
|
+
false,
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
/port is already allocated|address already in use|bind for .* failed/i,
|
|
22
|
+
'port_conflict',
|
|
23
|
+
'A port the container needs is already taken by another process or container. Stop the other one, then reply "retry".',
|
|
24
|
+
false,
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
/non-fast-forward|fetch first|updates were rejected|\[rejected\]/i,
|
|
28
|
+
'non_fast_forward',
|
|
29
|
+
'The remote branch has commits this machine does not have yet.',
|
|
30
|
+
true,
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
/protected branch|gh006|pre-receive hook declined|push declined/i,
|
|
34
|
+
'branch_protected',
|
|
35
|
+
'The branch is protected and does not accept direct pushes. Deploy from an unprotected branch or open a pull request.',
|
|
36
|
+
false,
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
/permission denied \(publickey|host key verification failed|could not read from remote repository|authentication failed|could not read username|terminal prompts disabled|invalid credentials|bad credentials|http 401|http 403/i,
|
|
40
|
+
'auth',
|
|
41
|
+
'Credentials were rejected. Background agents cannot answer password prompts: make sure the SSH key is loaded in the agent/keychain (or `gh auth login` is done) for the user running the Agile Monster daemon.',
|
|
42
|
+
false,
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
/please tell me who you are|unable to auto-detect email address|empty ident name/i,
|
|
46
|
+
'git_identity',
|
|
47
|
+
'Git has no user.name / user.email configured for this repository.',
|
|
48
|
+
false,
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
/could not resolve host|connection timed out|connection reset|network is unreachable|tls handshake timeout|etimedout|econnreset|temporary failure in name resolution|unexpected eof|i\/o timeout/i,
|
|
52
|
+
'network',
|
|
53
|
+
'A network error interrupted the deployment.',
|
|
54
|
+
true,
|
|
55
|
+
],
|
|
56
|
+
[/no space left on device/i, 'disk_full', 'The disk is full. Free up space (e.g. `docker system prune`) and reply "retry".', false],
|
|
57
|
+
[
|
|
58
|
+
/command not found|not recognized as an internal or external command|executable file not found|enoent/i,
|
|
59
|
+
'tool_missing',
|
|
60
|
+
'A tool the deployment needs is not installed (or not on the PATH of the background agent).',
|
|
61
|
+
false,
|
|
62
|
+
],
|
|
63
|
+
[/timed out after/i, 'timeout', 'The deployment command did not finish in time.', false],
|
|
64
|
+
];
|
|
65
|
+
export function classifyFailure(text) {
|
|
66
|
+
for (const [re, kind, hint, healable] of FAILURE_TABLE) {
|
|
67
|
+
if (re.test(text))
|
|
68
|
+
return { kind, hint, healable };
|
|
69
|
+
}
|
|
70
|
+
return { kind: 'unknown', hint: '', healable: false };
|
|
71
|
+
}
|
|
72
|
+
const AFFIRMATIVE_RE = /^(retry|re-?deploy|try again|deploy again|go ahead|fixed|yes|yep|ok|okay|confirm(ed)?|do it)[\s.!]*$/i;
|
|
73
|
+
/** A directive value only counts as a command when it starts like one — prose after "Deployment:" never runs. */
|
|
74
|
+
const COMMAND_START_RE = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:\.{1,2}\/|\/|~\/|(?:docker|docker-compose|make|npm|pnpm|yarn|npx|bun|bunx|node|deno|python3?|bash|sh|zsh|fly|flyctl|vercel|netlify|wrangler|firebase|gh|git|rsync|scp|ssh|ansible-playbook|kubectl|helm|terraform|cap|bundle|php|composer|gcloud|aws|az|heroku|task|just|cargo|go|dotnet|mvn|gradle|rake|fab|serverless|sls|cdk|pulumi)(?=\s|$))/;
|
|
75
|
+
/**
|
|
76
|
+
* Parse a human reply to a "deployment failed / may I deploy with X?" question.
|
|
77
|
+
* Returns null when the comment is not about deploying (it is then ordinary feedback on the code).
|
|
78
|
+
*/
|
|
79
|
+
export function parseDeployDirective(text) {
|
|
80
|
+
const clean = (text || '').trim();
|
|
81
|
+
if (!clean)
|
|
82
|
+
return null;
|
|
83
|
+
const out = { retry: false, method: null, command: null, liveUrl: null };
|
|
84
|
+
let matched = false;
|
|
85
|
+
const url = clean.match(/^\s*live\s*url\s*[:=]\s*`?(https?:\/\/[^\s`]+)`?\s*$/im);
|
|
86
|
+
if (url) {
|
|
87
|
+
out.liveUrl = url[1];
|
|
88
|
+
matched = true;
|
|
89
|
+
}
|
|
90
|
+
const directive = clean.match(/^\s*deploy(?:ment)?(?:\s+(?:with|via|using|method|command))?\s*[:=]\s*(.+?)\s*$/im);
|
|
91
|
+
if (directive) {
|
|
92
|
+
const value = directive[1].replace(/^`+|`+$/g, '').trim();
|
|
93
|
+
const asMethod = normalizeMethod(value);
|
|
94
|
+
if (AFFIRMATIVE_RE.test(value)) {
|
|
95
|
+
// "deploy: yes" confirms; it is not a program called `yes`
|
|
96
|
+
out.retry = true;
|
|
97
|
+
matched = true;
|
|
98
|
+
}
|
|
99
|
+
else if (asMethod) {
|
|
100
|
+
out.method = asMethod;
|
|
101
|
+
matched = true;
|
|
102
|
+
}
|
|
103
|
+
else if (COMMAND_START_RE.test(value)) {
|
|
104
|
+
out.command = value;
|
|
105
|
+
out.method = /^(?:\S+=\S*\s+)*docker(-compose|\s+compose)(\s|$)/i.test(value)
|
|
106
|
+
? 'docker_compose'
|
|
107
|
+
: /^(npx\s+)?(vercel|netlify|wrangler|firebase)\b|^fly(ctl)?\s/i.test(value)
|
|
108
|
+
? 'cloud_cli'
|
|
109
|
+
: /^gh\s+workflow\s+run\b/i.test(value)
|
|
110
|
+
? 'github_actions'
|
|
111
|
+
: 'deploy_script';
|
|
112
|
+
matched = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (clean.length < 60 && clean.split(/\r?\n/).some((line) => AFFIRMATIVE_RE.test(line.trim()))) {
|
|
116
|
+
out.retry = true;
|
|
117
|
+
matched = true;
|
|
118
|
+
}
|
|
119
|
+
if (out.method || out.liveUrl)
|
|
120
|
+
out.retry = true;
|
|
121
|
+
return matched ? out : null;
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Git
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
const NEVER_COMMIT_RE = /(^|\/)(\.agilemonster\.json|\.mcp\.json|\.cursor\/mcp\.json|\.vscode\/mcp\.json|\.env(\.[\w.-]+)?|id_rsa|id_ed25519|.*\.pem|.*\.p12|\.agilemonster\/.*|\.git\/.*)$/i;
|
|
127
|
+
/** Agent-modified paths that are safe to commit: inside the project, and never local config that carries secrets. */
|
|
128
|
+
export function committablePaths(projectPath, files) {
|
|
129
|
+
const root = path.resolve(projectPath);
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
const rel = f.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
133
|
+
const isEnvTemplate = /(^|\/)\.env\.(example|sample|template|dist)$/i.test(rel);
|
|
134
|
+
if (!rel || (!isEnvTemplate && NEVER_COMMIT_RE.test(rel)))
|
|
135
|
+
continue;
|
|
136
|
+
const abs = path.resolve(root, rel);
|
|
137
|
+
if (!abs.startsWith(root + path.sep))
|
|
138
|
+
continue;
|
|
139
|
+
if (!out.includes(rel))
|
|
140
|
+
out.push(rel);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
const LOCAL_EXCLUDES = ['.agilemonster/', '.agilemonster.json', '.agilemonster-task-*.lock', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', '.cursor/rules/agilemonster-queue.mdc'];
|
|
145
|
+
/**
|
|
146
|
+
* Keep Agile Monster's local files (which contain the personal API key) out of every commit, by anyone,
|
|
147
|
+
* via the repository's `info/exclude` — a per-clone ignore list that is itself never committed.
|
|
148
|
+
* Works for worktrees and for a bound folder that is a sub-directory of a larger repository.
|
|
149
|
+
*/
|
|
150
|
+
export function ensureLocalGitExcludes(projectPath) {
|
|
151
|
+
try {
|
|
152
|
+
const out = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude', '--show-prefix'], {
|
|
153
|
+
cwd: projectPath,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
156
|
+
timeout: 5000,
|
|
157
|
+
}).split(/\r?\n/);
|
|
158
|
+
const excludeRel = (out[0] || '').trim();
|
|
159
|
+
if (!excludeRel)
|
|
160
|
+
return;
|
|
161
|
+
const prefix = (out[1] || '').trim(); // e.g. "apps/web/" when the bound folder is inside a bigger repo
|
|
162
|
+
const file = path.resolve(projectPath, excludeRel);
|
|
163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
164
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
165
|
+
const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
|
166
|
+
// Patterns containing an inner slash are anchored to the repository root, so they need the sub-folder prefix
|
|
167
|
+
const wanted = LOCAL_EXCLUDES.map((e) => (prefix && e.replace(/\/$/, '').includes('/') ? `/${prefix}${e}` : e));
|
|
168
|
+
const missing = wanted.filter((e) => !lines.has(e));
|
|
169
|
+
if (missing.length > 0) {
|
|
170
|
+
fs.appendFileSync(file, `${current.endsWith('\n') || current === '' ? '' : '\n'}# Agile Monster local agent files\n${missing.join('\n')}\n`, 'utf8');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Not a Git repository (or git unavailable): nothing to protect.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function commitAndPush(req, profile, pushPolicy) {
|
|
178
|
+
const cwd = req.projectPath;
|
|
179
|
+
const needsPush = pushPolicy === 'required';
|
|
180
|
+
const state = {
|
|
181
|
+
ok: true,
|
|
182
|
+
isRepo: false,
|
|
183
|
+
branch: '',
|
|
184
|
+
fullSha: '',
|
|
185
|
+
committed: false,
|
|
186
|
+
pushed: false,
|
|
187
|
+
remote: '',
|
|
188
|
+
actions: [],
|
|
189
|
+
healed: [],
|
|
190
|
+
};
|
|
191
|
+
// `--literal-pathspecs`: a path like app/[id]/page.tsx is that one file, never a glob over the developer's others
|
|
192
|
+
const git = (args, timeoutMs = 45000) => exec('git', ['--literal-pathspecs', ...args], { cwd, timeoutMs });
|
|
193
|
+
state.isRepo = (await git(['rev-parse', '--is-inside-work-tree'])).stdout.trim() === 'true';
|
|
194
|
+
if (!state.isRepo) {
|
|
195
|
+
state.actions.push('project folder is not a Git repository (nothing committed)');
|
|
196
|
+
return state;
|
|
197
|
+
}
|
|
198
|
+
const fail = (res, what) => {
|
|
199
|
+
const cls = classifyFailure(res.tail);
|
|
200
|
+
return { ...state, ok: false, errorKind: cls.kind === 'unknown' ? 'git' : cls.kind, errorDetail: `${what}: ${res.tail}` };
|
|
201
|
+
};
|
|
202
|
+
ensureLocalGitExcludes(cwd);
|
|
203
|
+
state.branch = (await git(['branch', '--show-current'])).stdout.trim();
|
|
204
|
+
const files = committablePaths(cwd, req.files || []);
|
|
205
|
+
if (files.length > 0) {
|
|
206
|
+
if (!state.branch) {
|
|
207
|
+
return {
|
|
208
|
+
...state,
|
|
209
|
+
ok: false,
|
|
210
|
+
errorKind: 'detached_head',
|
|
211
|
+
errorDetail: 'The repository is in a detached-HEAD state, so there is no branch to commit the change to. Check out a branch and reply "retry".',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const staged = [];
|
|
215
|
+
for (const f of files) {
|
|
216
|
+
let add = await git(['add', '-A', '--', f]);
|
|
217
|
+
// Editors and IDE Git integrations hold index.lock for moments at a time
|
|
218
|
+
for (let attempt = 0; attempt < 3 && !add.ok && /index\.lock/i.test(add.tail); attempt++) {
|
|
219
|
+
await sleep(2000);
|
|
220
|
+
add = await git(['add', '-A', '--', f]);
|
|
221
|
+
}
|
|
222
|
+
if (add.ok) {
|
|
223
|
+
staged.push(f);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const ignored = await git(['check-ignore', '-q', '--', f]);
|
|
227
|
+
if (ignored.code === 0) {
|
|
228
|
+
state.actions.push(`note: \`${f}\` is ignored by Git and was not committed`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// A change that cannot be staged must never be reported as shipped
|
|
232
|
+
return fail(add, `Could not stage \`${f}\` for commit`);
|
|
233
|
+
}
|
|
234
|
+
if (staged.length > 0) {
|
|
235
|
+
const dirty = await git(['status', '--porcelain', '--', ...staged]);
|
|
236
|
+
if (dirty.stdout.trim().length > 0) {
|
|
237
|
+
const title = (req.title || 'update').replace(/[\r\n]+/g, ' ').trim().slice(0, 120);
|
|
238
|
+
// `--only -- <paths>` commits exactly these files even if the developer has other things staged.
|
|
239
|
+
const commit = await git(['commit', '--only', '-m', `feat: ${title} [AM-${req.cardId.slice(0, 8)}]`, '--', ...staged], 120000);
|
|
240
|
+
if (!commit.ok) {
|
|
241
|
+
const res = fail(commit, 'Git commit failed');
|
|
242
|
+
if (/hook/i.test(commit.tail) && res.errorKind === 'git')
|
|
243
|
+
res.errorKind = 'commit_hook';
|
|
244
|
+
return res;
|
|
245
|
+
}
|
|
246
|
+
state.committed = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
state.fullSha = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
251
|
+
const short = state.fullSha.slice(0, 7);
|
|
252
|
+
if (pushPolicy === 'none') {
|
|
253
|
+
state.actions.push(state.committed ? `committed \`${short}\` locally (not pushed)` : 'nothing new to commit');
|
|
254
|
+
return state;
|
|
255
|
+
}
|
|
256
|
+
const remotes = (await git(['remote'])).stdout.split(/\s+/).filter(Boolean);
|
|
257
|
+
if (remotes.length === 0) {
|
|
258
|
+
state.actions.push(state.committed ? `committed \`${short}\` locally (no Git remote configured)` : 'no Git remote configured');
|
|
259
|
+
return state;
|
|
260
|
+
}
|
|
261
|
+
if (!state.branch) {
|
|
262
|
+
state.actions.push('detached HEAD: nothing pushed');
|
|
263
|
+
return state;
|
|
264
|
+
}
|
|
265
|
+
const configured = (await git(['config', '--get', `branch.${state.branch}.remote`])).stdout.trim();
|
|
266
|
+
state.remote = configured && remotes.includes(configured) ? configured : remotes.includes('origin') ? 'origin' : remotes[0];
|
|
267
|
+
// A push-triggered pipeline needs a new commit to react to when the human asks for a re-deploy.
|
|
268
|
+
if (req.redeploy && !state.committed && needsPush && !profile.dispatchOnly) {
|
|
269
|
+
const ahead = (await git(['rev-list', '--count', `${state.remote}/${state.branch}..HEAD`])).stdout.trim();
|
|
270
|
+
if (ahead === '0') {
|
|
271
|
+
const empty = await git(['commit', '--allow-empty', '-m', `chore: trigger redeploy [AM-${req.cardId.slice(0, 8)}]`]);
|
|
272
|
+
if (empty.ok) {
|
|
273
|
+
state.committed = true;
|
|
274
|
+
state.fullSha = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let push = await git(['push', state.remote, state.branch], 90000);
|
|
279
|
+
if (!push.ok) {
|
|
280
|
+
let cls = classifyFailure(push.tail);
|
|
281
|
+
if (cls.kind === 'non_fast_forward' && needsPush) {
|
|
282
|
+
// Only rebase automatically when nothing of the developer's is in flight: with uncommitted edits, an
|
|
283
|
+
// autostash rebase can leave conflict markers in their files.
|
|
284
|
+
const inFlight = (await git(['status', '--porcelain', '--untracked-files=no'])).stdout.trim();
|
|
285
|
+
if (inFlight.length > 0) {
|
|
286
|
+
return {
|
|
287
|
+
...state,
|
|
288
|
+
ok: false,
|
|
289
|
+
errorKind: 'remote_ahead',
|
|
290
|
+
errorDetail: `The remote branch \`${state.branch}\` has new commits, and this working tree has other uncommitted changes, so I did not rebase automatically. Run \`git pull --rebase\` in the project and reply "retry".`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const pull = await git(['pull', '--rebase', state.remote, state.branch], 120000);
|
|
294
|
+
const unmerged = (await git(['diff', '--name-only', '--diff-filter=U'])).stdout.trim();
|
|
295
|
+
if (pull.ok && !unmerged) {
|
|
296
|
+
state.healed.push(`remote \`${state.branch}\` had new commits: rebased onto them and pushed again`);
|
|
297
|
+
state.fullSha = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
298
|
+
push = await git(['push', state.remote, state.branch], 90000);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
await git(['rebase', '--abort']);
|
|
302
|
+
return {
|
|
303
|
+
...state,
|
|
304
|
+
ok: false,
|
|
305
|
+
errorKind: 'rebase_conflict',
|
|
306
|
+
errorDetail: `The remote branch \`${state.branch}\` has new commits that conflict with this change, so it could not be pushed: ${pull.tail}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (cls.kind === 'network') {
|
|
311
|
+
await sleep(8000);
|
|
312
|
+
push = await git(['push', state.remote, state.branch], 90000);
|
|
313
|
+
if (push.ok)
|
|
314
|
+
state.healed.push('push succeeded on retry after a network error');
|
|
315
|
+
}
|
|
316
|
+
if (!push.ok) {
|
|
317
|
+
cls = classifyFailure(push.tail);
|
|
318
|
+
if (!needsPush) {
|
|
319
|
+
state.actions.push(`push to \`${state.remote}/${state.branch}\` skipped (${cls.kind === 'unknown' ? 'remote not reachable' : cls.kind.replace(/_/g, ' ')}); continuing with the local deployment`);
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
return fail(push, `git push to ${state.remote}/${state.branch} failed`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
state.pushed = true;
|
|
326
|
+
state.actions.push(`pushed \`${state.fullSha.slice(0, 7)}\` to \`${state.remote}/${state.branch}\``);
|
|
327
|
+
return state;
|
|
328
|
+
}
|
|
329
|
+
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
330
|
+
const DEPLOYISH_RE = /deploy|release|publish|cpanel|pages|production|ship/i;
|
|
331
|
+
const runLabel = (r) => r.workflowName || r.name || 'workflow';
|
|
332
|
+
/** Re-runs and dispatch retries leave several runs per workflow for one commit: only the newest one counts. */
|
|
333
|
+
function latestPerWorkflow(runs) {
|
|
334
|
+
const newest = new Map();
|
|
335
|
+
for (const r of runs) {
|
|
336
|
+
const key = runLabel(r).toLowerCase();
|
|
337
|
+
const prev = newest.get(key);
|
|
338
|
+
// `gh run list` returns newest first, so the first one seen wins unless timestamps say otherwise
|
|
339
|
+
if (!prev || (Date.parse(r.createdAt || '') || 0) > (Date.parse(prev.createdAt || '') || Number.MAX_SAFE_INTEGER)) {
|
|
340
|
+
newest.set(key, r);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return Array.from(newest.values());
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Decide what a commit's workflow runs say about the DEPLOYMENT.
|
|
347
|
+
* A failing lint/test workflow does not mean the deploy failed (unless the deploy is chained behind it),
|
|
348
|
+
* a deploy that has not finished is "pending", and a run cancelled by a newer one is neither success nor failure.
|
|
349
|
+
*
|
|
350
|
+
* `deployNames` may hold workflow names and/or workflow file paths (GitHub reports a workflow without a
|
|
351
|
+
* `name:` by its path).
|
|
352
|
+
*/
|
|
353
|
+
export function judgeCiRuns(runs, deployNames, chainedDeploy = false) {
|
|
354
|
+
const warnings = [];
|
|
355
|
+
const latest = latestPerWorkflow(runs);
|
|
356
|
+
if (latest.length === 0)
|
|
357
|
+
return { verdict: 'none', warnings };
|
|
358
|
+
const names = deployNames.map((n) => n.toLowerCase());
|
|
359
|
+
let deployRuns = names.length > 0 ? latest.filter((r) => names.includes(runLabel(r).toLowerCase())) : latest.filter((r) => DEPLOYISH_RE.test(runLabel(r)));
|
|
360
|
+
// Nothing tells us which run is the deployment: then every run has to pass
|
|
361
|
+
if (names.length === 0 && deployRuns.length === 0)
|
|
362
|
+
deployRuns = latest;
|
|
363
|
+
const others = latest.filter((r) => !deployRuns.includes(r));
|
|
364
|
+
if (deployRuns.length > 0) {
|
|
365
|
+
const failed = deployRuns.find((r) => r.status === 'completed' && !CI_OK.has(r.conclusion || '') && r.conclusion !== 'cancelled');
|
|
366
|
+
if (failed) {
|
|
367
|
+
return { verdict: 'failure', failed, reason: `"${runLabel(failed)}" ended with ${failed.conclusion || 'an error'}`, warnings, url: failed.url };
|
|
368
|
+
}
|
|
369
|
+
if (deployRuns.some((r) => r.status !== 'completed')) {
|
|
370
|
+
return { verdict: 'pending', warnings, url: deployRuns.find((r) => r.status !== 'completed')?.url };
|
|
371
|
+
}
|
|
372
|
+
for (const o of others) {
|
|
373
|
+
if (o.status === 'completed' && !CI_OK.has(o.conclusion || '')) {
|
|
374
|
+
warnings.push(`"${runLabel(o)}" ${o.conclusion} (not part of the deployment): ${o.url || ''}`.trim());
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const succeeded = deployRuns.filter((r) => r.conclusion === 'success' || r.conclusion === 'neutral');
|
|
378
|
+
if (succeeded.length === 0) {
|
|
379
|
+
const sample = deployRuns[0];
|
|
380
|
+
const why = sample.conclusion === 'cancelled' ? 'was cancelled (usually superseded by a newer run)' : 'was skipped for this commit';
|
|
381
|
+
return { verdict: 'no_deploy_run', reason: `"${runLabel(sample)}" ${why}`, warnings, url: sample.url };
|
|
382
|
+
}
|
|
383
|
+
return { verdict: 'success', warnings, url: succeeded[0].url };
|
|
384
|
+
}
|
|
385
|
+
if (others.some((r) => r.status !== 'completed')) {
|
|
386
|
+
return { verdict: 'pending', warnings, url: others.find((r) => r.status !== 'completed')?.url };
|
|
387
|
+
}
|
|
388
|
+
const failedOther = others.find((r) => !CI_OK.has(r.conclusion || '') && r.conclusion !== 'cancelled');
|
|
389
|
+
if (failedOther && chainedDeploy) {
|
|
390
|
+
return {
|
|
391
|
+
verdict: 'failure',
|
|
392
|
+
failed: failedOther,
|
|
393
|
+
reason: `"${runLabel(failedOther)}" ${failedOther.conclusion}, so the deployment that runs after it never started`,
|
|
394
|
+
warnings,
|
|
395
|
+
url: failedOther.url,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (failedOther)
|
|
399
|
+
warnings.push(`"${runLabel(failedOther)}" ${failedOther.conclusion}: ${failedOther.url || ''}`.trim());
|
|
400
|
+
return { verdict: 'no_deploy_run', warnings };
|
|
401
|
+
}
|
|
402
|
+
async function fetchRuns(cwd, args) {
|
|
403
|
+
const res = await exec('gh', ['run', 'list', ...args, '--json', 'databaseId,status,conclusion,name,workflowName,url,event,createdAt', '--limit', '20'], { cwd, timeoutMs: 25000 });
|
|
404
|
+
if (!res.ok)
|
|
405
|
+
return { runs: [], error: res.tail };
|
|
406
|
+
try {
|
|
407
|
+
return { runs: JSON.parse(res.stdout || '[]') };
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return { runs: [], error: 'gh returned unreadable output' };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function failedLogTail(cwd, run) {
|
|
414
|
+
if (!run?.databaseId)
|
|
415
|
+
return '';
|
|
416
|
+
const res = await exec('gh', ['run', 'view', String(run.databaseId), '--log-failed'], { cwd, timeoutMs: 40000 });
|
|
417
|
+
if (!res.ok || !res.stdout.trim())
|
|
418
|
+
return '';
|
|
419
|
+
return res.stdout
|
|
420
|
+
.split('\n')
|
|
421
|
+
.map((l) => l.replace(/^\S+\t[^\t]*\t\S+Z\s?/, '').trim())
|
|
422
|
+
.filter((l) => l.length > 0 && /error|fail|denied|fatal|exception|not found|exit code/i.test(l))
|
|
423
|
+
.slice(-6)
|
|
424
|
+
.join(' | ')
|
|
425
|
+
.slice(0, 700);
|
|
426
|
+
}
|
|
427
|
+
/** Names GitHub may report for a workflow: its `name:`, or its path when it has none. */
|
|
428
|
+
export function workflowMatchNames(workflows) {
|
|
429
|
+
return Array.from(new Set(workflows.flatMap((w) => [w.name, `.github/workflows/${w.file}`])));
|
|
430
|
+
}
|
|
431
|
+
/** Re-check a commit's runs once (used both inline and for deferred verification on later pulses). */
|
|
432
|
+
export async function checkCiOnce(cwd, sha, deployNames, chained) {
|
|
433
|
+
const { runs, error } = await fetchRuns(cwd, ['--commit', sha]);
|
|
434
|
+
if (error)
|
|
435
|
+
return { verdict: 'none', warnings: [], ghError: error };
|
|
436
|
+
const verdict = judgeCiRuns(runs, deployNames, chained);
|
|
437
|
+
if (verdict.verdict === 'failure') {
|
|
438
|
+
return { ...verdict, logTail: await failedLogTail(cwd, verdict.failed) };
|
|
439
|
+
}
|
|
440
|
+
return verdict;
|
|
441
|
+
}
|
|
442
|
+
const shQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
443
|
+
async function verifyGithubActions(req, profile, git, changedFiles) {
|
|
444
|
+
const cwd = req.projectPath;
|
|
445
|
+
const actions = [];
|
|
446
|
+
const deployWfs = profile.workflows.filter((w) => w.isDeploy);
|
|
447
|
+
const candidateWfs = deployWfs.length > 0 ? deployWfs : profile.workflows;
|
|
448
|
+
const deployNames = workflowMatchNames(deployWfs);
|
|
449
|
+
const chained = deployWfs.some((w) => w.workflowRun);
|
|
450
|
+
const short = git.fullSha.slice(0, 7);
|
|
451
|
+
const unverified = (text, extra = []) => ({ ok: true, unverified: true, actions: [...actions, text, ...extra], healed: [] });
|
|
452
|
+
if (!which('gh')) {
|
|
453
|
+
return unverified('GitHub Actions should have been triggered by the push, but that could not be verified: the `gh` CLI is not installed on this machine');
|
|
454
|
+
}
|
|
455
|
+
// A dispatch is either what the project needs (deploy workflow with no push trigger) or what the human asked
|
|
456
|
+
// for with an explicit `gh workflow run <file>` command — in which case exactly that workflow is started.
|
|
457
|
+
const explicitDispatch = (profile.command || '').match(/^gh\s+workflow\s+run\s+("[^"]+"|'[^']+'|\S+)/i);
|
|
458
|
+
const dispatchFile = explicitDispatch
|
|
459
|
+
? path.basename(explicitDispatch[1].replace(/^["']|["']$/g, ''))
|
|
460
|
+
: profile.dispatchOnly
|
|
461
|
+
? (candidateWfs.find((w) => w.dispatch) || candidateWfs[0])?.file || ''
|
|
462
|
+
: '';
|
|
463
|
+
let dispatchedAt = 0;
|
|
464
|
+
if (explicitDispatch || profile.dispatchOnly) {
|
|
465
|
+
if (!dispatchFile)
|
|
466
|
+
return { ok: false, actions, healed: [], errorKind: 'no_workflow', errorDetail: 'No dispatchable deploy workflow was found.', wrongMethod: true };
|
|
467
|
+
dispatchedAt = Date.now();
|
|
468
|
+
const dispatch = explicitDispatch
|
|
469
|
+
? await execShell(/\s--ref[\s=]/.test(profile.command || '') ? profile.command : `${profile.command} --ref ${shQuote(git.branch)}`, { cwd, timeoutMs: 30000 })
|
|
470
|
+
: await exec('gh', ['workflow', 'run', dispatchFile, '--ref', git.branch], { cwd, timeoutMs: 30000 });
|
|
471
|
+
if (!dispatch.ok) {
|
|
472
|
+
const cls = classifyFailure(dispatch.tail);
|
|
473
|
+
return { ok: false, actions, healed: [], errorKind: cls.kind === 'unknown' ? 'dispatch_failed' : cls.kind, errorDetail: `Could not start the \`${dispatchFile}\` workflow: ${dispatch.tail}` };
|
|
474
|
+
}
|
|
475
|
+
actions.push(`started the \`${dispatchFile}\` workflow on \`${git.branch}\``);
|
|
476
|
+
}
|
|
477
|
+
const waitMs = req.ciWaitMs ?? 8 * 60 * 1000;
|
|
478
|
+
const startedAt = Date.now();
|
|
479
|
+
const deadline = startedAt + waitMs;
|
|
480
|
+
let last = null;
|
|
481
|
+
let quietSince = 0;
|
|
482
|
+
await sleep(Math.min(6000, waitMs));
|
|
483
|
+
while (Date.now() < deadline) {
|
|
484
|
+
if (dispatchFile) {
|
|
485
|
+
const { runs, error } = await fetchRuns(cwd, ['--workflow', dispatchFile, '--event', 'workflow_dispatch']);
|
|
486
|
+
const mine = runs.filter((r) => Date.parse(r.createdAt || '') >= dispatchedAt - 15000).slice(0, 1);
|
|
487
|
+
// The single run we just started IS the deployment, whatever it is called
|
|
488
|
+
last = error ? { verdict: 'none', warnings: [], ghError: error } : judgeCiRuns(mine, mine.map(runLabel), false);
|
|
489
|
+
if (last.verdict === 'failure')
|
|
490
|
+
last.logTail = await failedLogTail(cwd, last.failed);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
last = await checkCiOnce(cwd, git.fullSha, deployNames, chained);
|
|
494
|
+
}
|
|
495
|
+
if (last.ghError && /auth|login|401|403|not logged/i.test(last.ghError)) {
|
|
496
|
+
return unverified('GitHub Actions should have been triggered, but that could not be verified: `gh` is not signed in on this machine (run `gh auth login`)');
|
|
497
|
+
}
|
|
498
|
+
if (last.verdict === 'success' || last.verdict === 'failure')
|
|
499
|
+
break;
|
|
500
|
+
if (last.verdict === 'none' && Date.now() - startedAt > 90000)
|
|
501
|
+
break;
|
|
502
|
+
if (last.verdict === 'no_deploy_run') {
|
|
503
|
+
// A chained deploy (workflow_run) appears a little after its upstream finishes; give it time.
|
|
504
|
+
quietSince = quietSince || Date.now();
|
|
505
|
+
if (!chained || Date.now() - quietSince > 120000)
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
quietSince = 0;
|
|
510
|
+
}
|
|
511
|
+
await sleep(12000);
|
|
512
|
+
}
|
|
513
|
+
const verdict = last?.verdict || 'none';
|
|
514
|
+
const warn = (last?.warnings || []).map((w) => `note: ${w}`);
|
|
515
|
+
if (verdict === 'success') {
|
|
516
|
+
return { ok: true, actions: [...actions, `GitHub Actions deployment passed for \`${short}\`${last?.url ? ` (${last.url})` : ''}`, ...warn], healed: [] };
|
|
517
|
+
}
|
|
518
|
+
if (verdict === 'failure') {
|
|
519
|
+
return {
|
|
520
|
+
ok: false,
|
|
521
|
+
actions,
|
|
522
|
+
healed: [],
|
|
523
|
+
errorKind: 'ci_failed',
|
|
524
|
+
runUrl: last?.url,
|
|
525
|
+
errorDetail: `GitHub Actions: ${last?.reason || 'the deployment workflow failed'} for commit ${short}. ${last?.url || ''}${last?.logTail ? `\nFailing step output: ${last.logTail}` : ''}`.trim(),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
if (verdict === 'pending' && !dispatchFile) {
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
pending: true,
|
|
532
|
+
actions: [...actions, `GitHub Actions is still deploying \`${short}\`${last?.url ? ` (${last.url})` : ''} — I will confirm on this card when it finishes`],
|
|
533
|
+
healed: [],
|
|
534
|
+
runUrl: last?.url,
|
|
535
|
+
workflowNames: deployNames,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (verdict === 'pending') {
|
|
539
|
+
return unverified(`the \`${dispatchFile}\` workflow was still running when I stopped waiting${last?.url ? ` (${last.url})` : ''} — check it before approving`);
|
|
540
|
+
}
|
|
541
|
+
// No deploy run for this commit: say so plainly. This is never recorded as a verified deployment.
|
|
542
|
+
const pushDeploys = candidateWfs.filter((w) => w.push);
|
|
543
|
+
const filtered = !dispatchFile && pushDeploys.length > 0 && pushDeploys.every((w) => !pushPathsMatch(w.push, changedFiles));
|
|
544
|
+
if (filtered) {
|
|
545
|
+
const paths = pushDeploys.flatMap((w) => w.push?.paths || []).slice(0, 5).join(', ');
|
|
546
|
+
return unverified(`no deployment ran because the changed files are outside the deploy workflow's path filter (${paths})`, warn);
|
|
547
|
+
}
|
|
548
|
+
if (last?.reason) {
|
|
549
|
+
return unverified(`no deployment ran: ${last.reason}`, warn);
|
|
550
|
+
}
|
|
551
|
+
return unverified(`no GitHub Actions deployment run appeared for \`${short}\` (check that Actions are enabled and the workflow triggers on \`${git.branch}\`)`, warn);
|
|
552
|
+
}
|
|
553
|
+
/** `docker compose ps --format json` prints either one JSON array or one object per line, depending on the version. */
|
|
554
|
+
export function parseComposePsJson(text) {
|
|
555
|
+
const trimmed = (text || '').trim();
|
|
556
|
+
if (!trimmed)
|
|
557
|
+
return [];
|
|
558
|
+
try {
|
|
559
|
+
const parsed = JSON.parse(trimmed);
|
|
560
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
561
|
+
}
|
|
562
|
+
catch { }
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
565
|
+
try {
|
|
566
|
+
out.push(JSON.parse(line));
|
|
567
|
+
}
|
|
568
|
+
catch { }
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
export function evaluateComposePs(entries) {
|
|
573
|
+
const bad = [];
|
|
574
|
+
let settling = false;
|
|
575
|
+
let running = 0;
|
|
576
|
+
for (const e of entries) {
|
|
577
|
+
const state = (e.State || '').toLowerCase();
|
|
578
|
+
const health = (e.Health || '').toLowerCase();
|
|
579
|
+
if (state === 'running')
|
|
580
|
+
running++;
|
|
581
|
+
if (state === 'dead' || state === 'restarting' || health === 'unhealthy')
|
|
582
|
+
bad.push(e);
|
|
583
|
+
else if (state === 'exited' && (e.ExitCode ?? 0) !== 0)
|
|
584
|
+
bad.push(e);
|
|
585
|
+
else if (state === 'created' || health === 'starting')
|
|
586
|
+
settling = true;
|
|
587
|
+
}
|
|
588
|
+
return { ok: bad.length === 0 && running > 0, settling, bad, running };
|
|
589
|
+
}
|
|
590
|
+
/** Map changed files to the Compose services that must be rebuilt or restarted (from `docker compose config --format json`). */
|
|
591
|
+
export function planComposeRefresh(config, composeDir, changedAbs) {
|
|
592
|
+
const plan = { rebuild: [], restart: [], all: false };
|
|
593
|
+
const services = config?.services && typeof config.services === 'object' ? config.services : null;
|
|
594
|
+
if (!services || changedAbs.length === 0)
|
|
595
|
+
return { ...plan, all: true };
|
|
596
|
+
const inside = (file, dir) => {
|
|
597
|
+
const d = path.resolve(dir);
|
|
598
|
+
return file === d || file.startsWith(d + path.sep);
|
|
599
|
+
};
|
|
600
|
+
for (const [name, svc] of Object.entries(services)) {
|
|
601
|
+
const build = svc?.build;
|
|
602
|
+
const context = typeof build === 'string' ? build : build?.context;
|
|
603
|
+
if (context) {
|
|
604
|
+
const ctxAbs = path.isAbsolute(context) ? context : path.resolve(composeDir, context);
|
|
605
|
+
if (changedAbs.some((f) => inside(f, ctxAbs))) {
|
|
606
|
+
plan.rebuild.push(name);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const volumes = Array.isArray(svc?.volumes) ? svc.volumes : [];
|
|
611
|
+
for (const v of volumes) {
|
|
612
|
+
const source = typeof v === 'string' ? v.split(':')[0] : v?.type === 'bind' ? v.source : null;
|
|
613
|
+
if (!source || (!path.isAbsolute(source) && !source.startsWith('.')))
|
|
614
|
+
continue;
|
|
615
|
+
const srcAbs = path.isAbsolute(source) ? source : path.resolve(composeDir, source);
|
|
616
|
+
if (changedAbs.some((f) => inside(f, srcAbs))) {
|
|
617
|
+
plan.restart.push(name);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (plan.rebuild.length === 0 && plan.restart.length === 0)
|
|
623
|
+
plan.all = true;
|
|
624
|
+
return plan;
|
|
625
|
+
}
|
|
626
|
+
async function dockerReady(cwd) {
|
|
627
|
+
return exec('docker', ['info', '--format', '{{.ServerVersion}}'], { cwd, timeoutMs: 15000 });
|
|
628
|
+
}
|
|
629
|
+
async function startDockerDesktop() {
|
|
630
|
+
if (process.platform !== 'darwin')
|
|
631
|
+
return null;
|
|
632
|
+
const apps = ['/Applications/OrbStack.app', '/Applications/Docker.app'].filter((p) => fs.existsSync(p));
|
|
633
|
+
if (apps.length === 0)
|
|
634
|
+
return null;
|
|
635
|
+
const context = await exec('docker', ['context', 'show'], { timeoutMs: 5000 });
|
|
636
|
+
const preferOrb = /orbstack/i.test(context.stdout) && apps.some((a) => a.includes('OrbStack'));
|
|
637
|
+
const app = preferOrb ? '/Applications/OrbStack.app' : apps.find((a) => a.includes('Docker')) || apps[0];
|
|
638
|
+
const opened = await exec('open', ['-ga', app], { timeoutMs: 10000 });
|
|
639
|
+
return opened.ok ? path.basename(app, '.app') : null;
|
|
640
|
+
}
|
|
641
|
+
function composeTargetFromCommand(command) {
|
|
642
|
+
const files = [];
|
|
643
|
+
let projectName = null;
|
|
644
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
645
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
646
|
+
const t = tokens[i];
|
|
647
|
+
const next = (tokens[i + 1] || '').replace(/^["']|["']$/g, '');
|
|
648
|
+
if ((t === '-f' || t === '--file') && next)
|
|
649
|
+
files.push(next);
|
|
650
|
+
else if (t.startsWith('--file='))
|
|
651
|
+
files.push(t.slice(7));
|
|
652
|
+
else if ((t === '-p' || t === '--project-name') && next)
|
|
653
|
+
projectName = next;
|
|
654
|
+
else if (t === 'up' || t === 'restart' || t === 'build')
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
return { projectName, files, running: false };
|
|
658
|
+
}
|
|
659
|
+
async function deployDockerCompose(req, profile, changedFiles) {
|
|
660
|
+
const cwd = req.projectPath;
|
|
661
|
+
const actions = [];
|
|
662
|
+
const healed = [];
|
|
663
|
+
if (!which('docker')) {
|
|
664
|
+
await ensureUserPath(true);
|
|
665
|
+
if (!which('docker')) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
actions,
|
|
669
|
+
healed,
|
|
670
|
+
errorKind: 'tool_missing',
|
|
671
|
+
wrongMethod: true,
|
|
672
|
+
errorDetail: 'Docker is not installed on this machine (the `docker` command was not found), so a local Docker Compose deployment cannot run here.',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
let ready = await dockerReady(cwd);
|
|
677
|
+
if (!ready.ok) {
|
|
678
|
+
const started = await startDockerDesktop();
|
|
679
|
+
if (started) {
|
|
680
|
+
for (let i = 0; i < 24 && !ready.ok; i++) {
|
|
681
|
+
await sleep(5000);
|
|
682
|
+
ready = await dockerReady(cwd);
|
|
683
|
+
}
|
|
684
|
+
if (ready.ok)
|
|
685
|
+
healed.push(`Docker was not running: started ${started} and waited for it to come up`);
|
|
686
|
+
}
|
|
687
|
+
if (!ready.ok) {
|
|
688
|
+
return {
|
|
689
|
+
ok: false,
|
|
690
|
+
actions,
|
|
691
|
+
healed,
|
|
692
|
+
errorKind: 'docker_daemon_down',
|
|
693
|
+
errorDetail: `Docker is installed but not running${started ? ` (tried starting ${started}, but it did not become ready)` : ''}: ${ready.tail}`,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const custom = isRunnableCommand(profile.command) && !profile.compose ? profile.command : null;
|
|
698
|
+
const target = profile.compose || (custom ? composeTargetFromCommand(custom) : { projectName: null, files: [], running: false });
|
|
699
|
+
const base = composeArgs(target);
|
|
700
|
+
const compose = (args, timeoutMs = 60000) => exec('docker', [...base, ...args], { cwd, timeoutMs });
|
|
701
|
+
const before = parseComposePsJson((await compose(['ps', '-a', '--format', 'json'])).stdout);
|
|
702
|
+
const idsBefore = new Map(before.map((e) => [e.Service || e.Name || '', e.ID || '']));
|
|
703
|
+
let plan = { rebuild: [], restart: [], all: true };
|
|
704
|
+
const cfg = await compose(['config', '--format', 'json'], 30000);
|
|
705
|
+
if (!custom && !cfg.ok && classifyFailure(cfg.tail).kind === 'compose_missing') {
|
|
706
|
+
return { ok: false, actions, healed, errorKind: 'compose_missing', wrongMethod: true, errorDetail: `No Compose file could be loaded for this project: ${cfg.tail}` };
|
|
707
|
+
}
|
|
708
|
+
if (cfg.ok && changedFiles.length > 0) {
|
|
709
|
+
try {
|
|
710
|
+
const firstFile = target.files[0] ? path.resolve(cwd, target.files[0]) : path.join(cwd, 'compose.yaml');
|
|
711
|
+
plan = planComposeRefresh(JSON.parse(cfg.stdout), path.dirname(firstFile), changedFiles.map((f) => path.resolve(cwd, f)));
|
|
712
|
+
}
|
|
713
|
+
catch { }
|
|
714
|
+
}
|
|
715
|
+
const BUILD_TIMEOUT = 20 * 60 * 1000;
|
|
716
|
+
let up;
|
|
717
|
+
if (custom) {
|
|
718
|
+
up = await execShell(custom, { cwd, timeoutMs: BUILD_TIMEOUT });
|
|
719
|
+
}
|
|
720
|
+
else if (plan.all) {
|
|
721
|
+
up = await compose(['up', '-d', '--build'], BUILD_TIMEOUT);
|
|
722
|
+
}
|
|
723
|
+
else if (plan.rebuild.length > 0) {
|
|
724
|
+
up = await compose(['up', '-d', '--build', ...plan.rebuild], BUILD_TIMEOUT);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
up = await compose(['up', '-d'], BUILD_TIMEOUT);
|
|
728
|
+
}
|
|
729
|
+
// `compose up --build` is idempotent, so a network hiccup during an image pull is safe to retry.
|
|
730
|
+
// A human-supplied command is never re-run automatically.
|
|
731
|
+
if (!custom && !up.ok && classifyFailure(up.tail).kind === 'network') {
|
|
732
|
+
await sleep(10000);
|
|
733
|
+
up = await compose(['up', '-d', '--build', ...(plan.all ? [] : plan.rebuild)], BUILD_TIMEOUT);
|
|
734
|
+
if (up.ok)
|
|
735
|
+
healed.push('Docker build succeeded on retry after a network error');
|
|
736
|
+
}
|
|
737
|
+
if (!up.ok) {
|
|
738
|
+
const cls = classifyFailure(up.tail);
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
actions,
|
|
742
|
+
healed,
|
|
743
|
+
errorKind: cls.kind === 'unknown' ? 'docker_build_failed' : cls.kind,
|
|
744
|
+
wrongMethod: cls.kind === 'compose_missing',
|
|
745
|
+
errorDetail: `Docker Compose could not rebuild/start the containers: ${up.tail}`,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (!custom && !plan.all && plan.restart.length > 0) {
|
|
749
|
+
const restart = await compose(['restart', ...plan.restart], 120000);
|
|
750
|
+
if (!restart.ok) {
|
|
751
|
+
return { ok: false, actions, healed, errorKind: 'docker_restart_failed', errorDetail: `Could not restart ${plan.restart.join(', ')}: ${restart.tail}` };
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Verify: every container up (or cleanly finished), health checks passing.
|
|
755
|
+
// A container that was already dead before this deploy and was not touched by it (same ID) is reported,
|
|
756
|
+
// not blamed on the deploy.
|
|
757
|
+
const brokenBefore = new Set(evaluateComposePs(before).bad.map((e) => e.ID || '').filter(Boolean));
|
|
758
|
+
let entries = [];
|
|
759
|
+
let verdict = evaluateComposePs(entries);
|
|
760
|
+
let leftovers = [];
|
|
761
|
+
for (let i = 0; i < 18; i++) {
|
|
762
|
+
entries = parseComposePsJson((await compose(['ps', '-a', '--format', 'json'])).stdout);
|
|
763
|
+
leftovers = entries.filter((e) => brokenBefore.has(e.ID || ''));
|
|
764
|
+
verdict = evaluateComposePs(entries.filter((e) => !brokenBefore.has(e.ID || '')));
|
|
765
|
+
if (verdict.bad.length > 0)
|
|
766
|
+
break;
|
|
767
|
+
if (!verdict.settling) {
|
|
768
|
+
// "Running" one second after start proves little: a container that crashes on boot looks fine at first.
|
|
769
|
+
// Require it to still be healthy a few seconds later.
|
|
770
|
+
await sleep(6000);
|
|
771
|
+
const again = parseComposePsJson((await compose(['ps', '-a', '--format', 'json'])).stdout);
|
|
772
|
+
const recheck = evaluateComposePs(again.filter((e) => !brokenBefore.has(e.ID || '')));
|
|
773
|
+
entries = again;
|
|
774
|
+
verdict = recheck;
|
|
775
|
+
if (recheck.bad.length > 0 || !recheck.settling)
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
await sleep(5000);
|
|
779
|
+
}
|
|
780
|
+
if (!verdict.ok) {
|
|
781
|
+
const culprit = verdict.bad[0];
|
|
782
|
+
const svc = culprit?.Service || culprit?.Name || '';
|
|
783
|
+
const logs = svc ? await compose(['logs', '--no-color', '--tail', '25', svc], 20000) : null;
|
|
784
|
+
const why = culprit
|
|
785
|
+
? `container \`${svc}\` is ${culprit.Health === 'unhealthy' ? 'unhealthy' : culprit.State}${culprit.ExitCode ? ` (exit code ${culprit.ExitCode})` : ''}`
|
|
786
|
+
: 'no containers are running after the deploy';
|
|
787
|
+
return {
|
|
788
|
+
ok: false,
|
|
789
|
+
actions,
|
|
790
|
+
healed,
|
|
791
|
+
errorKind: 'container_unhealthy',
|
|
792
|
+
errorDetail: `Docker Compose finished, but ${why}.${logs?.stdout ? `\nLast log lines: ${logs.stdout.split('\n').map((l) => l.trim()).filter(Boolean).slice(-6).join(' | ').slice(0, 600)}` : ''}`,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
const recreated = entries
|
|
796
|
+
.filter((e) => (e.State || '').toLowerCase() === 'running')
|
|
797
|
+
.map((e) => e.Service || e.Name || '')
|
|
798
|
+
.filter((svc) => svc && idsBefore.get(svc) !== entries.find((e) => (e.Service || e.Name) === svc)?.ID);
|
|
799
|
+
const parts = [];
|
|
800
|
+
if (recreated.length > 0)
|
|
801
|
+
parts.push(`recreated ${recreated.map((s) => `\`${s}\``).join(', ')} from a fresh build`);
|
|
802
|
+
if (!plan.all && plan.restart.length > 0)
|
|
803
|
+
parts.push(`restarted ${plan.restart.map((s) => `\`${s}\``).join(', ')}`);
|
|
804
|
+
if (parts.length === 0)
|
|
805
|
+
parts.push('images were already up to date, containers left running');
|
|
806
|
+
actions.push(`Docker Compose${target.projectName ? ` project \`${target.projectName}\`` : ''}: ${parts.join('; ')} (${verdict.running} running)`);
|
|
807
|
+
if (leftovers.length > 0) {
|
|
808
|
+
actions.push(`note: ${leftovers.map((e) => `\`${e.Service || e.Name}\``).join(', ')} was already stopped with an error before this deploy and was left as is`);
|
|
809
|
+
}
|
|
810
|
+
const port = entries.flatMap((e) => e.Publishers || []).map((p) => p.PublishedPort || 0).find((p) => p > 0);
|
|
811
|
+
if (port) {
|
|
812
|
+
const status = await httpStatus(`http://localhost:${port}`, 5000);
|
|
813
|
+
if (status > 0)
|
|
814
|
+
actions.push(`responding on http://localhost:${port} (HTTP ${status})`);
|
|
815
|
+
}
|
|
816
|
+
return { ok: true, actions, healed };
|
|
817
|
+
}
|
|
818
|
+
// ---------------------------------------------------------------------------
|
|
819
|
+
// Scripts / cloud CLIs, smoke check
|
|
820
|
+
// ---------------------------------------------------------------------------
|
|
821
|
+
async function deployWithCommand(req, profile) {
|
|
822
|
+
const cwd = req.projectPath;
|
|
823
|
+
const command = (profile.command || '').trim();
|
|
824
|
+
if (!isRunnableCommand(command)) {
|
|
825
|
+
return { ok: false, actions: [], healed: [], errorKind: 'no_command', wrongMethod: true, errorDetail: 'No deploy command is known for this project.' };
|
|
826
|
+
}
|
|
827
|
+
const first = command.split(/\s+/)[0];
|
|
828
|
+
// Only a plain relative script path is checked up front; `~/x.sh`, absolute paths and `VAR=a ./x.sh` go to the shell as written
|
|
829
|
+
const looksLikeFile = /^\.{1,2}\//.test(first);
|
|
830
|
+
let line = command;
|
|
831
|
+
if (looksLikeFile) {
|
|
832
|
+
const abs = path.resolve(cwd, first);
|
|
833
|
+
if (!fs.existsSync(abs)) {
|
|
834
|
+
return { ok: false, actions: [], healed: [], errorKind: 'script_missing', wrongMethod: true, errorDetail: `The deploy script \`${first}\` does not exist in this project.` };
|
|
835
|
+
}
|
|
836
|
+
let executable = true;
|
|
837
|
+
try {
|
|
838
|
+
fs.accessSync(abs, fs.constants.X_OK);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
executable = false;
|
|
842
|
+
}
|
|
843
|
+
if (!executable && /\.sh$/.test(first))
|
|
844
|
+
line = `bash ${command}`;
|
|
845
|
+
}
|
|
846
|
+
const healed = [];
|
|
847
|
+
// A deploy command may have half-finished before it failed: it is never re-run without a human saying so.
|
|
848
|
+
const res = await execShell(line, { cwd, timeoutMs: 15 * 60 * 1000 });
|
|
849
|
+
if (!res.ok) {
|
|
850
|
+
const cls = classifyFailure(res.tail);
|
|
851
|
+
return {
|
|
852
|
+
ok: false,
|
|
853
|
+
actions: [],
|
|
854
|
+
healed,
|
|
855
|
+
errorKind: cls.kind === 'unknown' ? 'deploy_command_failed' : cls.kind,
|
|
856
|
+
errorDetail: `Deploy command \`${command}\` ${res.timedOut ? 'timed out' : `exited with code ${res.code}`}: ${res.tail}`,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return { ok: true, actions: [`deployed with \`${command}\` (exit 0)`], healed };
|
|
860
|
+
}
|
|
861
|
+
async function httpStatus(url, timeoutMs) {
|
|
862
|
+
try {
|
|
863
|
+
const ctrl = new AbortController();
|
|
864
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
865
|
+
const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal });
|
|
866
|
+
clearTimeout(t);
|
|
867
|
+
return res.status;
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function smokeCheck(url, attempts) {
|
|
874
|
+
let status = 0;
|
|
875
|
+
for (let i = 0; i < attempts; i++) {
|
|
876
|
+
status = await httpStatus(url, 15000);
|
|
877
|
+
if (status > 0 && status < 400)
|
|
878
|
+
return status;
|
|
879
|
+
if (i < attempts - 1)
|
|
880
|
+
await sleep(8000);
|
|
881
|
+
}
|
|
882
|
+
return status;
|
|
883
|
+
}
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// Orchestration
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
async function runMethod(req, profile, git, changedFiles) {
|
|
888
|
+
switch (profile.method) {
|
|
889
|
+
case 'github_actions':
|
|
890
|
+
if (!git.pushed && !profile.dispatchOnly) {
|
|
891
|
+
return { ok: false, actions: [], healed: [], errorKind: 'not_pushed', wrongMethod: true, errorDetail: 'GitHub Actions deploys from a pushed commit, but this project has no Git remote to push to.' };
|
|
892
|
+
}
|
|
893
|
+
return verifyGithubActions(req, profile, git, changedFiles);
|
|
894
|
+
case 'docker_compose':
|
|
895
|
+
return deployDockerCompose(req, profile, changedFiles);
|
|
896
|
+
case 'deploy_script':
|
|
897
|
+
case 'cloud_cli':
|
|
898
|
+
return deployWithCommand(req, profile);
|
|
899
|
+
case 'git_push':
|
|
900
|
+
return { ok: true, unverified: true, actions: git.pushed ? ['no deploy step is known for this project beyond pushing'] : ['nothing to push'], healed: [] };
|
|
901
|
+
case 'manual':
|
|
902
|
+
return { ok: true, unverified: true, actions: ['deployment is manual for this project — a human still needs to release it'], healed: [] };
|
|
903
|
+
default:
|
|
904
|
+
return { ok: true, unverified: true, actions: ['no deployment step is configured for this project'], healed: [] };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const HINTS = {
|
|
908
|
+
ci_failed: 'Open the workflow run above, fix the failing step, and reply "retry" — or tell me the correct route with `deploy: <method or command>`.',
|
|
909
|
+
container_unhealthy: 'Check the container logs above. Once it starts cleanly, reply "retry".',
|
|
910
|
+
docker_build_failed: 'The image did not build. Fix the build error above and reply "retry".',
|
|
911
|
+
commit_hook: 'A Git commit hook rejected the change. Fix what the hook reports (or tell me how to proceed) and reply "retry".',
|
|
912
|
+
deploy_command_failed: 'Fix the error above and reply "retry", or give me the right command with `deploy: <command>`.',
|
|
913
|
+
};
|
|
914
|
+
function profileFromCandidate(profile, candidate) {
|
|
915
|
+
return {
|
|
916
|
+
...profile,
|
|
917
|
+
method: candidate.method,
|
|
918
|
+
command: candidate.command,
|
|
919
|
+
compose: candidate.compose,
|
|
920
|
+
dispatchOnly: Boolean(candidate.dispatchOnly),
|
|
921
|
+
source: 'detected',
|
|
922
|
+
evidence: candidate.evidence,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
export async function runDeployment(req) {
|
|
926
|
+
const startedAt = Date.now();
|
|
927
|
+
await ensureUserPath();
|
|
928
|
+
const memory = loadMemory(req.projectPath);
|
|
929
|
+
let profile = resolveDeployProfile(req.projectPath, { bound: req.bound, memory });
|
|
930
|
+
if (req.override?.method) {
|
|
931
|
+
const humanCommand = (req.override.command || '').trim() || null;
|
|
932
|
+
const match = profile.method === req.override.method ? profile : null;
|
|
933
|
+
const alt = profile.alternatives.find((a) => a.method === req.override?.method);
|
|
934
|
+
profile = {
|
|
935
|
+
...(match || (alt ? profileFromCandidate(profile, alt) : profile)),
|
|
936
|
+
method: req.override.method,
|
|
937
|
+
// A command the human typed is used exactly as written — never swapped for a detected one
|
|
938
|
+
command: humanCommand || match?.command || alt?.command || null,
|
|
939
|
+
source: 'explicit',
|
|
940
|
+
confidence: 'high',
|
|
941
|
+
needsConfirmation: false,
|
|
942
|
+
evidence: ['route confirmed on this card', ...((match || alt)?.evidence || [])],
|
|
943
|
+
};
|
|
944
|
+
if (humanCommand)
|
|
945
|
+
profile.compose = undefined;
|
|
946
|
+
}
|
|
947
|
+
const changedFiles = committablePaths(req.projectPath, req.files || []);
|
|
948
|
+
const pushPolicy = profile.method === 'github_actions' || profile.method === 'git_push'
|
|
949
|
+
? 'required'
|
|
950
|
+
: profile.method === 'manual' || profile.method === 'none'
|
|
951
|
+
? 'none'
|
|
952
|
+
: 'attempt';
|
|
953
|
+
const result = (partial, git) => ({
|
|
954
|
+
ok: false,
|
|
955
|
+
pending: false,
|
|
956
|
+
unverified: false,
|
|
957
|
+
method: profile.method,
|
|
958
|
+
command: profile.command,
|
|
959
|
+
commitHash: (git?.fullSha || '').slice(0, 7),
|
|
960
|
+
fullSha: git?.fullSha || '',
|
|
961
|
+
branch: git?.branch || '',
|
|
962
|
+
deployedNote: '',
|
|
963
|
+
errorDetail: '',
|
|
964
|
+
errorKind: null,
|
|
965
|
+
hint: '',
|
|
966
|
+
actions: [],
|
|
967
|
+
healed: [],
|
|
968
|
+
profile,
|
|
969
|
+
...partial,
|
|
970
|
+
});
|
|
971
|
+
// First-time, unconfirmed routes with production reach: ask once (before touching Git), then remember the answer.
|
|
972
|
+
if (profile.needsConfirmation && !req.override) {
|
|
973
|
+
log.info('deploy.needs_confirmation', { card: req.cardId, project: req.projectPath, method: profile.method, command: profile.command });
|
|
974
|
+
return result({
|
|
975
|
+
errorKind: 'needs_confirmation',
|
|
976
|
+
errorDetail: `This project looks like it deploys with ${methodLabel(profile.method)}${profile.command ? ` (\`${profile.command}\`)` : ''}, but I have never run that here and nobody has confirmed it.`,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
const git = await commitAndPush(req, profile, pushPolicy);
|
|
980
|
+
if (!git.ok) {
|
|
981
|
+
const kind = git.errorKind || 'git';
|
|
982
|
+
recordAttempt(req.projectPath, { card_id: req.cardId, method: profile.method, command: profile.command, ok: false, kind, error: git.errorDetail, duration_ms: Date.now() - startedAt });
|
|
983
|
+
log.warn('deploy.git_failed', { card: req.cardId, project: req.projectPath, kind, error: git.errorDetail });
|
|
984
|
+
return result({ errorDetail: git.errorDetail || 'Git failed', errorKind: kind, hint: classifyFailure(git.errorDetail || '').hint || HINTS[kind] || '', healed: git.healed }, git);
|
|
985
|
+
}
|
|
986
|
+
let step = await runMethod(req, profile, git, changedFiles);
|
|
987
|
+
const healed = [...git.healed, ...step.healed];
|
|
988
|
+
// The chosen route does not apply here: fall back to the next-best one rather than giving up.
|
|
989
|
+
if (!step.ok && step.wrongMethod && !req.override && profile.source !== 'explicit' && profile.source !== 'user') {
|
|
990
|
+
// Only fall back to a routine route. One that needs a human's confirmation is never run as a "fallback".
|
|
991
|
+
const fallback = profile.alternatives.find((a) => a.score >= 55 && a.method !== profile.method && !requiresConfirmation(a));
|
|
992
|
+
if (fallback) {
|
|
993
|
+
recordAttempt(req.projectPath, { card_id: req.cardId, method: profile.method, command: profile.command, ok: false, kind: step.errorKind, error: step.errorDetail, wrong_method: true });
|
|
994
|
+
const previous = profile.method;
|
|
995
|
+
profile = profileFromCandidate(profile, fallback);
|
|
996
|
+
const retry = await runMethod(req, profile, git, changedFiles);
|
|
997
|
+
if (retry.ok) {
|
|
998
|
+
healed.push(`\`${previous}\` does not apply to this project (${(step.errorDetail || '').slice(0, 120)}); used ${methodLabel(profile.method)} instead and will remember that`);
|
|
999
|
+
}
|
|
1000
|
+
step = { ...retry, healed: [...step.healed, ...retry.healed] };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (!step.ok) {
|
|
1004
|
+
const kind = step.errorKind || 'unknown';
|
|
1005
|
+
recordAttempt(req.projectPath, {
|
|
1006
|
+
card_id: req.cardId,
|
|
1007
|
+
method: profile.method,
|
|
1008
|
+
command: profile.command,
|
|
1009
|
+
ok: false,
|
|
1010
|
+
kind,
|
|
1011
|
+
error: step.errorDetail,
|
|
1012
|
+
commit: git.fullSha.slice(0, 7),
|
|
1013
|
+
duration_ms: Date.now() - startedAt,
|
|
1014
|
+
wrong_method: Boolean(step.wrongMethod),
|
|
1015
|
+
});
|
|
1016
|
+
log.warn('deploy.failed', { card: req.cardId, project: req.projectPath, method: profile.method, kind, error: step.errorDetail });
|
|
1017
|
+
return result({
|
|
1018
|
+
errorDetail: step.errorDetail || 'Deployment failed',
|
|
1019
|
+
errorKind: kind,
|
|
1020
|
+
hint: HINTS[kind] || classifyFailure(step.errorDetail || '').hint,
|
|
1021
|
+
actions: [...git.actions, ...step.actions],
|
|
1022
|
+
healed,
|
|
1023
|
+
}, git);
|
|
1024
|
+
}
|
|
1025
|
+
const actions = [...git.actions, ...step.actions];
|
|
1026
|
+
// Smoke check: a human-provided URL must respond; a guessed URL is only ever good news.
|
|
1027
|
+
if (profile.liveUrl && /^https?:\/\//i.test(profile.liveUrl) && !step.pending && profile.method !== 'manual' && profile.method !== 'none') {
|
|
1028
|
+
const trusted = profile.liveUrlSource === 'explicit' || profile.liveUrlSource === 'user';
|
|
1029
|
+
const status = await smokeCheck(profile.liveUrl, trusted ? 4 : 1);
|
|
1030
|
+
if (status > 0 && status < 400) {
|
|
1031
|
+
actions.push(`verified live at ${profile.liveUrl} (HTTP ${status})`);
|
|
1032
|
+
}
|
|
1033
|
+
else if (trusted) {
|
|
1034
|
+
const detail = `The deployment ran, but ${profile.liveUrl} returned ${status === 0 ? 'no response' : `HTTP ${status}`}.`;
|
|
1035
|
+
recordAttempt(req.projectPath, { card_id: req.cardId, method: profile.method, command: profile.command, ok: false, kind: 'live_check_failed', error: detail, commit: git.fullSha.slice(0, 7), duration_ms: Date.now() - startedAt });
|
|
1036
|
+
return result({ errorDetail: detail, errorKind: 'live_check_failed', hint: 'Check the site, then reply "retry".', actions, healed }, git);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
// A human-pinned route is its own proof of intent; anything else only becomes "what worked" once verified
|
|
1040
|
+
const unverified = Boolean(step.unverified);
|
|
1041
|
+
recordAttempt(req.projectPath, {
|
|
1042
|
+
card_id: req.cardId,
|
|
1043
|
+
method: profile.method,
|
|
1044
|
+
// The generated compose command is not stored: the stack is rediscovered each time (-p/-f can change,
|
|
1045
|
+
// and only then can changed files be mapped to the services that need a rebuild or restart)
|
|
1046
|
+
command: profile.method === 'docker_compose' && profile.compose ? null : profile.command,
|
|
1047
|
+
ok: true,
|
|
1048
|
+
pending: Boolean(step.pending),
|
|
1049
|
+
unverified,
|
|
1050
|
+
commit: git.fullSha.slice(0, 7),
|
|
1051
|
+
duration_ms: Date.now() - startedAt,
|
|
1052
|
+
live_url: profile.liveUrlSource === 'guessed' ? null : profile.liveUrl,
|
|
1053
|
+
});
|
|
1054
|
+
// Only claim "Deployed" when a deployment actually ran and was checked
|
|
1055
|
+
const headline = step.pending
|
|
1056
|
+
? '⏳ **Deploying**'
|
|
1057
|
+
: !unverified
|
|
1058
|
+
? '🚀 **Deployed**'
|
|
1059
|
+
: git.pushed
|
|
1060
|
+
? '📤 **Pushed** (deployment not verified)'
|
|
1061
|
+
: '📦 **Changes saved**';
|
|
1062
|
+
const healedNote = healed.length > 0 ? `\n🩹 Self-healed: ${healed.join('; ')}.` : '';
|
|
1063
|
+
const deployedNote = `${headline}: ${actions.join(', ') || `commit \`${git.fullSha.slice(0, 7)}\``}.${healedNote}`;
|
|
1064
|
+
log.info('deploy.ok', { card: req.cardId, project: req.projectPath, method: profile.method, pending: Boolean(step.pending), ms: Date.now() - startedAt });
|
|
1065
|
+
return result({
|
|
1066
|
+
ok: true,
|
|
1067
|
+
pending: Boolean(step.pending),
|
|
1068
|
+
unverified,
|
|
1069
|
+
deployedNote,
|
|
1070
|
+
actions,
|
|
1071
|
+
healed,
|
|
1072
|
+
pendingVerification: step.pending
|
|
1073
|
+
? {
|
|
1074
|
+
card_id: req.cardId,
|
|
1075
|
+
card_title: req.title,
|
|
1076
|
+
provider: '',
|
|
1077
|
+
sha: git.fullSha,
|
|
1078
|
+
branch: git.branch,
|
|
1079
|
+
workflow_names: step.workflowNames || [],
|
|
1080
|
+
started_at: new Date().toISOString(),
|
|
1081
|
+
run_url: step.runUrl || null,
|
|
1082
|
+
}
|
|
1083
|
+
: undefined,
|
|
1084
|
+
}, git);
|
|
1085
|
+
}
|
|
1086
|
+
/** Plain-English failure message for the card, including how to answer so the next attempt can succeed. */
|
|
1087
|
+
export function describeFailureForHuman(res) {
|
|
1088
|
+
if (res.errorKind === 'needs_confirmation') {
|
|
1089
|
+
const suggestion = res.command || res.method;
|
|
1090
|
+
const others = res.profile.alternatives.filter((a) => a.score >= 25).slice(0, 3);
|
|
1091
|
+
return [
|
|
1092
|
+
`${res.errorDetail} Why I think so: ${res.profile.evidence[0] || 'it was the best match in the project'}.`,
|
|
1093
|
+
'',
|
|
1094
|
+
`Reply \`deploy: ${suggestion}\` to confirm — I will run it now and use it for every future card without asking again.`,
|
|
1095
|
+
`Or reply \`deploy: manual\` if you deploy this project yourself${others.length > 0 ? `, or pick another route I found: ${others.map((a) => `\`deploy: ${a.command || a.method}\``).join(', ')}` : ''}.`,
|
|
1096
|
+
].join('\n');
|
|
1097
|
+
}
|
|
1098
|
+
const lines = [`What failed: ${res.errorDetail.slice(0, 900)}`];
|
|
1099
|
+
if (res.hint)
|
|
1100
|
+
lines.push('', res.hint);
|
|
1101
|
+
lines.push('', `I tried: ${methodLabel(res.method)}${res.command ? ` (\`${res.command}\`)` : ''} — chosen because ${res.profile.evidence[0] || 'it was the best match I found'}.`);
|
|
1102
|
+
const alts = res.profile.alternatives.filter((a) => a.score >= 25).slice(0, 3);
|
|
1103
|
+
if (alts.length > 0) {
|
|
1104
|
+
lines.push(`Other routes I found: ${alts.map((a) => `\`${a.method}\`${a.command ? ` (\`${a.command}\`)` : ''}`).join(', ')}.`);
|
|
1105
|
+
}
|
|
1106
|
+
lines.push('', 'Reply **retry** once it is fixed, or tell me how this project deploys and I will remember it for every future card:', '`deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · or a command, e.g. `deploy: ./deploy/deploy.sh`');
|
|
1107
|
+
return lines.join('\n');
|
|
1108
|
+
}
|