faberun 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/integrations/claude-code/statusline.sh +31 -2
- package/package.json +2 -2
- package/skills/faberun/references/spec-format.md +9 -0
- package/src/campaign/chain.mjs +2 -1
- package/src/campaign/index.mjs +102 -1
- package/src/campaign/metrics.mjs +2 -2
- package/src/cli/brand.mjs +2 -0
- package/src/cli/campaign-contract.mjs +55 -0
- package/src/cli/campaign.mjs +48 -13
- package/src/cli/init.mjs +8 -2
- package/src/cli/launch.mjs +2 -1
- package/src/cli/plan.mjs +3 -2
- package/src/cli/project.mjs +150 -0
- package/src/cli/skills.mjs +2 -3
- package/src/cli/update.mjs +7 -10
- package/src/cli.mjs +30 -4
- package/src/contract/index.mjs +44 -0
- package/src/contract/task-packet.mjs +12 -3
- package/src/engine/cancel.mjs +13 -0
- package/src/engine/dispatch.mjs +20 -2
- package/src/engine/lifecycle.mjs +38 -61
- package/src/engine/live-preflight.mjs +2 -1
- package/src/engine/notify-queue.mjs +9 -1
- package/src/engine/pricing-seed.json +543 -0
- package/src/engine/pricing-seed.mjs +54 -0
- package/src/engine/process.mjs +8 -1
- package/src/engine/result-file.mjs +2 -1
- package/src/engine/resume.mjs +6 -2
- package/src/engine/run-identity.mjs +9 -2
- package/src/engine/scheduler.mjs +40 -6
- package/src/engine/settle-judge.mjs +141 -0
- package/src/engine/settle.mjs +1 -1
- package/src/harnesses/protocol.mjs +46 -10
- package/src/harnesses/replay/bin.mjs +10 -1
- package/src/host/home.mjs +10 -3
- package/src/host/platform.mjs +111 -0
- package/src/host/preflight.mjs +43 -21
- package/src/host/projects.mjs +155 -0
- package/src/notify/index.mjs +88 -10
- package/src/plan/pipeline.mjs +32 -9
- package/src/plan/repo-facts.mjs +80 -2
- package/src/plan/spec.mjs +2 -1
- package/src/repo/declared-paths.mjs +7 -1
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +2 -1
- package/src/repo/signal.mjs +24 -7
- package/src/repo/source-identity.mjs +7 -0
- package/src/repo/workspace.mjs +12 -2
- package/src/repo/worktree.mjs +89 -22
- package/src/report/next.mjs +66 -6
- package/src/report/progress.mjs +724 -0
- package/src/report/render.mjs +3 -3
- package/src/run/migrate.mjs +278 -0
- package/src/run/paths.mjs +189 -0
- package/src/seat/index.mjs +2 -1
- package/src/web/app.css +170 -0
- package/src/web/app.mjs +690 -0
- package/src/web/index.html +38 -283
- package/src/web/server.mjs +197 -182
package/src/report/render.mjs
CHANGED
|
@@ -171,7 +171,7 @@ function formatElapsed(node, now) {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/** @param {number} ms @returns {string} */
|
|
174
|
-
function formatDuration(ms) {
|
|
174
|
+
export function formatDuration(ms) {
|
|
175
175
|
const totalSeconds = Math.floor(ms / 1000);
|
|
176
176
|
const hours = Math.floor(totalSeconds / 3600);
|
|
177
177
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
@@ -537,7 +537,7 @@ function readNodes(runDir, contract) {
|
|
|
537
537
|
* @param {string} runDir
|
|
538
538
|
* @returns {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}}
|
|
539
539
|
*/
|
|
540
|
-
function readRunUsage(runDir) {
|
|
540
|
+
export function readRunUsage(runDir) {
|
|
541
541
|
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: /** @type {number|null} */ (null) };
|
|
542
542
|
const path = join(runDir, "usage.jsonl");
|
|
543
543
|
if (!existsSync(path)) return totals;
|
|
@@ -733,7 +733,7 @@ export function roleCosts(nodes) {
|
|
|
733
733
|
* @param {RoleUsage} role
|
|
734
734
|
* @returns {string}
|
|
735
735
|
*/
|
|
736
|
-
function formatRole(role) {
|
|
736
|
+
export function formatRole(role) {
|
|
737
737
|
if (role.costUsd !== null) return compactCost(role.costUsd);
|
|
738
738
|
if (role.costProvenance === "none") return "-";
|
|
739
739
|
return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `faberun migrate`: the one command that moves a repository's legacy
|
|
3
|
+
* in-tree runs root into the project's directory under the home (R7).
|
|
4
|
+
*
|
|
5
|
+
* Separate from `run/paths.mjs` because that module resolves where state is
|
|
6
|
+
* read from and must keep answering throughout a migration, while this module
|
|
7
|
+
* owns the move itself, whose entire safety story is ordering. The resolver
|
|
8
|
+
* answers the home side the moment `<home>/projects/<id>/runs` exists, so the
|
|
9
|
+
* copy is never written there: it is staged at the sibling `runs.incoming`,
|
|
10
|
+
* verified, and published by one rename within the project's directory —
|
|
11
|
+
* same filesystem, so atomic. A reader therefore sees either the complete
|
|
12
|
+
* legacy tree, which keeps answering with its warning until the instant the
|
|
13
|
+
* rename lands, or the complete home copy, never a partial authoritative
|
|
14
|
+
* tree; a process killed mid-copy leaves nothing resolvable under the home.
|
|
15
|
+
*
|
|
16
|
+
* The controller lock is keyed by pid and process-start token, not by path,
|
|
17
|
+
* so the check before the copy cannot promise anything about the moment of
|
|
18
|
+
* removal: a resume that begins after that check and before the removal
|
|
19
|
+
* would have its state pulled out from under it. The re-check between the
|
|
20
|
+
* verified copy and the publish is what turns that race into a refusal, and
|
|
21
|
+
* a refused migration leaves the complete original in the tree and nothing
|
|
22
|
+
* authoritative under the home. A kill between the publish and the removal —
|
|
23
|
+
* the only two adjacent calls that touch both trees — is finished, not
|
|
24
|
+
* redone, by the next run: it verifies the published copy still holds every
|
|
25
|
+
* byte the original holds and removes the original.
|
|
26
|
+
*
|
|
27
|
+
* Idempotent by the same shape. A second run after a completed migration
|
|
28
|
+
* finds no legacy root and reports nothing to move — the normal case for an
|
|
29
|
+
* operator rerunning the command to be sure. A re-run after an interrupted
|
|
30
|
+
* one discards the staging copy it finds and copies anew: staging is never
|
|
31
|
+
* merged with, so no combination of two half-copies can ever be published.
|
|
32
|
+
*
|
|
33
|
+
* The target shape `<home>/projects/<id>/runs` and its staging sibling are
|
|
34
|
+
* composed here from `projectsDir` plus names `run/paths.mjs` keeps private;
|
|
35
|
+
* widening that module's surface for one caller is worse than spelling the
|
|
36
|
+
* two literals here, next to this comment.
|
|
37
|
+
*/
|
|
38
|
+
import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { campaignsDir } from "../campaign/layout.mjs";
|
|
41
|
+
import { readRemotes } from "../cli/project.mjs";
|
|
42
|
+
import { faberunHome } from "../host/home.mjs";
|
|
43
|
+
import { projectsDir, registerProject } from "../host/projects.mjs";
|
|
44
|
+
import { lockStale, readLock } from "./lock.mjs";
|
|
45
|
+
import { RUNS_DIR_NAME } from "./paths.mjs";
|
|
46
|
+
import { errorCode } from "../util.mjs";
|
|
47
|
+
|
|
48
|
+
/** @typedef {{runDir: string, pid: number|undefined}} LiveLease */
|
|
49
|
+
/** @typedef {{moved: boolean, legacy: string, target: string|null, runs: number, campaigns: number}} MigrateResult */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `rmSync`'s own retry knobs for a recursive removal, applied to every
|
|
53
|
+
* removal in this module: Node's own docs name a large recursive removal as
|
|
54
|
+
* the case `maxRetries`/`retryDelay` exist for, retrying on EBUSY, EMFILE,
|
|
55
|
+
* ENFILE, ENOTEMPTY and EPERM. Measured 2026-09-19: a bare
|
|
56
|
+
* `rmSync(legacy, { recursive: true })` raised ENOTEMPTY twice running
|
|
57
|
+
* against this repository's own `.runs` — thousands of files across many
|
|
58
|
+
* attempt worktrees and controller snapshots — with no concurrent writer of
|
|
59
|
+
* this module's own; a fresh copy had already verified byte for byte before
|
|
60
|
+
* either attempt, so this was the removal racing something transient
|
|
61
|
+
* (Spotlight, a `.DS_Store` write, an FS event listener), never a sign the
|
|
62
|
+
* data was unsafe to remove.
|
|
63
|
+
*/
|
|
64
|
+
const REMOVE_OPTIONS = { recursive: true, maxRetries: 5, retryDelay: 100 };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Move the legacy runs root at `<cwd>/.runs` into the home layout:
|
|
68
|
+
* `<home>/projects/<project id>/runs` for the project `cwd` registers as,
|
|
69
|
+
* with its git remotes recorded so a later repository move can be
|
|
70
|
+
* reassociated by remote. Fully synchronous, like every reader of run state
|
|
71
|
+
* in this tree.
|
|
72
|
+
*
|
|
73
|
+
* The migration refuses — before anything is written — while any run under
|
|
74
|
+
* the legacy root holds a live controller lock, and refuses again after the
|
|
75
|
+
* copy has verified, before the copy is published and the original removed.
|
|
76
|
+
*
|
|
77
|
+
* `options.leasesOf` replaces the live-lease scan. It exists so the
|
|
78
|
+
* between-copy-and-removal re-check has a named test: a fully synchronous
|
|
79
|
+
* migration cannot be interrupted from outside, so the race is staged from
|
|
80
|
+
* the injected scan instead.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} cwd
|
|
83
|
+
* @param {{home?: string, leasesOf?: (tree: string) => LiveLease[]}} [options]
|
|
84
|
+
* @returns {MigrateResult}
|
|
85
|
+
*/
|
|
86
|
+
export function migrateRunState(cwd, options = {}) {
|
|
87
|
+
const home = options.home ?? faberunHome();
|
|
88
|
+
const leasesOf = options.leasesOf ?? liveLeases;
|
|
89
|
+
const legacy = join(cwd, RUNS_DIR_NAME);
|
|
90
|
+
if (!existsSync(legacy)) return { moved: false, legacy, target: null, runs: 0, campaigns: 0 };
|
|
91
|
+
refuseLiveLeases(leasesOf(legacy));
|
|
92
|
+
const project = registerProject(home, cwd, readRemotes(cwd));
|
|
93
|
+
const projectDir = join(projectsDir(home), project.id);
|
|
94
|
+
const target = join(projectDir, "runs");
|
|
95
|
+
// A published copy beside an untouched original is the one kill this
|
|
96
|
+
// ordering cannot route through staging: the previous attempt got through
|
|
97
|
+
// the rename and died before the removal. The copy needs no re-copying —
|
|
98
|
+
// only proof that it still holds every byte the original holds, after
|
|
99
|
+
// which removing the original finishes the move. Paths under the published
|
|
100
|
+
// copy that the original lacks are runs controllers appended after it
|
|
101
|
+
// became authoritative; this branch never writes to the copy, so nothing
|
|
102
|
+
// of theirs is at risk.
|
|
103
|
+
if (existsSync(target)) {
|
|
104
|
+
verifyCopy(legacy, target);
|
|
105
|
+
const runs = countRunDirs(legacy);
|
|
106
|
+
const campaigns = countCampaigns(legacy);
|
|
107
|
+
refuseLiveLeases(leasesOf(legacy));
|
|
108
|
+
rmSync(legacy, REMOVE_OPTIONS);
|
|
109
|
+
return { moved: true, legacy, target, runs, campaigns };
|
|
110
|
+
}
|
|
111
|
+
const staging = join(projectDir, "runs.incoming");
|
|
112
|
+
// A staging left by an interrupted attempt is a partial copy of a tree
|
|
113
|
+
// that may have changed since; it is discarded, never merged with.
|
|
114
|
+
rmSync(staging, { ...REMOVE_OPTIONS, force: true });
|
|
115
|
+
// verbatimSymlinks: the default resolves a copied link's target to an
|
|
116
|
+
// absolute path back into the tree being removed, which would dangle the
|
|
117
|
+
// moment the removal ran; verbatim keeps the literal target, so relative
|
|
118
|
+
// links (the worktrees' dependency symlinks) survive the move.
|
|
119
|
+
cpSync(legacy, staging, { recursive: true, verbatimSymlinks: true });
|
|
120
|
+
verifyCopy(legacy, staging);
|
|
121
|
+
verifyNothingExtra(legacy, staging);
|
|
122
|
+
const runs = countRunDirs(legacy);
|
|
123
|
+
const campaigns = countCampaigns(legacy);
|
|
124
|
+
// A lease found here began after the first check: its controller is still
|
|
125
|
+
// resolving the legacy tree — the copy is not published yet, so there is
|
|
126
|
+
// nothing else to resolve — and the publish would strand it on a tree
|
|
127
|
+
// about to be removed. Refusing leaves the complete original answering and
|
|
128
|
+
// nothing authoritative under the home; the re-run once the holder exits
|
|
129
|
+
// discards the staging, copies anew, and publishes.
|
|
130
|
+
refuseLiveLeases(leasesOf(legacy));
|
|
131
|
+
// The publish is the rename, and the rename is the only thing that ever
|
|
132
|
+
// creates `runs`: from this instant the home side is authoritative and
|
|
133
|
+
// complete, and nothing reads the legacy tree again except this removal.
|
|
134
|
+
renameSync(staging, target);
|
|
135
|
+
rmSync(legacy, REMOVE_OPTIONS);
|
|
136
|
+
return { moved: true, legacy, target, runs, campaigns };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Every controller lock under `tree` whose holder is still alive. The lock
|
|
141
|
+
* file's name is spelled here because `run/lock.mjs` keeps the constant
|
|
142
|
+
* private and this scan reads a whole tree, not one run directory. Symlinked
|
|
143
|
+
* directories are not descended into: the locks that lease this tree are the
|
|
144
|
+
* ones inside it.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} tree
|
|
147
|
+
* @returns {LiveLease[]}
|
|
148
|
+
*/
|
|
149
|
+
function liveLeases(tree) {
|
|
150
|
+
/** @type {LiveLease[]} */
|
|
151
|
+
const leases = [];
|
|
152
|
+
/** @param {string} dir @returns {void} */
|
|
153
|
+
const walk = (dir) => {
|
|
154
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
155
|
+
const path = join(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
walk(path);
|
|
158
|
+
} else if (entry.name === "controller.lock") {
|
|
159
|
+
const lock = readLock(dir);
|
|
160
|
+
if (!lockStale(lock)) {
|
|
161
|
+
leases.push({ runDir: dir, pid: lock && !("invalid" in lock) ? lock.pid : undefined });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
walk(tree);
|
|
167
|
+
return leases;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {LiveLease[]} leases @returns {void} */
|
|
171
|
+
function refuseLiveLeases(leases) {
|
|
172
|
+
const lease = leases[0];
|
|
173
|
+
if (lease) {
|
|
174
|
+
throw new Error(`run ${lease.runDir} holds a live controller lock (pid ${lease.pid ?? "unknown"}); stop the run or wait for its controller to exit, then run migrate again`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What verify means here: every path under `source` must exist under `copy`
|
|
180
|
+
* with the same content — directories recursively, symlinks by their literal
|
|
181
|
+
* target, everything else byte for byte. That comparison is exactly the
|
|
182
|
+
* guarantee removal needs: once the original is gone the copy is the only
|
|
183
|
+
* holder of this state, so "holds every byte the original held" is the whole
|
|
184
|
+
* of what must be true, and byte-for-byte equality is the strongest check
|
|
185
|
+
* that proves it without trusting the copy step's own bookkeeping.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} source
|
|
188
|
+
* @param {string} copy
|
|
189
|
+
* @returns {void}
|
|
190
|
+
*/
|
|
191
|
+
function verifyCopy(source, copy) {
|
|
192
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
193
|
+
const from = join(source, entry.name);
|
|
194
|
+
const to = join(copy, entry.name);
|
|
195
|
+
if (entry.isDirectory()) {
|
|
196
|
+
// lstat, not stat: a copied symlink to a directory must not pass as
|
|
197
|
+
// the directory it points at.
|
|
198
|
+
if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to);
|
|
199
|
+
verifyCopy(from, to);
|
|
200
|
+
} else if (entry.isSymbolicLink()) {
|
|
201
|
+
if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to);
|
|
202
|
+
} else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The staging copy must also hold nothing the original lacks. It was created
|
|
208
|
+
* by discarding any earlier staging and copying in one step, so an extra
|
|
209
|
+
* path means the copy step wrote outside its instructions — and the publish
|
|
210
|
+
* would make that invented state authoritative.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} source
|
|
213
|
+
* @param {string} copy
|
|
214
|
+
* @returns {void}
|
|
215
|
+
*/
|
|
216
|
+
function verifyNothingExtra(source, copy) {
|
|
217
|
+
for (const entry of readdirSync(copy, { withFileTypes: true })) {
|
|
218
|
+
const inSource = join(source, entry.name);
|
|
219
|
+
const inCopy = join(copy, entry.name);
|
|
220
|
+
if (entry.isDirectory()) {
|
|
221
|
+
if (!lstatSync(inSource, { throwIfNoEntry: false })?.isDirectory()) throw verifyExtraFailure(inCopy);
|
|
222
|
+
verifyNothingExtra(inSource, inCopy);
|
|
223
|
+
} else if (entry.isSymbolicLink()) {
|
|
224
|
+
// readlinkSync, never readFileSync: a worktree's node_modules symlink
|
|
225
|
+
// (prepareWorktreeEnvironment in repo/worktree.mjs) targets a
|
|
226
|
+
// directory, and readFileSync on a symlink dereferences it — measured
|
|
227
|
+
// 2026-09-19, this crashed migrate with EISDIR against this
|
|
228
|
+
// repository's own .runs tree before the branch below existed.
|
|
229
|
+
if (readLinkOrUndefined(inSource) === undefined) throw verifyExtraFailure(inCopy);
|
|
230
|
+
} else if (readOrUndefined(inSource) === undefined) {
|
|
231
|
+
throw verifyExtraFailure(inCopy);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {string} from @param {string} to @returns {Error} */
|
|
237
|
+
function verifyFailure(from, to) {
|
|
238
|
+
return new Error(`migration copy does not verify: ${from} is missing or different at ${to}; nothing was published or removed`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** @param {string} path @returns {Error} */
|
|
242
|
+
function verifyExtraFailure(path) {
|
|
243
|
+
return new Error(`migration copy does not verify: ${path} is not part of the original; nothing was published or removed`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @param {string} path @returns {Buffer|undefined} */
|
|
247
|
+
function readOrUndefined(path) {
|
|
248
|
+
try {
|
|
249
|
+
return readFileSync(path);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @param {string} path @returns {string|undefined} */
|
|
257
|
+
function readLinkOrUndefined(path) {
|
|
258
|
+
try {
|
|
259
|
+
return readlinkSync(path);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {string} runsDir @returns {number} */
|
|
267
|
+
function countRunDirs(runsDir) {
|
|
268
|
+
return readdirSync(runsDir, { withFileTypes: true })
|
|
269
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(runsDir, entry.name, "contract.json")))
|
|
270
|
+
.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {string} runsDir @returns {number} */
|
|
274
|
+
function countCampaigns(runsDir) {
|
|
275
|
+
const campaigns = campaignsDir(runsDir);
|
|
276
|
+
if (!existsSync(campaigns)) return 0;
|
|
277
|
+
return readdirSync(campaigns, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
|
|
278
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a run, a campaign and a worktree tree live on disk, and the name of
|
|
3
|
+
* the runs directory itself.
|
|
4
|
+
*
|
|
5
|
+
* Every one of these shapes used to be spelled out inline, independently, at
|
|
6
|
+
* each call site that needed it, all sharing the literal `".runs"`. A later
|
|
7
|
+
* move or rename of that directory had to find and change every one of them
|
|
8
|
+
* separately; this module is the one place such a change is made instead. It
|
|
9
|
+
* composes `campaign/layout.mjs`, which already owns the campaign directory
|
|
10
|
+
* shape given a runs root, rather than re-deriving it.
|
|
11
|
+
*
|
|
12
|
+
* Since R2 the runs root answers from the operator's home, not the
|
|
13
|
+
* repository: `runsRoot(cwd)` is `<home>/projects/<id>/runs` for the project
|
|
14
|
+
* `cwd` belongs to, so run state survives a repository move and never lands
|
|
15
|
+
* inside the target tree. A repository that still holds its own `.runs` from
|
|
16
|
+
* before the move keeps being answered there until the migration command has
|
|
17
|
+
* run, because answering the home side of a not-yet-migrated repository would
|
|
18
|
+
* split its state across two layouts.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
22
|
+
import { campaignDir, campaignsDir } from "../campaign/layout.mjs";
|
|
23
|
+
import { faberunHome } from "../host/home.mjs";
|
|
24
|
+
import { findProjectByPath, projectsDir, readProject, registerProject } from "../host/projects.mjs";
|
|
25
|
+
|
|
26
|
+
/** The runs directory name, relative to the tree it lives under. */
|
|
27
|
+
export const RUNS_DIR_NAME = ".runs";
|
|
28
|
+
|
|
29
|
+
/** The runs directory name inside a project's directory under the home. */
|
|
30
|
+
const PROJECT_RUNS_DIR_NAME = "runs";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The runs directory for a working tree: the project's runs directory under
|
|
34
|
+
* `faberunHome()`, or — for a repository that still holds its own `.runs`
|
|
35
|
+
* and has nothing under the home yet — that directory, with a warning.
|
|
36
|
+
*
|
|
37
|
+
* Resolving registers a never-seen path on demand. The alternative is a
|
|
38
|
+
* separate init step before the first run of any repository, which no
|
|
39
|
+
* existing workflow performs; a resolve is how every consumer announces the
|
|
40
|
+
* repository it is working on, so it is also where the registration belongs.
|
|
41
|
+
* The registry write happens once per path: a known project resolves through
|
|
42
|
+
* two small read-only reads (the index, the record).
|
|
43
|
+
*
|
|
44
|
+
* When both layouts exist the home side wins. That is the mid-migration
|
|
45
|
+
* case: the migration command copies the repository's state into the home
|
|
46
|
+
* and removes the original afterwards, so a present home directory is the
|
|
47
|
+
* authoritative copy and the leftover `.runs` is the stale pre-copy one.
|
|
48
|
+
* Letting the repository side win would make a completed migration whose
|
|
49
|
+
* cleanup failed silently answer from state the migration already superseded.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function runsRoot(cwd) {
|
|
55
|
+
const home = faberunHome();
|
|
56
|
+
const project = findProjectByPath(home, cwd);
|
|
57
|
+
const homeRuns = project ? projectRunsDir(home, project.id) : null;
|
|
58
|
+
if (homeRuns && existsSync(homeRuns)) return homeRuns;
|
|
59
|
+
const legacy = join(cwd, RUNS_DIR_NAME);
|
|
60
|
+
if (existsSync(legacy)) {
|
|
61
|
+
warnLegacyRuns(legacy);
|
|
62
|
+
return legacy;
|
|
63
|
+
}
|
|
64
|
+
const registered = project ?? registerProject(home, cwd);
|
|
65
|
+
return projectRunsDir(home, registered.id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} home @param {string} id @returns {string} */
|
|
69
|
+
function projectRunsDir(home, id) {
|
|
70
|
+
return join(projectsDir(home), id, PROJECT_RUNS_DIR_NAME);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let warnedLegacyRuns = false;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One warning per process, not per resolve: the resolver sits under a hot
|
|
77
|
+
* path, and repeating the same stderr line at every call would bury whatever
|
|
78
|
+
* the command is actually printing.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} legacyPath
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
function warnLegacyRuns(legacyPath) {
|
|
84
|
+
if (warnedLegacyRuns) return;
|
|
85
|
+
warnedLegacyRuns = true;
|
|
86
|
+
process.stderr.write(`[warn] runs · ${legacyPath} still holds this repository's runs; move them under the home with \`faberun migrate\`\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The project id a runs directory under the home layout carries, or null for
|
|
91
|
+
* any other shape — a legacy `<repo>/.runs` answers null because
|
|
92
|
+
* `RUNS_DIR_NAME` is dotted and `PROJECT_RUNS_DIR_NAME` is not.
|
|
93
|
+
*
|
|
94
|
+
* `<home>/projects/<id>/runs` is composed entirely by this module, so the id
|
|
95
|
+
* sits at a fixed position: between the `projects` segment and the runs
|
|
96
|
+
* directory itself. Readers that must name the repository a runs directory
|
|
97
|
+
* belongs to (the AGENTS.md signal block, campaign ledger preservation) parse
|
|
98
|
+
* it here instead of deriving a sibling path by directory arithmetic, which
|
|
99
|
+
* only held while runs lived inside the repository.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} runsDir
|
|
102
|
+
* @returns {string|null}
|
|
103
|
+
*/
|
|
104
|
+
export function projectIdForRunsDir(runsDir) {
|
|
105
|
+
const parts = resolve(runsDir).split(sep);
|
|
106
|
+
const id = parts.at(-2);
|
|
107
|
+
if (parts.at(-3) !== "projects" || parts.at(-1) !== PROJECT_RUNS_DIR_NAME || !id) return null;
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The repository path a runs directory belongs to, read live from the
|
|
113
|
+
* project registry, or null when the runs directory is not under the home
|
|
114
|
+
* layout or its project has no record. Live, never cached: a project
|
|
115
|
+
* reassociated to a new path must answer its current repository.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} runsDir
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
export function repositoryForRunsDir(runsDir) {
|
|
121
|
+
const id = projectIdForRunsDir(runsDir);
|
|
122
|
+
if (!id) return null;
|
|
123
|
+
return readProject(faberunHome(), id)?.path ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A single run's own directory.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} cwd
|
|
130
|
+
* @param {string} runId
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function runDirectory(cwd, runId) {
|
|
134
|
+
return join(runsRoot(cwd), runId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The campaigns directory beneath a runs root: `<runs>/campaigns`.
|
|
139
|
+
* Composes `campaign/layout.mjs`'s `campaignsDir`, which owns the shape given
|
|
140
|
+
* a runs root.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} cwd
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function campaignsRoot(cwd) {
|
|
146
|
+
return campaignsDir(runsRoot(cwd));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A single campaign's own directory: `<runs>/campaigns/<campaignId>`.
|
|
151
|
+
* Composes `campaign/layout.mjs`'s `campaignDir`, which owns the shape given
|
|
152
|
+
* a runs root.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} cwd
|
|
155
|
+
* @param {string} campaignId
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function campaignTree(cwd, campaignId) {
|
|
159
|
+
return campaignDir(runsRoot(cwd), campaignId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {string} runDir @param {string} runId @returns {string} */
|
|
163
|
+
function worktreeRoot(runDir, runId) {
|
|
164
|
+
return join(dirname(runDir), "worktrees", runId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The isolated worktree for one attempt at one node.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} runDir
|
|
171
|
+
* @param {string} runId
|
|
172
|
+
* @param {string} nodeId
|
|
173
|
+
* @param {number} attempt
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function attemptWorktreePath(runDir, runId, nodeId, attempt) {
|
|
177
|
+
return join(worktreeRoot(runDir, runId), `${nodeId}.${attempt}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The detached worktree an integration candidate is verified in.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} runDir
|
|
184
|
+
* @param {string} runId
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
export function candidateWorktreePath(runDir, runId) {
|
|
188
|
+
return join(worktreeRoot(runDir, runId), ".candidate");
|
|
189
|
+
}
|
package/src/seat/index.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { briefFromState, materializeBrief } from "../campaign/brief.mjs";
|
|
|
18
18
|
import { readProjectionState } from "../campaign/projection.mjs";
|
|
19
19
|
import { resolveCampaign } from "../campaign/index.mjs";
|
|
20
20
|
import { errorMessage } from "../util.mjs";
|
|
21
|
+
import { runsRoot } from "../run/paths.mjs";
|
|
21
22
|
|
|
22
23
|
/** `seat status --json` payload version. */
|
|
23
24
|
const SEAT_STATUS_SCHEMA_VERSION = 1;
|
|
@@ -117,7 +118,7 @@ export function stopSeat(options = {}) {
|
|
|
117
118
|
*/
|
|
118
119
|
export function switchSeat(options) {
|
|
119
120
|
const cwd = resolve(options.cwd ?? ".");
|
|
120
|
-
const runsDir =
|
|
121
|
+
const runsDir = runsRoot(cwd);
|
|
121
122
|
let resolved;
|
|
122
123
|
try {
|
|
123
124
|
resolved = resolveCampaign(runsDir, options.campaign);
|
package/src/web/app.css
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--fr-terra: #B5522A; --fr-terra-300: #E0875F;
|
|
3
|
+
--fr-argila: #D97B4F; --fr-argila-300: #ECAB86;
|
|
4
|
+
--fr-areia: #F4E9D8;
|
|
5
|
+
--fr-folha: #556B3F; --fr-folha-300: #93AA70;
|
|
6
|
+
--fr-carvao: #1F1F1F;
|
|
7
|
+
|
|
8
|
+
--bg: var(--fr-areia); --surface: #ffffff; --surface-2: #ece3d2; --border: #d8cbb0;
|
|
9
|
+
--text: var(--fr-carvao); --text-2: #4a4437; --text-3: #7a725e;
|
|
10
|
+
--ok: var(--fr-folha); --ok-soft: #e3ead9;
|
|
11
|
+
--warn: var(--fr-argila); --warn-soft: #f7e6da;
|
|
12
|
+
--fail: var(--fr-terra); --fail-soft: #f5ded6;
|
|
13
|
+
--muted: #8a8471; --muted-soft: #e9e4d5;
|
|
14
|
+
--edge: #b8ac8e;
|
|
15
|
+
|
|
16
|
+
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--bg: var(--fr-carvao); --surface: #262620; --surface-2: #2e2d26; --border: #45433a;
|
|
22
|
+
--text: var(--fr-areia); --text-2: #cfc7b3; --text-3: #a39c88;
|
|
23
|
+
--ok: var(--fr-folha-300); --ok-soft: #33402766;
|
|
24
|
+
--warn: var(--fr-argila-300); --warn-soft: #4a352566;
|
|
25
|
+
--fail: var(--fr-terra-300); --fail-soft: #4a2a1e66;
|
|
26
|
+
--muted: #96906f; --muted-soft: #3a382d;
|
|
27
|
+
--edge: #55523f;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
* { box-sizing: border-box; }
|
|
31
|
+
[hidden] { display: none !important; }
|
|
32
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); }
|
|
33
|
+
a { color: var(--fr-terra); }
|
|
34
|
+
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
|
|
35
|
+
:focus-visible { outline: 2px solid var(--fr-terra); outline-offset: 2px; }
|
|
36
|
+
.mono { font-family: var(--mono); font-size: 12.5px; }
|
|
37
|
+
.dim { color: var(--text-2); }
|
|
38
|
+
.empty { color: var(--text-3); padding: 10px 0; }
|
|
39
|
+
|
|
40
|
+
#top {
|
|
41
|
+
display: flex; align-items: center; gap: 16px; padding: 12px 20px;
|
|
42
|
+
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
43
|
+
}
|
|
44
|
+
#top .brand { font-weight: 700; color: var(--fr-terra); }
|
|
45
|
+
.field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
46
|
+
.field select { font: inherit; text-transform: none; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; }
|
|
47
|
+
#top .spacer { flex: 1; }
|
|
48
|
+
#conn { font-size: 12px; color: var(--text-2); display: inline-flex; align-items: center; gap: 6px; }
|
|
49
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
|
|
50
|
+
.dot.live { background: var(--ok); }
|
|
51
|
+
.dot.lost { background: var(--fail); }
|
|
52
|
+
|
|
53
|
+
#main { max-width: 1180px; margin: 0 auto; padding: 24px 20px 60px; }
|
|
54
|
+
.band { margin-top: 26px; }
|
|
55
|
+
.band > h2 { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-3); margin: 0 0 10px; }
|
|
56
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
|
|
57
|
+
.caveat { color: var(--text-2); font-size: 12.5px; margin: 0 0 12px; }
|
|
58
|
+
|
|
59
|
+
/* The opening band: the owner's four questions, answered the way the
|
|
60
|
+
notification answers them. It leads the page at every width. */
|
|
61
|
+
.summaryband { display: flex; flex-direction: column; gap: 10px; }
|
|
62
|
+
.sumhead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
63
|
+
.sumname { font-weight: 700; font-size: 15px; color: var(--fr-terra); }
|
|
64
|
+
@media (prefers-color-scheme: dark) { .sumname { color: var(--fr-terra-300); } }
|
|
65
|
+
.sumgoal { color: var(--text-2); font-size: 13px; }
|
|
66
|
+
.sumrow { font-size: 13px; }
|
|
67
|
+
.sumrow.progressline { display: flex; align-items: center; gap: 12px; }
|
|
68
|
+
.progresstrack { flex: 1; min-width: 120px; height: 8px; border-radius: 4px; background: var(--surface-2); border: 1px solid var(--border); overflow: hidden; }
|
|
69
|
+
.progressfill { height: 100%; background: var(--ok); }
|
|
70
|
+
.sumcounts { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
71
|
+
|
|
72
|
+
/* The collapsed spec map: a reference table a reader opens, styled so the
|
|
73
|
+
closed summary reads as the band heading it replaced. */
|
|
74
|
+
.specdetails > summary { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-3); cursor: pointer; margin: 0 0 10px; display: inline-block; list-style: revert; }
|
|
75
|
+
.specdetails > summary:hover { color: var(--text-2); }
|
|
76
|
+
|
|
77
|
+
.specmap { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
|
78
|
+
.specfront h3 { font-size: 12.5px; margin: 0 0 8px; color: var(--fr-terra); }
|
|
79
|
+
.reqrow { border-top: 1px solid var(--border); padding: 8px 0; }
|
|
80
|
+
.reqrow:first-of-type { border-top: none; }
|
|
81
|
+
.reqid { color: var(--text-3); }
|
|
82
|
+
.reqstatement { font-size: 12.5px; margin: 2px 0; }
|
|
83
|
+
.reqphase { font-size: 12px; color: var(--text-2); }
|
|
84
|
+
|
|
85
|
+
/* `display: block` on the svg is load-bearing: a replaced inline element
|
|
86
|
+
(the svg default) inside an `overflow-x: auto` block can be sized as
|
|
87
|
+
though it never overflows, so the container never grows a scrollbar and
|
|
88
|
+
the trailing box (measured: the chain band's Release box) is cut off at
|
|
89
|
+
the viewport edge instead of reachable by scroll. */
|
|
90
|
+
.graphcard { overflow-x: auto; width: 100%; }
|
|
91
|
+
.graphcard svg { display: block; }
|
|
92
|
+
.phasehead { margin: 0 0 10px; font-size: 13px; }
|
|
93
|
+
.phasehead .phasename { font-weight: 650; }
|
|
94
|
+
.phasehead p { margin: 2px 0 0; }
|
|
95
|
+
|
|
96
|
+
svg .edge { stroke: var(--edge); stroke-width: 2; fill: none; }
|
|
97
|
+
svg .node rect, svg .stage rect { stroke: var(--border); stroke-width: 1; fill: var(--muted-soft); cursor: pointer; }
|
|
98
|
+
svg .node text, svg .stage text { fill: var(--text); font-family: var(--sans); font-size: 12px; }
|
|
99
|
+
svg .node text.mono, svg .stage text.mono { font-family: var(--mono); }
|
|
100
|
+
svg .node text.node-status, svg .stage text.stage-state { fill: var(--text-2); font-size: 11px; }
|
|
101
|
+
svg .node.ok rect, svg .stage.done rect { fill: var(--ok-soft); stroke: var(--ok); }
|
|
102
|
+
svg .node.progress rect, svg .stage.active rect { fill: var(--warn-soft); stroke: var(--warn); }
|
|
103
|
+
svg .node.fail rect { fill: var(--fail-soft); stroke: var(--fail); }
|
|
104
|
+
svg .node.muted rect, svg .stage.not_started rect { fill: var(--muted-soft); stroke: var(--muted); }
|
|
105
|
+
svg .stage.current rect { stroke-width: 2.5; }
|
|
106
|
+
svg .node:focus-visible rect, svg .stage:focus-visible rect { outline: 2px solid var(--fr-terra); outline-offset: 1px; }
|
|
107
|
+
|
|
108
|
+
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 650; }
|
|
109
|
+
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
110
|
+
.pill.ok { color: var(--ok); background: var(--ok-soft); }
|
|
111
|
+
.pill.progress { color: var(--warn); background: var(--warn-soft); }
|
|
112
|
+
.pill.warn { color: var(--warn); background: var(--warn-soft); }
|
|
113
|
+
.pill.fail { color: var(--fail); background: var(--fail-soft); }
|
|
114
|
+
.pill.muted { color: var(--muted); background: var(--muted-soft); }
|
|
115
|
+
|
|
116
|
+
.drilldown-head { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; }
|
|
117
|
+
.drilldown-head h3 { margin: 0; font-size: 15px; }
|
|
118
|
+
.drows { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px 16px; margin-bottom: 14px; }
|
|
119
|
+
.drow { display: flex; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); padding: 4px 0; font-size: 13px; }
|
|
120
|
+
.dlabel { color: var(--text-3); text-transform: uppercase; font-size: 11px; letter-spacing: 0.03em; }
|
|
121
|
+
.dvalue { font-family: var(--mono); font-size: 12.5px; text-align: right; }
|
|
122
|
+
|
|
123
|
+
.detailblock { margin-top: 14px; }
|
|
124
|
+
.detailblock h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 0 0 6px; }
|
|
125
|
+
.link { padding: 2px 0; word-break: break-all; }
|
|
126
|
+
.finding { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|
127
|
+
.finding:last-child { border-bottom: none; }
|
|
128
|
+
|
|
129
|
+
/* The worker transcript, one step per line: what the worker did, not the
|
|
130
|
+
wire format it did it in. The raw file the On-disk block names stays the
|
|
131
|
+
place to read the JSONL itself. */
|
|
132
|
+
.tstep { padding: 5px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
|
|
133
|
+
.tstep:last-child { border-bottom: none; }
|
|
134
|
+
.tstep .mono { word-break: break-all; }
|
|
135
|
+
.tstep.error { color: var(--fail); }
|
|
136
|
+
.tstep.message { color: var(--text); }
|
|
137
|
+
.tstep.note { color: var(--text-2); }
|
|
138
|
+
pre { white-space: pre-wrap; word-break: break-word; background: var(--surface-2); border-radius: 8px; padding: 10px 12px; font-family: var(--mono); font-size: 12px; max-height: 360px; overflow: auto; }
|
|
139
|
+
|
|
140
|
+
.timelinelist { display: flex; flex-direction: column; gap: 6px; }
|
|
141
|
+
.trow { display: grid; grid-template-columns: 130px 140px 1fr; gap: 8px; font-size: 12.5px; border-bottom: 1px solid var(--border); padding: 4px 0; }
|
|
142
|
+
.trow .tat { color: var(--text-3); }
|
|
143
|
+
.trow .ttype { color: var(--fr-terra); font-weight: 600; }
|
|
144
|
+
|
|
145
|
+
/* Narrow viewport (phone widths, ~390px and up to a small tablet): the
|
|
146
|
+
header stacks instead of clipping, the spec map collapses to one column,
|
|
147
|
+
and the bands reorder so the summary — the four answers — leads and the
|
|
148
|
+
open phase follows, with the spec map (a static requirement reference)
|
|
149
|
+
last. The markup is the same document at every width; only this media
|
|
150
|
+
query moves things, via `order` on the `#main` flex container below,
|
|
151
|
+
never by emitting different HTML for the two cases. */
|
|
152
|
+
@media (max-width: 600px) {
|
|
153
|
+
#top { flex-wrap: wrap; row-gap: 8px; }
|
|
154
|
+
#top .field { flex: 1 1 100%; }
|
|
155
|
+
#top .field select { width: 100%; }
|
|
156
|
+
#top .spacer { flex: 0 0 0; display: none; }
|
|
157
|
+
#conn { flex: 1 1 100%; justify-content: flex-end; }
|
|
158
|
+
|
|
159
|
+
#main { display: flex; flex-direction: column; }
|
|
160
|
+
#summary { order: 1; }
|
|
161
|
+
#phaseGraph { order: 2; }
|
|
162
|
+
#chain { order: 3; }
|
|
163
|
+
#drilldown { order: 4; }
|
|
164
|
+
#specMap { order: 5; }
|
|
165
|
+
|
|
166
|
+
.specmap { grid-template-columns: 1fr; }
|
|
167
|
+
|
|
168
|
+
.goalclamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; cursor: pointer; }
|
|
169
|
+
.goalclamp:focus { -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
|
|
170
|
+
}
|