cawdev-cli 1.2.1 → 1.2.2
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/sonar-analysis.mjs +55 -9
- package/mcp/server.mjs +11 -4
- package/package.json +1 -1
- package/runner/README.md +34 -1
- package/runner/attach.mjs +255 -33
- package/runner/runner.mjs +116 -14
- package/runner/session-cards.mjs +228 -16
- package/runner/split.mjs +35 -10
package/runner/runner.mjs
CHANGED
|
@@ -1750,11 +1750,22 @@ async function readingCopy(source, defaultBranch) {
|
|
|
1750
1750
|
}
|
|
1751
1751
|
}
|
|
1752
1752
|
|
|
1753
|
-
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null) {
|
|
1753
|
+
async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseBranch = null, { resumingMerge = false } = {}) {
|
|
1754
1754
|
await access(join(path, '.git')).catch(() => {
|
|
1755
1755
|
throw new Error(`${path} is not a git repository.`);
|
|
1756
1756
|
});
|
|
1757
1757
|
|
|
1758
|
+
// R621. A checkout mid-merge was left by something the daemon did not see (a
|
|
1759
|
+
// daemon killed between the merge and the RUNNING post, or a person). Whose
|
|
1760
|
+
// merge it is cannot be known, so it is said and not touched. A MERGE run
|
|
1761
|
+
// carrying on its own branch is the one run that is meant to find it so.
|
|
1762
|
+
if (!resumingMerge && await git(path, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false)) {
|
|
1763
|
+
throw new Error(
|
|
1764
|
+
`${path} is in the middle of a merge (MERGE_HEAD is set): from a merge run on its branch that did not finish, ` +
|
|
1765
|
+
`or one started by hand. Finish it (resolve and \`git -C ${path} commit\`) or abandon it ` +
|
|
1766
|
+
`(\`git -C ${path} merge --abort\`), then start the run again.`);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1758
1769
|
const dirty = await git(path, ['status', '--porcelain']);
|
|
1759
1770
|
if (dirty && !allowDirty) {
|
|
1760
1771
|
throw new Error(
|
|
@@ -1869,7 +1880,7 @@ async function prepareWorkingCopy(path, branch, defaultBranch, allowDirty, baseB
|
|
|
1869
1880
|
* — an older runner that did not say why — and it is the property that makes
|
|
1870
1881
|
* being wrong there cost a fetch rather than a model.
|
|
1871
1882
|
*/
|
|
1872
|
-
async function prepareMerge(cwd, branch, defaultBranch) {
|
|
1883
|
+
async function prepareMerge(cwd, branch, defaultBranch, from = null) {
|
|
1873
1884
|
const base = defaultBranch || 'main';
|
|
1874
1885
|
await git(cwd, ['fetch', '--prune', 'origin']).catch((failure) => {
|
|
1875
1886
|
log(` fetch skipped: ${failure.message.split('\n')[0]}`);
|
|
@@ -1881,6 +1892,10 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1881
1892
|
const into = await git(cwd, ['rev-parse', '--verify', `origin/${base}`])
|
|
1882
1893
|
.then(() => `origin/${base}`)
|
|
1883
1894
|
.catch(() => base);
|
|
1895
|
+
// R621. Told to the caller BEFORE the merge runs, so a throw below still leaves it known.
|
|
1896
|
+
if (from) {
|
|
1897
|
+
from.into = into;
|
|
1898
|
+
}
|
|
1884
1899
|
|
|
1885
1900
|
let clean = true;
|
|
1886
1901
|
try {
|
|
@@ -1915,6 +1930,55 @@ async function prepareMerge(cwd, branch, defaultBranch) {
|
|
|
1915
1930
|
};
|
|
1916
1931
|
}
|
|
1917
1932
|
|
|
1933
|
+
/**
|
|
1934
|
+
* R621. Puts a checkout back as `prepareMerge` found it, when the run it was
|
|
1935
|
+
* prepared for never started. `from` is `{ before, into, branch }`: HEAD before
|
|
1936
|
+
* `git merge` ran, and the ref merged in (null when not known).
|
|
1937
|
+
*
|
|
1938
|
+
* Never throws. Returns one sentence, or null when there was nothing to undo.
|
|
1939
|
+
* `reset --keep`, never `--hard`: an allowDirty tree's own work must survive,
|
|
1940
|
+
* and git had already refused any dirty file the merge touches.
|
|
1941
|
+
*/
|
|
1942
|
+
async function undoPreparedMerge(cwd, from) {
|
|
1943
|
+
const couldNot = (why) => {
|
|
1944
|
+
const said = `!! could not undo the prepared merge in ${cwd}: ${String(why).split('\n')[0]}`;
|
|
1945
|
+
log(said);
|
|
1946
|
+
return said;
|
|
1947
|
+
};
|
|
1948
|
+
try {
|
|
1949
|
+
const midMerge = await git(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false);
|
|
1950
|
+
if (midMerge) {
|
|
1951
|
+
await git(cwd, ['merge', '--abort']);
|
|
1952
|
+
return `the merge prepared in ${cwd} was undone (git merge --abort), because the run did not start`;
|
|
1953
|
+
}
|
|
1954
|
+
const before = from?.before;
|
|
1955
|
+
if (!before) {
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
const head = await git(cwd, ['rev-parse', 'HEAD']);
|
|
1959
|
+
if (head === before) {
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
const merged = from.into ? await git(cwd, ['rev-parse', from.into]) : null;
|
|
1963
|
+
// A fast-forward (the branch was strictly behind) moves HEAD onto `into` with no
|
|
1964
|
+
// merge commit; anything else must be a merge commit of `before` and `into`.
|
|
1965
|
+
const fastForward = merged !== null && head === merged
|
|
1966
|
+
&& await git(cwd, ['merge-base', '--is-ancestor', before, head]).then(() => true, () => false);
|
|
1967
|
+
if (!fastForward) {
|
|
1968
|
+
if ((await git(cwd, ['rev-parse', 'HEAD^1'])) !== before) {
|
|
1969
|
+
return couldNot('HEAD is not a merge commit on top of the pre-merge head');
|
|
1970
|
+
}
|
|
1971
|
+
if (merged && (await git(cwd, ['rev-parse', 'HEAD^2'])) !== merged) {
|
|
1972
|
+
return couldNot(`HEAD's second parent is not ${from.into}`);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
await git(cwd, ['reset', '--keep', before]);
|
|
1976
|
+
return `the merge commit prepared on ${from.branch} was undone (reset to ${before.slice(0, 7)}), because the run did not start`;
|
|
1977
|
+
} catch (failure) {
|
|
1978
|
+
return couldNot(failure.message);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1918
1982
|
/**
|
|
1919
1983
|
* Each live run's protected-file guard — R562: `{ globs, mode, allowed, base }`.
|
|
1920
1984
|
*
|
|
@@ -2750,10 +2814,10 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2750
2814
|
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2751
2815
|
}
|
|
2752
2816
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2753
|
-
const
|
|
2754
|
-
const settled = await waitForAnalysis(
|
|
2817
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
2818
|
+
const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2755
2819
|
return await reportBranchReading({
|
|
2756
|
-
ask:
|
|
2820
|
+
ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs: 'MAIN',
|
|
2757
2821
|
warning: "This server does not analyse branches; the branch was analysed as the project's main analysis.",
|
|
2758
2822
|
headSha, report, findings: run.findingsToProve,
|
|
2759
2823
|
});
|
|
@@ -2797,10 +2861,10 @@ async function scanQuality(config, run, cwd, branch) {
|
|
|
2797
2861
|
|
|
2798
2862
|
// What the scanner left behind: where the server queued the analysis.
|
|
2799
2863
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2800
|
-
const
|
|
2801
|
-
const settled = await waitForAnalysis(
|
|
2864
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
2865
|
+
const settled = await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
2802
2866
|
await reportBranchReading({
|
|
2803
|
-
ask:
|
|
2867
|
+
ask: sonarReadAsker(serverUrl, token), reportTask, settled, key, branch, analysedAs, warning, headSha, report,
|
|
2804
2868
|
findings: run.findingsToProve,
|
|
2805
2869
|
});
|
|
2806
2870
|
} catch (failure) {
|
|
@@ -2817,6 +2881,11 @@ function trimSlash(url) {
|
|
|
2817
2881
|
return url.replace(/\/$/, '');
|
|
2818
2882
|
}
|
|
2819
2883
|
|
|
2884
|
+
// Once the analysis has settled, the gate, measures, issue and hotspot pages and finding proofs
|
|
2885
|
+
// together get two minutes: not killed because the wait used up the scan's budget, not unbounded either (R622).
|
|
2886
|
+
const SONAR_READ_BUDGET_MS = 120_000;
|
|
2887
|
+
const sonarReadAsker = (serverUrl, token) => sonarAsker(serverUrl, token, { deadline: Date.now() + SONAR_READ_BUDGET_MS });
|
|
2888
|
+
|
|
2820
2889
|
/** The gate and the new-code measures, for a BRANCH or MAIN reading — never for PROJECT, which has neither. */
|
|
2821
2890
|
async function reportBranchReading({ ask, reportTask, settled, key, branch, analysedAs, warning, headSha, report, findings }) {
|
|
2822
2891
|
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
@@ -2989,8 +3058,9 @@ async function scanAsScratchProject({ quality, run, cwd, branch, key, url, token
|
|
|
2989
3058
|
}
|
|
2990
3059
|
|
|
2991
3060
|
const reportTask = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2992
|
-
const
|
|
2993
|
-
await waitForAnalysis(
|
|
3061
|
+
const serverUrl = (reportTask.serverUrl ?? url).replace(/\/$/, '');
|
|
3062
|
+
await waitForAnalysis(sonarAsker(serverUrl, token), reportTask.ceTaskId, deadline, quality.timeoutSeconds);
|
|
3063
|
+
const ask = sonarReadAsker(serverUrl, token);
|
|
2994
3064
|
|
|
2995
3065
|
const changed = await changedLines(cwd, mergeBase);
|
|
2996
3066
|
const counted = (component, line) => {
|
|
@@ -6935,6 +7005,9 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
6935
7005
|
let shield = null;
|
|
6936
7006
|
// R155. What `git merge` did before anything was spawned, on a MERGE run.
|
|
6937
7007
|
let merge = null;
|
|
7008
|
+
// R621. What `undoPreparedMerge` needs, and whether RUNNING was accepted.
|
|
7009
|
+
let mergeFrom = null;
|
|
7010
|
+
let started = false;
|
|
6938
7011
|
|
|
6939
7012
|
try {
|
|
6940
7013
|
log(`claiming ${run.projectSlug} ${run.label} on ${run.branch}`);
|
|
@@ -7107,8 +7180,10 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7107
7180
|
if (!allowDirty) {
|
|
7108
7181
|
await resetWorkspace(resolve(path));
|
|
7109
7182
|
}
|
|
7183
|
+
const resumingMerge = run.profile === 'MERGE'
|
|
7184
|
+
&& await git(resolve(path), ['rev-parse', '--abbrev-ref', 'HEAD']).then((now) => now === run.branch, () => false);
|
|
7110
7185
|
const prepared = await prepareWorkingCopy(
|
|
7111
|
-
resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch);
|
|
7186
|
+
resolve(path), run.branch, defaultBranch, allowDirty, run.baseBranch, { resumingMerge });
|
|
7112
7187
|
branch = prepared.branch;
|
|
7113
7188
|
baseCommit = prepared.base;
|
|
7114
7189
|
if (handoff) {
|
|
@@ -7157,7 +7232,19 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7157
7232
|
// R155. The daemon does the merge, BEFORE anything is spawned, and what
|
|
7158
7233
|
// git conflicts on becomes the session's whole write scope. A clean
|
|
7159
7234
|
// merge needs no session at all and gets none.
|
|
7160
|
-
|
|
7235
|
+
// R621. Recorded here and not inside `prepareMerge`, so that a throw after
|
|
7236
|
+
// `git merge` ran still has it. A null `before` is kept: there is nothing
|
|
7237
|
+
// safe to reset to, but `git merge --abort` still covers MERGE_HEAD.
|
|
7238
|
+
// Not set at all when the checkout is ALREADY mid-merge (a Carry-on): that
|
|
7239
|
+
// merge is not this attempt's to abort.
|
|
7240
|
+
const alreadyMerging = await git(resolve(path), ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])
|
|
7241
|
+
.then(() => true, () => false);
|
|
7242
|
+
mergeFrom = alreadyMerging ? null : {
|
|
7243
|
+
before: await git(resolve(path), ['rev-parse', 'HEAD']).catch(() => null),
|
|
7244
|
+
into: null,
|
|
7245
|
+
branch,
|
|
7246
|
+
};
|
|
7247
|
+
merge = await prepareMerge(resolve(path), branch, run.baseBranch ?? defaultBranch, mergeFrom ?? undefined);
|
|
7161
7248
|
run.conflictedFiles = merge.conflicted;
|
|
7162
7249
|
log(merge.clean
|
|
7163
7250
|
? ` ${merge.into} merged into ${branch} with no conflict — nothing to resolve`
|
|
@@ -7193,9 +7280,16 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7193
7280
|
// run's path is worse than one showing nothing.
|
|
7194
7281
|
body: { state: 'RUNNING', workspace: workspace ?? null },
|
|
7195
7282
|
});
|
|
7283
|
+
started = true;
|
|
7196
7284
|
} catch (refused) {
|
|
7197
7285
|
if (PLATFORM_REFUSALS.has(refused.status)) {
|
|
7198
7286
|
log(` the platform did not let this run start (${refused.message}); leaving it as the platform has it`);
|
|
7287
|
+
if (mergeFrom) {
|
|
7288
|
+
const undone = await undoPreparedMerge(resolve(path), mergeFrom);
|
|
7289
|
+
if (undone) {
|
|
7290
|
+
log(` ${undone}`);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7199
7293
|
protectedGuards.delete(run.id);
|
|
7200
7294
|
return;
|
|
7201
7295
|
}
|
|
@@ -7226,9 +7320,16 @@ async function startRun(config, offered, workspace, source = null) {
|
|
|
7226
7320
|
// Anything that goes wrong before or during the spawn is the run's failure,
|
|
7227
7321
|
// and the reason belongs on the run where someone will see it.
|
|
7228
7322
|
log(` failed: ${failure.message}`);
|
|
7323
|
+
let summary = failure.message;
|
|
7324
|
+
if (mergeFrom && !started) {
|
|
7325
|
+
const undone = await undoPreparedMerge(resolve(path), mergeFrom);
|
|
7326
|
+
if (undone) {
|
|
7327
|
+
summary = `${summary}\n\n${undone}`;
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7229
7330
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
7230
7331
|
method: 'POST',
|
|
7231
|
-
body: { state: 'FAILED', summary
|
|
7332
|
+
body: { state: 'FAILED', summary },
|
|
7232
7333
|
}).catch((second) => log(` could not record the failure: ${second.message}`));
|
|
7233
7334
|
} finally {
|
|
7234
7335
|
reader?.release();
|
|
@@ -7663,7 +7764,8 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
7663
7764
|
// the tree the tests just ran against, by the daemon and not the session.
|
|
7664
7765
|
// AWAITED: the workspace is released in `perform`'s finally, and a
|
|
7665
7766
|
// scanner still reading files when the next run's reset runs is the
|
|
7666
|
-
// hazard; the block's `timeoutSeconds` bounds the wait
|
|
7767
|
+
// hazard; the block's `timeoutSeconds` bounds the wait and
|
|
7768
|
+
// SONAR_READ_BUDGET_MS the reads after it (R622). It never throws.
|
|
7667
7769
|
if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
|
|
7668
7770
|
await scanQuality(config, run, cwd, run.branch);
|
|
7669
7771
|
}
|
package/runner/session-cards.mjs
CHANGED
|
@@ -87,7 +87,7 @@ function spread(width, n) {
|
|
|
87
87
|
const BADGES = {
|
|
88
88
|
PLAN: ['PLAN', 'violetLight'],
|
|
89
89
|
CODE: ['DEV', 'accent'],
|
|
90
|
-
|
|
90
|
+
TEST: ['TEST', 'text'],
|
|
91
91
|
MERGE: ['MERGE', 'text'],
|
|
92
92
|
RELEASE: ['RELEASE', 'text'],
|
|
93
93
|
};
|
|
@@ -109,17 +109,19 @@ export function badgeOf(profile, ink) {
|
|
|
109
109
|
* @param waiting a word when it waits on you (`waiting for an answer`), else null
|
|
110
110
|
* @param now ms, for how long a live session has run
|
|
111
111
|
*/
|
|
112
|
-
export function cardStatus(meta, waiting = null, now = Date.now()) {
|
|
112
|
+
export function cardStatus(meta, waiting = null, now = Date.now(), tick = 0) {
|
|
113
113
|
if (waiting) {
|
|
114
114
|
return { key: 'waiting', mark: '◆', word: waiting };
|
|
115
115
|
}
|
|
116
116
|
if (!meta.ended) {
|
|
117
|
+
const shape = spinnerFor(meta.view?.profile ?? meta.profile, meta.stage);
|
|
117
118
|
if (meta.state === 'queued' || meta.state === 'claiming') {
|
|
118
|
-
|
|
119
|
+
// Nothing moves while queued: the spinner rests on its first frame, grey.
|
|
120
|
+
return { key: 'queued', mark: shape[0], word: meta.state };
|
|
119
121
|
}
|
|
120
122
|
const since = Date.parse(meta.startedAt ?? '');
|
|
121
123
|
const ran = Number.isFinite(since) ? ` · ${elapsed(now - since)}` : '';
|
|
122
|
-
return { key: 'running', mark:
|
|
124
|
+
return { key: 'running', mark: spinnerFrame(shape, tick), word: `${meta.state ?? 'running'}${ran}` };
|
|
123
125
|
}
|
|
124
126
|
const state = meta.view?.state;
|
|
125
127
|
if (!state || meta.view?.live) {
|
|
@@ -137,6 +139,36 @@ export function cardStatus(meta, waiting = null, now = Date.now()) {
|
|
|
137
139
|
return { key: 'stopped', mark: '✕', word: String(state).toLowerCase().replace(/_/g, ' ') };
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// --- the spinner ---------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** A stage that writes turns the solid quadrant counter-clockwise: the rule `cawdev-actions.js` draws. */
|
|
145
|
+
export const SPINNER_WRITES = ['◴', '◵', '◶', '◷'];
|
|
146
|
+
/** A stage that only reads turns the thin arc clockwise. */
|
|
147
|
+
export const SPINNER_READS = ['◜', '◝', '◞', '◟'];
|
|
148
|
+
/** Stages that only read; every other action — plan, code, merge, release, roadmap, interview — writes. */
|
|
149
|
+
const READING_STAGES = new Set(['test', 'ask', 'audit', 'scope', 'show']);
|
|
150
|
+
const READING_PROFILES = new Set(['TEST', 'ASK', 'AUDIT', 'SCOPE']);
|
|
151
|
+
|
|
152
|
+
/** The frames a session turns through: by its stage when it walks stages, else by its profile. */
|
|
153
|
+
export function spinnerFor(profile, stage = null) {
|
|
154
|
+
const word = String(stage ?? '').toLowerCase();
|
|
155
|
+
if (word) return READING_STAGES.has(word) ? SPINNER_READS : SPINNER_WRITES;
|
|
156
|
+
return READING_PROFILES.has(String(profile ?? '').toUpperCase()) ? SPINNER_READS : SPINNER_WRITES;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One frame every two ticks, so every spinner on the screen is in phase. */
|
|
160
|
+
export function spinnerFrame(shape, tick = 0) {
|
|
161
|
+
return shape[Math.floor(Math.max(0, tick) / 2) % shape.length];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How many ticks a changed value is drawn bright before it settles. */
|
|
165
|
+
export const BEAT_TICKS = 4;
|
|
166
|
+
|
|
167
|
+
/** Whether a value that changed at `changedAt` is still in its beat at `tick`. */
|
|
168
|
+
export function beating(changedAt, tick) {
|
|
169
|
+
return Number.isFinite(changedAt) && tick - changedAt >= 0 && tick - changedAt < BEAT_TICKS;
|
|
170
|
+
}
|
|
171
|
+
|
|
140
172
|
/** `42s`, `12m`, `2h05`. */
|
|
141
173
|
export function elapsed(ms) {
|
|
142
174
|
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
@@ -149,7 +181,7 @@ export function elapsed(ms) {
|
|
|
149
181
|
/** The colour of each status — the frame's, the mark's and the rain's. */
|
|
150
182
|
const STATUS_PAINT = {
|
|
151
183
|
running: 'success', waiting: 'warn', queued: 'muted', done: 'success', planned: 'success', merging: 'muted',
|
|
152
|
-
idle: 'warn', stopped: 'danger', ended: 'muted',
|
|
184
|
+
idle: 'warn', stopped: 'danger', ended: 'muted', landed: 'success',
|
|
153
185
|
};
|
|
154
186
|
|
|
155
187
|
export function statusPaint(key, ink) {
|
|
@@ -161,13 +193,15 @@ export function statusPaint(key, ink) {
|
|
|
161
193
|
* one it is on. Too long for `width`, it says the count and the stage instead,
|
|
162
194
|
* which still answers "how far has it got".
|
|
163
195
|
*/
|
|
164
|
-
export function stageWalk(stages, current, width, ink) {
|
|
196
|
+
export function stageWalk(stages, current, width, ink, tick = null) {
|
|
165
197
|
if (!Array.isArray(stages) || !stages.length || !current) return '';
|
|
166
198
|
const at = stages.indexOf(current);
|
|
199
|
+
// The mark on the current stage turns when a tick is given, as the status's does.
|
|
200
|
+
const mark = tick === null ? '●' : spinnerFrame(spinnerFor(null, current), tick);
|
|
167
201
|
const words = stages.map((name, i) => {
|
|
168
202
|
const word = String(name).toLowerCase();
|
|
169
203
|
if (at < 0 || i > at) return ink.muted(`${word} ○`);
|
|
170
|
-
return i === at ? ink.bold(`${word}
|
|
204
|
+
return i === at ? ink.bold(`${word} ${mark}`) : ink.muted(`${word} ✓`);
|
|
171
205
|
});
|
|
172
206
|
const line = words.join(ink.muted(' ─ '));
|
|
173
207
|
if (visibleWidth(line) <= width) return line;
|
|
@@ -227,8 +261,12 @@ export const RAIN = {
|
|
|
227
261
|
ended: { speed: 0, density: 0.12, trail: 1 },
|
|
228
262
|
};
|
|
229
263
|
|
|
230
|
-
/**
|
|
231
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Plain ASCII: one column in every terminal and every font. Half-width
|
|
266
|
+
* katakana are East Asian "halfwidth" — a terminal or font that draws them two
|
|
267
|
+
* columns wide pushed every rain row past the card's right border.
|
|
268
|
+
*/
|
|
269
|
+
export const GLYPHS = '0123456789=+*<>:ZXJ';
|
|
232
270
|
|
|
233
271
|
/** A small integer hash — the same inputs, the same drop. */
|
|
234
272
|
function mix(...values) {
|
|
@@ -384,6 +422,168 @@ export function mergingRobotLines(width, rows, { tick = 0, still = false, base =
|
|
|
384
422
|
return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
|
|
385
423
|
}
|
|
386
424
|
|
|
425
|
+
// --- the robots' moments ----------------------------------------------------------
|
|
426
|
+
//
|
|
427
|
+
// Each is a pure function of `since`, the ticks since the card's status changed
|
|
428
|
+
// (attach.mjs records the tick when it marks a session ended), so a test can
|
|
429
|
+
// name the frame it wants. The pacing and merging robots above are unchanged.
|
|
430
|
+
|
|
431
|
+
/** The tick the walk-in of a finished plan takes: from x = -6, a column every two ticks, in by x = 0. */
|
|
432
|
+
export const WALK_IN_TICKS = 14;
|
|
433
|
+
|
|
434
|
+
/** `text` placed at column `x` of a row `width` wide; a negative `x` cuts the text's front off. */
|
|
435
|
+
function placed(text, x, width) {
|
|
436
|
+
const cut = x < 0 ? text.slice(Math.min(text.length, -x)) : text;
|
|
437
|
+
const left = Math.max(0, x);
|
|
438
|
+
return (' '.repeat(left) + cut).slice(0, width).padEnd(width);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Overlays plain `text` on the plain row `base` at `x`; spaces in `text` leave `base` alone. */
|
|
442
|
+
function overlay(base, text, x) {
|
|
443
|
+
const chars = [...base];
|
|
444
|
+
[...text].forEach((ch, i) => {
|
|
445
|
+
const at = x + i;
|
|
446
|
+
if (at >= 0 && at < chars.length && ch !== ' ') chars[at] = ch;
|
|
447
|
+
});
|
|
448
|
+
return chars.join('');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Three plain rows → exactly `rows` painted ones, bottom-aligned, as the other robots are. */
|
|
452
|
+
function stand(rowsOf, width, rows, paintRow) {
|
|
453
|
+
const drawn = (rows >= 3 ? rowsOf : rows === 2 ? [rowsOf[0], rowsOf[2]] : [rowsOf[0]])
|
|
454
|
+
.map((row, i, all) => paintRow(row, i, all.length));
|
|
455
|
+
return [...new Array(Math.max(0, rows - drawn.length)).fill(' '.repeat(width)), ...drawn].slice(-rows);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A finished plan, the moment it finishes: the robot walks in from the left carrying `✓ plan done`, then paces. */
|
|
459
|
+
export function planWalkInLines(width, rows, { since = 0, tick = 0, still = false } = {}, ink) {
|
|
460
|
+
if (still || since >= WALK_IN_TICKS) {
|
|
461
|
+
return robotLines(width, rows, { tick: still ? 0 : Math.max(0, since - WALK_IN_TICKS), still }, ink);
|
|
462
|
+
}
|
|
463
|
+
const x = -6 + Math.floor(since / 2);
|
|
464
|
+
const sign = ` ${ROBOT_SIGN}`;
|
|
465
|
+
const legs = since % 2 ? ' / \\ ' : ' | | ';
|
|
466
|
+
const rowsOf = [`[o_o]${sign}`, '\\[_]/', legs];
|
|
467
|
+
return stand(rowsOf, width, rows, (row, i) => {
|
|
468
|
+
const plain = placed(row, x, width);
|
|
469
|
+
return i === 0 && row.length > 5
|
|
470
|
+
? ink.bold(ink.success(plain.slice(0, Math.max(0, x + 5)))) + ink.success(plain.slice(Math.max(0, x + 5)))
|
|
471
|
+
: ink.bold(ink.success(plain));
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Where a development's robot sets the branch down: just left of the dock's sign. */
|
|
476
|
+
function dockOf(width, word) {
|
|
477
|
+
const dock = ` ▶ ${word}`;
|
|
478
|
+
return { dock, dockAt: Math.max(0, width - dock.length), branchX: Math.max(8, width - dock.length - 4) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A plain row painted in `[from, to, paint]` segments, the rest in `base`. */
|
|
482
|
+
function segments(plain, spans, base) {
|
|
483
|
+
let out = '';
|
|
484
|
+
let at = 0;
|
|
485
|
+
for (const [from, to, paint] of [...spans].sort((a, b) => a[0] - b[0])) {
|
|
486
|
+
if (from > at) out += base(plain.slice(at, from));
|
|
487
|
+
out += paint(plain.slice(from, to));
|
|
488
|
+
at = to;
|
|
489
|
+
}
|
|
490
|
+
return out + (at < plain.length ? base(plain.slice(at)) : '');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The dock's sign set on the row the branch sits on (the middle one, or the last when there is no room for three). */
|
|
494
|
+
function withDock(rowsOf, rows, dock, width) {
|
|
495
|
+
const at = rows >= 3 ? 1 : rowsOf.length - 1;
|
|
496
|
+
return rowsOf.map((row, i) => (i === at && width > dock.length
|
|
497
|
+
? row.slice(0, width - dock.length) + dock : row));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A finished development, the moment it finishes: the robot walks in carrying
|
|
502
|
+
* the branch (`[>_>]`, `/[_]=[■]`) to `▶ review`, sets it down (the `[■]` stays
|
|
503
|
+
* by the dock), steps back three columns and waits beside it — `[o_o]`, a blink
|
|
504
|
+
* every 23 ticks, a look at the branch (`[>_>]`) three ticks in every forty —
|
|
505
|
+
* under the sign `✓ built · /pr`.
|
|
506
|
+
*/
|
|
507
|
+
export function devDoneRobotLines(width, rows, { since = 0, still = false } = {}, ink) {
|
|
508
|
+
const { dock, dockAt, branchX } = dockOf(width, 'review');
|
|
509
|
+
const endX = Math.max(0, branchX - 5);
|
|
510
|
+
const walk = 2 * (endX + 8);
|
|
511
|
+
const t = still ? walk + 4 : since;
|
|
512
|
+
const carrying = t < walk + 1;
|
|
513
|
+
const back = carrying ? 0 : Math.min(3, t - walk - 1);
|
|
514
|
+
const x = carrying ? Math.min(endX, -8 + Math.floor(t / 2)) : Math.max(0, endX - back);
|
|
515
|
+
const waited = t - walk - 4;
|
|
516
|
+
const blink = !still && waited >= 0 && waited % 23 === 22;
|
|
517
|
+
const looking = !still && waited >= 0 && waited % 40 >= 37;
|
|
518
|
+
const head = carrying || looking ? '[>_>]' : blink ? '[-_-]' : '[o_o]';
|
|
519
|
+
const legs = !carrying || still ? ' | | ' : Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
|
|
520
|
+
const sign = '✓ built · /pr';
|
|
521
|
+
const signed = !carrying && x >= sign.length + 2;
|
|
522
|
+
let top = placed(head, x, width);
|
|
523
|
+
if (signed) top = overlay(top, sign, 1);
|
|
524
|
+
const plain = withDock([
|
|
525
|
+
top,
|
|
526
|
+
carrying ? placed('/[_]=[■]', x, width) : overlay(placed('/[_]\\', x, width), '[■]', branchX),
|
|
527
|
+
placed(legs, x, width),
|
|
528
|
+
], rows, dock, width);
|
|
529
|
+
return stand(plain, width, rows, (row, i, n) => {
|
|
530
|
+
const onDock = (n >= 3 ? i === 1 : i === n - 1);
|
|
531
|
+
const spans = [];
|
|
532
|
+
if (onDock) spans.push([dockAt, width, ink.muted]);
|
|
533
|
+
if (signed && (n >= 3 ? i === 0 : i === 0)) spans.push([1, 1 + sign.length, ink.success]);
|
|
534
|
+
return segments(row, spans, (text) => ink.bold(ink.accent(text)));
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Nothing built: the robot walks in empty-handed, amber, stands at the left and shrugs. */
|
|
539
|
+
export function nothingBuiltRobotLines(width, rows, { since = 0, still = false, ref = null } = {}, ink) {
|
|
540
|
+
const paint = (text) => ink.bold(ink.warn(text));
|
|
541
|
+
const x = still ? 1 : Math.min(1, -6 + Math.floor(since / 2));
|
|
542
|
+
const waited = still ? 0 : since - 14;
|
|
543
|
+
const shrug = waited >= 0 && waited % 30 >= 24;
|
|
544
|
+
const blink = waited >= 0 && waited % 23 === 22;
|
|
545
|
+
const head = blink ? '[-_-]' : '[o_o]';
|
|
546
|
+
const arms = shrug ? '¯[_]¯' : '/[_]\\';
|
|
547
|
+
const sign = ` ✗ nothing built · /new plan ${ref ?? '<card>'}`;
|
|
548
|
+
const shown = width - 6 - 1 >= sign.length ? sign : ' ✗ nothing built';
|
|
549
|
+
const rowsOf = [head + (x >= 0 ? shown : ''), arms, ' | | '];
|
|
550
|
+
return stand(rowsOf, width, rows, (row, i) => {
|
|
551
|
+
const plain = placed(row, x, width);
|
|
552
|
+
return i === 0 && x >= 0
|
|
553
|
+
? paint(plain.slice(0, x + 5)) + ink.warn(plain.slice(x + 5))
|
|
554
|
+
: paint(plain);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Ticks the robot waves for after a landing, and ticks it then takes to walk off a card `width` wide. */
|
|
559
|
+
export const WAVE_TICKS = 12;
|
|
560
|
+
/** A landed card closes itself this many ticks after it lands (3 s at 120 ms). */
|
|
561
|
+
export const CLOSE_AFTER_TICKS = 25;
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The landing: the branch is set down at `▶ main`, the robot waves (`[^_^]`,
|
|
565
|
+
* arms alternating) for twelve ticks and walks off the right edge.
|
|
566
|
+
*/
|
|
567
|
+
export function landingRobotLines(width, rows, { since = 0, still = false, base = 'main' } = {}, ink) {
|
|
568
|
+
const { dock, dockAt, branchX } = dockOf(width, base);
|
|
569
|
+
const stands = Math.max(0, branchX - 8);
|
|
570
|
+
const t = still ? 0 : since;
|
|
571
|
+
const waving = t < WAVE_TICKS;
|
|
572
|
+
// Off the edge before the card closes itself, however wide the card is.
|
|
573
|
+
const pace = Math.max(1, Math.ceil((width - stands) / (CLOSE_AFTER_TICKS - WAVE_TICKS - 1)));
|
|
574
|
+
const x = stands + Math.max(0, t - WAVE_TICKS) * pace;
|
|
575
|
+
const arms = !waving ? '/[_]\\' : Math.floor(t / 2) % 2 ? '\\[_]/' : '/[_]\\';
|
|
576
|
+
const head = waving ? '[^_^]' : '[>_>]';
|
|
577
|
+
const legs = !waving && Math.floor(t / 2) % 2 ? ' / \\ ' : ' | | ';
|
|
578
|
+
const plain = withDock([
|
|
579
|
+
placed(head, x, width),
|
|
580
|
+
overlay(placed(arms, x, width), '[■]', branchX),
|
|
581
|
+
placed(legs, x, width),
|
|
582
|
+
], rows, dock, width);
|
|
583
|
+
return stand(plain, width, rows, (row, i, n) => segments(
|
|
584
|
+
row, (n >= 3 ? i === 1 : i === n - 1) ? [[dockAt, width, ink.muted]] : [], (text) => ink.bold(ink.accent(text))));
|
|
585
|
+
}
|
|
586
|
+
|
|
387
587
|
// --- a card --------------------------------------------------------------------
|
|
388
588
|
|
|
389
589
|
/**
|
|
@@ -420,7 +620,10 @@ export function sessionCardLines(rect, card, ink) {
|
|
|
420
620
|
const title = titleRoom >= 4 ? ` ${ellipsed(card.title ?? '', titleRoom - 1)} ` : ' ';
|
|
421
621
|
const label = `${left}${badge}${title}`;
|
|
422
622
|
const fill = Math.max(0, inner - 1 - visibleWidth(label) - visibleWidth(status) - 1);
|
|
423
|
-
|
|
623
|
+
// A status that changed is drawn reversed for a few ticks, then settles.
|
|
624
|
+
const beat = card.changedAt !== undefined && beating(card.changedAt, card.tick ?? 0);
|
|
625
|
+
const shownStatus = visibleWidth(label) + visibleWidth(status) + 2 <= inner
|
|
626
|
+
? (beat ? ink.reverse(paint(status)) : paint(status)) : '';
|
|
424
627
|
const top = paint(tl + h) + (card.focused ? ink.bold(label) : label)
|
|
425
628
|
+ paint(h.repeat(shownStatus ? fill : Math.max(0, inner - 1 - visibleWidth(label)))) + shownStatus
|
|
426
629
|
+ paint((shownStatus ? h : '') + tr);
|
|
@@ -432,15 +635,24 @@ export function sessionCardLines(rect, card, ink) {
|
|
|
432
635
|
const room = Math.max(0, height - 2 - footer);
|
|
433
636
|
const words = (card.lines ?? []).filter((line) => line !== null && line !== undefined).slice(0, room);
|
|
434
637
|
const spare = room - words.length;
|
|
638
|
+
// The robots move whether the rain is on or off: they are not the matrix.
|
|
639
|
+
const since = Number.isFinite(card.since) ? card.since : null;
|
|
435
640
|
const rain = spare <= 0 ? []
|
|
436
|
-
// A planned card has its robot whatever `/matrix` says; off, it stands still.
|
|
437
641
|
: card.status.key === 'planned'
|
|
438
|
-
? robotLines(inner, spare, { tick: card.tick
|
|
642
|
+
? (since === null ? robotLines(inner, spare, { tick: card.tick }, ink)
|
|
643
|
+
: planWalkInLines(inner, spare, { since }, ink))
|
|
439
644
|
: card.status.key === 'merging'
|
|
440
|
-
? mergingRobotLines(inner, spare, { tick: card.tick,
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
645
|
+
? mergingRobotLines(inner, spare, { tick: card.tick, base: card.base }, ink)
|
|
646
|
+
: card.status.key === 'done'
|
|
647
|
+
? devDoneRobotLines(inner, spare, { since: since ?? 1e6 }, ink)
|
|
648
|
+
: card.status.key === 'idle'
|
|
649
|
+
? nothingBuiltRobotLines(inner, spare, { since: since ?? 1e6, ref: card.ref }, ink)
|
|
650
|
+
: card.status.key === 'landed'
|
|
651
|
+
? landingRobotLines(inner, spare, { since: since ?? 1e6, base: card.base }, ink)
|
|
652
|
+
// Failed, running, waiting, queued, stopped: no robot. The rain only when asked for.
|
|
653
|
+
: card.rain !== true || card.status.key === 'failed'
|
|
654
|
+
? new Array(spare).fill('')
|
|
655
|
+
: rainLines(inner, spare, { seed: card.seed, tick: card.tick, mood: card.status.key }, ink);
|
|
444
656
|
const lines = [top, ...words.map(said), ...rain.map(row)];
|
|
445
657
|
if (footer) lines.push(said(ink.muted(card.footer)));
|
|
446
658
|
lines.push(paint(bl + h.repeat(inner) + br));
|
package/runner/split.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// Advanced mode — the attached terminal split into one window per session,
|
|
2
2
|
// emacs-style, with the client's own prompt at the bottom.
|
|
3
3
|
//
|
|
4
|
-
// **R81
|
|
5
|
-
// reason
|
|
6
|
-
// search and copy stop working and the transcript above the fold is gone.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// **R81 chose the scrollback as the default, and R624 changes the default.**
|
|
5
|
+
// R81's reason was that a full-screen repaint EATS THE SCROLLBACK: the wheel,
|
|
6
|
+
// search and copy stop working and the transcript above the fold is gone. With
|
|
7
|
+
// several sessions and a roadmap on one machine that reason no longer wins, so
|
|
8
|
+
// `cawdev` opens HERE wherever the terminal can draw it (`advancedBlocker`: a
|
|
9
|
+
// TTY, not TERM=dumb, 80×24 at least); `cawdev --simple` and `/simple` are the
|
|
10
|
+
// scrollback terminal (`scrollback.mjs`, untouched), one command away, and the
|
|
11
|
+
// choice is remembered in prefs.mjs. It is drawn on the terminal's ALTERNATE
|
|
9
12
|
// screen — the buffer vim and emacs use — which leaves the normal screen and
|
|
10
13
|
// its scrollback exactly as they were. Leaving the mode puts them back, and
|
|
11
14
|
// what was said on the watched session meanwhile is laid into the scrollback
|
|
12
|
-
// then, so nothing is lost by having looked at it
|
|
15
|
+
// then (`/export` does it on demand), so nothing is lost by having looked at it
|
|
16
|
+
// this way.
|
|
13
17
|
//
|
|
14
18
|
// Everything that decides a picture is a pure function — the layout, one
|
|
15
19
|
// window, the whole frame — so it can be rendered at four sizes and READ,
|
|
@@ -133,7 +137,10 @@ export function windowLines(rect, {
|
|
|
133
137
|
const label = title ? ` ${clip(title, Math.max(0, inner - 3))} ` : '';
|
|
134
138
|
const top = paint(tl + h) + label + paint(h.repeat(Math.max(0, inner - 1 - visibleWidth(label))) + tr);
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
// A footer is a row, or several: keys that do not fit one wrap onto the next
|
|
141
|
+
// rather than being cut off.
|
|
142
|
+
const footers = (Array.isArray(footer) ? footer : [footer]).filter(Boolean);
|
|
143
|
+
const footerRows = footers.length;
|
|
137
144
|
const subtitleRows = subtitle ? 1 : 0;
|
|
138
145
|
const rows = Math.max(0, height - 2 - footerRows - subtitleRows);
|
|
139
146
|
const tail = tailOf(body, inner, rows);
|
|
@@ -146,8 +153,8 @@ export function windowLines(rect, {
|
|
|
146
153
|
for (let i = 0; i < rows; i++) {
|
|
147
154
|
lines.push(paint(v) + padVisible(clip(tail[i] ?? '', inner), inner) + RESET + paint(v));
|
|
148
155
|
}
|
|
149
|
-
|
|
150
|
-
lines.push(paint(v) + padVisible(clip(
|
|
156
|
+
for (const row of footers) {
|
|
157
|
+
lines.push(paint(v) + padVisible(clip(row, inner), inner) + RESET + paint(v));
|
|
151
158
|
}
|
|
152
159
|
lines.push(paint(bl + h.repeat(inner) + br));
|
|
153
160
|
return lines.slice(0, height);
|
|
@@ -486,7 +493,7 @@ export function actionsFor(meta) {
|
|
|
486
493
|
} else if (isAutoCheckout(view?.workspace) && view?.state !== 'RUNNING' && !view?.live) {
|
|
487
494
|
parts.push('/drop');
|
|
488
495
|
}
|
|
489
|
-
parts.push('
|
|
496
|
+
parts.push('w closes');
|
|
490
497
|
return parts.join(' · ');
|
|
491
498
|
}
|
|
492
499
|
|
|
@@ -1201,3 +1208,21 @@ export function cardLines(card, ink, url = null) {
|
|
|
1201
1208
|
}
|
|
1202
1209
|
return lines;
|
|
1203
1210
|
}
|
|
1211
|
+
|
|
1212
|
+
/** Advanced mode's smallest terminal. */
|
|
1213
|
+
export const ADVANCED_MIN_COLUMNS = 80;
|
|
1214
|
+
export const ADVANCED_MIN_ROWS = 24;
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Why a terminal cannot draw advanced mode, in a few words — or null when it
|
|
1218
|
+
* can: a TTY, not `TERM=dumb`, at least 80×24. Pure, so the default is a
|
|
1219
|
+
* test and not a guess.
|
|
1220
|
+
*/
|
|
1221
|
+
export function advancedBlocker({ tty, term, columns, rows }) {
|
|
1222
|
+
if (!tty) return 'no terminal to draw on (a pipe) · /advanced-mode needs a TTY';
|
|
1223
|
+
if (term === 'dumb') return 'TERM=dumb cannot draw it';
|
|
1224
|
+
if ((columns ?? 0) < ADVANCED_MIN_COLUMNS || (rows ?? 0) < ADVANCED_MIN_ROWS) {
|
|
1225
|
+
return `the terminal is ${columns ?? '?'}×${rows ?? '?'}, advanced mode needs ${ADVANCED_MIN_COLUMNS}×${ADVANCED_MIN_ROWS}`;
|
|
1226
|
+
}
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|