@troylar/flightdeck 1.17.0 → 1.19.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/CHANGELOG.md +31 -0
- package/README.md +8 -0
- package/bin/flightdeck.js +526 -16
- package/dist/flightdeck.html +2353 -128
- package/package.json +1 -1
package/bin/flightdeck.js
CHANGED
|
@@ -14,8 +14,10 @@ const APP = path.join(__dirname, '..', 'dist', 'flightdeck.html');
|
|
|
14
14
|
const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills', AGENTS = 'AGENTS.md';
|
|
15
15
|
const BEGIN = '<!-- flightdeck:begin -->', END = '<!-- flightdeck:end -->';
|
|
16
16
|
/* --check exit codes, for scripts: nothing to do, updates to install, files that need a person (or an assistant) to merge.
|
|
17
|
-
export: 0 a complete pack, 4 a partial pack (made, with what's unresolved listed), 1 no pack (any partial file removed).
|
|
18
|
-
|
|
17
|
+
export: 0 a complete pack, 4 a partial pack (made, with what's unresolved listed), 1 no pack (any partial file removed).
|
|
18
|
+
Reading for other apps (processes, process, runs, run, next, followups): 0 complete, 4 partial (data printed, every
|
|
19
|
+
problem listed), 1 no data, 5 no such process, version or run. */
|
|
20
|
+
const EXIT = { none: 0, error: 1, updates: 2, merge: 3, partial: 4, notFound: 5 };
|
|
19
21
|
|
|
20
22
|
class Refused extends Error {}
|
|
21
23
|
/* A file that changed between the moment install read it and the moment it would replace it. */
|
|
@@ -122,11 +124,14 @@ function safeWrite(dir, parts, text, expect) {
|
|
|
122
124
|
const now = lstatOrNull(tmp);
|
|
123
125
|
if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
|
|
124
126
|
if (!fs.readFileSync(tmp).equals(data)) throw new Error('the written file didn\'t read back the same');
|
|
127
|
+
const hooks = safeWrite.hooks || {};
|
|
128
|
+
if (hooks.beforeCompare) hooks.beforeCompare(target);
|
|
125
129
|
if (expect !== undefined) {
|
|
126
130
|
const st = lstatOrNull(target);
|
|
127
131
|
if (st && (st.isSymbolicLink() || !st.isFile())) fail('refusing to write through a link: ' + target);
|
|
128
132
|
if ((st ? fs.readFileSync(target, 'utf8') : null) !== expect) throw new Changed(parts.join('/'));
|
|
129
133
|
}
|
|
134
|
+
if (hooks.afterCompare) hooks.afterCompare(target);
|
|
130
135
|
fs.renameSync(tmp, target);
|
|
131
136
|
moved = true;
|
|
132
137
|
} catch (e) {
|
|
@@ -378,10 +383,10 @@ function backup(src, toArg) {
|
|
|
378
383
|
}
|
|
379
384
|
function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
|
|
380
385
|
|
|
381
|
-
/* ----------
|
|
386
|
+
/* ---------- The engine: export and reading for other apps run the app's own rules ---------- */
|
|
382
387
|
/* The app's own rules, from this installed package's dist/flightdeck.html and nowhere else (never a team folder's copy):
|
|
383
388
|
its core and storage blocks, run in node:vm without the screens. */
|
|
384
|
-
function
|
|
389
|
+
function loadEngine() {
|
|
385
390
|
const html = app(), block = (id) => {
|
|
386
391
|
const m = new RegExp('<script id="' + id + '">([\\s\\S]*?)</script>').exec(html);
|
|
387
392
|
if (!m) fail('the app file in this install has no ' + id + ' block (' + APP + '). Reinstall the package.');
|
|
@@ -395,10 +400,32 @@ function engine() {
|
|
|
395
400
|
/* The team folder for the collector: list, read, stat and blob, read-only, and never through a link. Every folder on the
|
|
396
401
|
way to a path is checked with lstat (a linked folder is refused), a link in a listing is reported as a link and never
|
|
397
402
|
opened, and a file is opened without following a link and must be the very file lstat saw (device and inode), so one
|
|
398
|
-
swapped for a link after it was listed is refused too. links collects what was skipped.
|
|
399
|
-
|
|
400
|
-
|
|
403
|
+
swapped for a link after it was listed is refused too. links collects what was skipped. opts.maxBytes (or a read's own
|
|
404
|
+
{ maxBytes }) caps what read() takes in: a bigger file is sized from its open handle and refused before any byte is
|
|
405
|
+
read, and named in skipped. */
|
|
406
|
+
function readAdapter(root, opts) {
|
|
407
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [], skipped = [], max = (opts && opts.maxBytes) || 0;
|
|
401
408
|
function linkErr(rel) { const e = new Refused('refusing to read through a link: ' + rel); e.link = true; if (links.indexOf(rel) < 0) links.push(rel); return e; }
|
|
409
|
+
function skipErr(rel, bytes, cap, more) {
|
|
410
|
+
const e = new Refused(rel + ' is ' + (more ? 'more than ' : '') + size(bytes) + ', over the ' + size(cap) + ' this command reads');
|
|
411
|
+
e.skipped = true; e.size = bytes;
|
|
412
|
+
if (!skipped.some((x) => x.path === rel)) skipped.push({ path: rel, size: bytes, cap, more: !!more });
|
|
413
|
+
return e;
|
|
414
|
+
}
|
|
415
|
+
/* The whole file from an open descriptor, counting the bytes as they come: the size the handle gave is only a first
|
|
416
|
+
check, so a file that grows while it's read is refused (skipped) the moment it passes the cap. */
|
|
417
|
+
function readAll(fd, rel, cap) {
|
|
418
|
+
const chunks = [], buf = Buffer.alloc(65536);
|
|
419
|
+
let total = 0;
|
|
420
|
+
for (;;) {
|
|
421
|
+
const n = fs.readSync(fd, buf, 0, buf.length, total);
|
|
422
|
+
if (!n) break;
|
|
423
|
+
total += n;
|
|
424
|
+
if (cap && total > cap) throw skipErr(rel, cap, cap, true);
|
|
425
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
426
|
+
}
|
|
427
|
+
return Buffer.concat(chunks, total);
|
|
428
|
+
}
|
|
402
429
|
function partsOf(p) {
|
|
403
430
|
const a = p ? String(p).split('/') : [];
|
|
404
431
|
a.forEach((x) => { if (!x || x === '.' || x === '..' || /[\\\u0000]/.test(x)) fail('refusing an unexpected path: ' + p); });
|
|
@@ -448,11 +475,13 @@ function readAdapter(root) {
|
|
|
448
475
|
} catch (e) { fs.closeSync(fd); throw e; }
|
|
449
476
|
}
|
|
450
477
|
return {
|
|
451
|
-
links,
|
|
478
|
+
links, skipped,
|
|
452
479
|
list: (p) => new Promise((res) => {
|
|
453
480
|
const a = partsOf(p), dir = dirAt(a);
|
|
454
|
-
|
|
455
|
-
const
|
|
481
|
+
/* No such folder: empty, and marked missing, so a reader that saw it listed can tell gone from empty. */
|
|
482
|
+
if (!dir) { const none = []; none.missing = true; return res(none); }
|
|
483
|
+
let entries;
|
|
484
|
+
try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; const none = []; none.missing = true; return res(none); }
|
|
456
485
|
sameWalk(a, dir.ids, p || '.');
|
|
457
486
|
res(entries.map((e) => {
|
|
458
487
|
const rel = (p ? p + '/' : '') + e.name;
|
|
@@ -465,26 +494,36 @@ function readAdapter(root) {
|
|
|
465
494
|
try { f = fileAt(p); } catch (e) { if (!e.link) throw e; }
|
|
466
495
|
res(f ? { size: f.st.size, mtime: f.st.mtimeMs } : null);
|
|
467
496
|
}),
|
|
468
|
-
read: (p) => new Promise((res) => {
|
|
497
|
+
read: (p, ro) => new Promise((res) => {
|
|
469
498
|
const o = open(p);
|
|
470
499
|
if (!o) return res(null);
|
|
500
|
+
const cap = (ro && ro.maxBytes) || max;
|
|
501
|
+
if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
|
|
471
502
|
/* Decoded as a browser decodes a file (TextDecoder: UTF-8, a byte-order mark dropped), so every reader sees the same text. */
|
|
472
|
-
try { const data =
|
|
503
|
+
try { const data = readAll(o.fd, p, cap); res({ text: new TextDecoder('utf-8').decode(data), size: data.length, mtime: o.st.mtimeMs }); } finally { fs.closeSync(o.fd); }
|
|
473
504
|
}),
|
|
474
505
|
/* A file to read a piece at a time, from the descriptor opened above; close() when done. */
|
|
475
|
-
blob: (p) => new Promise((res) => {
|
|
506
|
+
blob: (p, ro) => new Promise((res) => {
|
|
476
507
|
const o = open(p);
|
|
477
508
|
if (!o) return res(null);
|
|
509
|
+
const cap = (ro && ro.maxBytes) || max;
|
|
510
|
+
if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
|
|
478
511
|
let closed = false;
|
|
512
|
+
const close = () => { if (!closed) { closed = true; fs.closeSync(o.fd); } };
|
|
479
513
|
res({
|
|
480
514
|
size: o.st.size,
|
|
515
|
+
/* The whole file (screenshots are checked this way), counted against the cap as it's read; then closed. */
|
|
516
|
+
arrayBuffer: () => new Promise((ok) => {
|
|
517
|
+
if (closed) throw new Error(p + ' was already closed');
|
|
518
|
+
try { const data = readAll(o.fd, p, cap); ok(data.buffer.slice(data.byteOffset, data.byteOffset + data.length)); } finally { close(); }
|
|
519
|
+
}),
|
|
481
520
|
slice: (a, b) => ({ arrayBuffer: () => new Promise((ok) => {
|
|
482
521
|
const buf = Buffer.alloc(Math.max(0, b - a));
|
|
483
522
|
let got = 0;
|
|
484
523
|
while (got < buf.length) { const n = fs.readSync(o.fd, buf, got, buf.length - got, a + got); if (!n) break; got += n; }
|
|
485
524
|
ok(buf.buffer.slice(buf.byteOffset, buf.byteOffset + got));
|
|
486
525
|
}) }),
|
|
487
|
-
close
|
|
526
|
+
close
|
|
488
527
|
});
|
|
489
528
|
})
|
|
490
529
|
};
|
|
@@ -518,7 +557,7 @@ async function exportPack(opt) {
|
|
|
518
557
|
const dir = folderArg(opt.folder, 'export');
|
|
519
558
|
if (!opt.run && !opt.process) fail('say which runs: --run <process>/<run id>, or --process <process> --from YYYY-MM-DD --through YYYY-MM-DD');
|
|
520
559
|
if (opt.process && (!opt.from || !opt.through)) fail('give the days with --from YYYY-MM-DD and --through YYYY-MM-DD (both included)');
|
|
521
|
-
const { PF, version } =
|
|
560
|
+
const { PF, version } = loadEngine(), E = PF.evidence;
|
|
522
561
|
const tz = opt.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
523
562
|
if (!E.zoneOk(tz)) fail('"' + tz + '" isn\'t a time zone this computer knows. Use a name like Europe/London or America/New_York.');
|
|
524
563
|
const out = outFolder(expand(opt.out || path.join(require('os').homedir(), 'Flightdeck evidence')), withinOf(dir));
|
|
@@ -571,6 +610,444 @@ async function exportPack(opt) {
|
|
|
571
610
|
}
|
|
572
611
|
}
|
|
573
612
|
|
|
613
|
+
/* ---------- Reading for other apps: processes, process, runs, run, next, followups --json (docs/apps.md) ---------- */
|
|
614
|
+
const READS = ['processes', 'process', 'runs', 'run', 'next', 'followups'];
|
|
615
|
+
/* What one read takes in at most: a record past this is named (skipped), never read. */
|
|
616
|
+
const READ_MAX = 16 * 1048576;
|
|
617
|
+
/* Where each part of loadAll lives, for naming a part that couldn't be read. */
|
|
618
|
+
const PART_PATH = { people: 'people.json', drafts: 'maps/drafts/', publications: 'publications/', runs: 'runs/', legacy: 'runs/', skills: 'skills/', proposals: 'proposals/',
|
|
619
|
+
'step types': 'step-types/', 'Jira workflows': 'jira/workflows.json', 'sign-offs': 'validations/', corrections: 'corrections/', 'follow-ups': 'followups/' };
|
|
620
|
+
const PROC_ID = /^[a-z0-9][a-z0-9-]{0,59}$/, RUN_KEY = /^([a-z0-9][a-z0-9-]{0,59})\/([a-z0-9-]{1,40})$/i;
|
|
621
|
+
|
|
622
|
+
function fileWhy(w) { return 'This file ' + String(w || 'couldn\'t be read') + '.'; }
|
|
623
|
+
/* A run this copy can only list by name: saved by a newer Flightdeck (its format, fields this copy doesn't know, or its own
|
|
624
|
+
frozen checklist and step types using features this copy can't read). A run on a version that's only unusable here
|
|
625
|
+
still reads; its version is named instead. */
|
|
626
|
+
function newerRun(PF, r) {
|
|
627
|
+
const why = r._unrunnable || '';
|
|
628
|
+
if (!why) return false;
|
|
629
|
+
if (r.snapshot === '' || why.indexOf(PF.run.NEWER) === 0) return true;
|
|
630
|
+
return why.indexOf(PF.publish.NEWER) === 0 && !!PF.publish.unrunnable(r.snapshot, r.types);
|
|
631
|
+
}
|
|
632
|
+
function runProblems(PF, r) {
|
|
633
|
+
const out = [], key = r.process + '/' + r.id, dir = 'runs/' + key + '/';
|
|
634
|
+
if (newerRun(PF, r)) out.push({ path: dir + 'run.json', kind: 'newer', why: '"' + r.title + '" (' + key + '): ' + r._unrunnable });
|
|
635
|
+
/* Times sanitize couldn't read, and every other record it dropped, cut or replaced: one problem for the file, naming each. */
|
|
636
|
+
const isNewer = newerRun(PF, r), bt = isNewer ? '' : badTimesWhy(r._badTimes), fixed = isNewer ? [] : (r._repairs || []).map((x) => x.at + ' (' + x.what + ')'), parts = [];
|
|
637
|
+
if (bt) parts.push('Some times in this file couldn\'t be read (' + bt + ').');
|
|
638
|
+
if (fixed.length) parts.push('Records this copy dropped or changed: ' + fixed.slice(0, 20).join(', ') + (fixed.length > 20 ? ', and ' + (fixed.length - 20) + ' more' : '') + '.');
|
|
639
|
+
/* The safety net: anywhere else sanitize's result differs from the file (R.unexplained), not already named above. */
|
|
640
|
+
const named = (r._repairs || []).map((x) => x.at).concat(Object.keys(r._badTimes || {}));
|
|
641
|
+
const other = isNewer ? [] : (r._drift || []).filter((p) => !explained(p, named));
|
|
642
|
+
if (other.length) parts.push('Other places this copy reads differently from the file: ' + other.slice(0, 20).join(', ') + (other.length > 20 ? ', and ' + (other.length - 20) + ' more' : '') + '.');
|
|
643
|
+
if (parts.length) out.push({ path: dir + 'run.json', kind: 'malformed', why: parts.join(' ') + ' The run is shown as the app shows it, without them.' });
|
|
644
|
+
if (r._conflicts && r._conflicts.length) out.push({ path: dir, kind: 'conflict', why: 'Conflict copies or lost saves beside this run: ' + r._conflicts.join(', ') + '. A person resolves them in the app.' });
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
/* Whether a place where the file and what the app reads differ is already named: the same place, one inside the other, or
|
|
648
|
+
another entry of the same list (dropping one entry shifts the rest). */
|
|
649
|
+
function explained(p, named) {
|
|
650
|
+
const base = (x) => x.replace(/\[\d+\].*$/, '');
|
|
651
|
+
return named.some((e) => p === e || p.startsWith(e + '.') || p.startsWith(e + '[') || e.startsWith(p + '.') || e.startsWith(p + '[') || (/\[/.test(p) && /\[/.test(e) && base(p) === base(e)));
|
|
652
|
+
}
|
|
653
|
+
/* A follow-up decision whose file says something cleanDecision doesn't keep as written (_drift, reading for other apps). */
|
|
654
|
+
function decisionDrift(d) {
|
|
655
|
+
return d && d._drift && d._drift.length ? { path: 'followups/' + d.process + '/' + d._file, kind: 'malformed', why: 'This decision isn\'t read as written: ' + d._drift.slice(0, 20).join(', ') + '.' } : null;
|
|
656
|
+
}
|
|
657
|
+
/* Which times R.sanitize couldn't read (r._badTimes), in words, or ''. */
|
|
658
|
+
function badTimesWhy(b) {
|
|
659
|
+
if (!b) return '';
|
|
660
|
+
const names = { created: 'the start', ended: 'the end', pauses: 'an earlier pause', paused: 'the pause under way', log: 'a log entry' };
|
|
661
|
+
return Object.keys(b).map((k) => (k === 'log' && Array.isArray(b.log) && b.log.length > 1 ? b.log.length + ' log entries' : names[k] || k)).join(', ');
|
|
662
|
+
}
|
|
663
|
+
/* One run, judged the same way by runs, run and next, so a summary never says other than the run itself: its version
|
|
664
|
+
(pub: what readVersion or the version list found; a version that isn't in the folder reads as missing files, as
|
|
665
|
+
readVersion finds it; null when the command doesn't read versions) decides why it's read-only, as the app decides it
|
|
666
|
+
(unusable here, or changed since the run started: R's own reason comes first), then every problem of the run file
|
|
667
|
+
and of that version. Sets r._unrunnable; returns the problems. */
|
|
668
|
+
function judgeRun(PF, r, pub) {
|
|
669
|
+
const A = PF.appjson, out = [], valid = typeof r.folder === 'string' && !!PF.publish.versionOf(r.folder) && /^[a-z0-9][a-z0-9-]{0,59}$/.test(r.process);
|
|
670
|
+
const dir = 'publications/' + r.process + '/' + r.folder + '/';
|
|
671
|
+
if (pub && valid && !pub._readFailed) {
|
|
672
|
+
const why = pub.unusable ? pub.why : pub.ok ? PF.repo.runMismatch(r, pub.info) : '';
|
|
673
|
+
if (why && !r._unrunnable) r._unrunnable = why;
|
|
674
|
+
if (!pub.ok) { const x = A.pubProblem(pub); out.push({ path: dir, kind: x.kind, why: x.why }); }
|
|
675
|
+
/* The version is fine; the run doesn't match it. Named at the run, so the version is never taken as unreadable. */
|
|
676
|
+
else if (why) out.push({ path: 'runs/' + r.process + '/' + r.id + '/run.json', kind: 'unusable', why: 'This run doesn\'t match its version ' + r.folder + ' (its checklist or version number differs from the version\'s record), so the app opens it read-only.' });
|
|
677
|
+
}
|
|
678
|
+
return out.concat(runProblems(PF, r));
|
|
679
|
+
}
|
|
680
|
+
/* A version folder that isn't in the folder, as readVersion would find it: none of its files there. */
|
|
681
|
+
function missingVersion(PF, r) { const v = PF.publish.verify(r.folder, { 'map.md': null, 'types.json': null, 'publication.json': null }); v.folder = r.folder; v.process = r.process; return v; }
|
|
682
|
+
/* What's wrong with people.json as a people list, or '' (no file is fine: names are then null). */
|
|
683
|
+
function peopleWhy(p) {
|
|
684
|
+
if (!p || p.hash == null) return '';
|
|
685
|
+
const doc = p.doc;
|
|
686
|
+
if (doc == null) return 'This file isn\'t complete JSON, so no names could be read.';
|
|
687
|
+
const list = Array.isArray(doc) ? doc : typeof doc === 'object' && Array.isArray(doc.people) ? doc.people : null;
|
|
688
|
+
if (!list) return 'This file isn\'t a people list, so no names could be read.';
|
|
689
|
+
const bad = list.filter((x) => !(x && typeof x === 'object' && typeof x.id === 'string' && typeof x.name === 'string')).length;
|
|
690
|
+
const long = list.filter((x) => x && typeof x.name === 'string' && x.name.length > 60).length;
|
|
691
|
+
if (!bad && long) return 'This file has ' + long + ' ' + (long === 1 ? 'name' : 'names') + ' over 60 characters; they are cut to 60, as the app shows them.';
|
|
692
|
+
return bad ? 'This file has ' + bad + ' ' + (bad === 1 ? 'entry' : 'entries') + ' without a text ID and name; those names are left null.' : '';
|
|
693
|
+
}
|
|
694
|
+
/* What a whole-folder read found beyond failed reads (which the ledger already holds): parts that failed, runs and plans
|
|
695
|
+
that won't parse, versions that can't be used, newer runs and conflict copies, one per file or folder. */
|
|
696
|
+
function folderProblems(PF, d) {
|
|
697
|
+
const A = PF.appjson, out = [];
|
|
698
|
+
Object.keys(d.failed || {}).forEach((part) => out.push({ path: PART_PATH[part] || part, kind: 'unreadable', why: 'This part of the folder couldn\'t be read: ' + d.failed[part] + '.' }));
|
|
699
|
+
(d.unreadable || []).forEach((u) => out.push({ path: u.path, kind: u.kind || 'unreadable', why: fileWhy(u.why) }));
|
|
700
|
+
(d.nofile || []).forEach((n) => out.push({ path: n.path, kind: 'nofile', why: 'The run folder has no run.json.' }));
|
|
701
|
+
(d.malformed || []).forEach((m) => out.push({ path: m.path, kind: m.kind || 'malformed', why: fileWhy(m.why) }));
|
|
702
|
+
(d.fuUnread || []).forEach((u) => out.push({ path: 'followups/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a follow-up decision this copy can read.' }));
|
|
703
|
+
(d.corrUnread || []).forEach((u) => out.push({ path: u.dir + '/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a decision this copy can read.' }));
|
|
704
|
+
(d.processes || []).forEach((proc) => (proc.pubs || []).forEach((p) => { if (!p.ok && !p._readFailed) { const x = A.pubProblem(p); out.push({ path: 'publications/' + proc.process + '/' + p.folder + '/', kind: x.kind, why: x.why }); } }));
|
|
705
|
+
(d.fuDecisions || []).forEach((x) => { const p = decisionDrift(x); if (p) out.push(p); });
|
|
706
|
+
const pw = peopleWhy(d.people);
|
|
707
|
+
if (pw) out.push({ path: 'people.json', kind: 'malformed', why: pw });
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
function adapterProblems(adapter) {
|
|
711
|
+
return (adapter.links || []).map((p) => ({ path: p, kind: 'link', why: 'A link. Flightdeck never reads through links.' }))
|
|
712
|
+
.concat((adapter.skipped || []).map((x) => ({ path: x.path, kind: 'skipped', why: 'Too large to read here (' + (x.more ? 'more than ' : '') + size(x.size) + '; the limit is ' + size(x.cap) + ').' })));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* What a whole-folder command's output depends on, as folders and files: its data, the publications that say whether a
|
|
716
|
+
run or plan can be used and which version a plan starts on, and people.json for names. With --process, that process's
|
|
717
|
+
part only (the plans are all read, since a plan's process is inside the file). */
|
|
718
|
+
function needs(req) {
|
|
719
|
+
const sub = (top) => top + (req.proc ? '/' + req.proc : '');
|
|
720
|
+
if (req.cmd === 'processes' || req.cmd === 'process') return [req.cmd === 'process' ? 'publications/' + req.proc : 'publications'];
|
|
721
|
+
if (req.cmd === 'runs') return [sub('runs'), 'proposals', sub('publications'), 'people.json'];
|
|
722
|
+
return [sub('followups'), sub('runs'), 'people.json'].concat(req.proc ? [sub('publications'), 'proposals'] : []);
|
|
723
|
+
}
|
|
724
|
+
/* A view of the folder that holds only those paths (and the folders above them, listed to reach them). Anything else
|
|
725
|
+
looks absent: it's never read, so it's never a problem and never part of the output. It sits above the ledger, so
|
|
726
|
+
what it hides never reaches it. */
|
|
727
|
+
/* Whether a path is one of these, inside one, or a folder above one (listed to reach it). */
|
|
728
|
+
function covers(prefixes) { return (p) => { p = p || ''; return p === '' || prefixes.some((x) => p === x || p.startsWith(x + '/') || x.startsWith(p + '/')); }; }
|
|
729
|
+
/* What run and next depend on: the run's folder, its version once it's known, people.json, and that run's own follow-up
|
|
730
|
+
decisions (the files named <run id>.*, not the other runs' in the same folder). */
|
|
731
|
+
function runCovers(proc, id, r) {
|
|
732
|
+
const base = covers(['runs/' + proc + '/' + id, 'people.json'].concat(r && typeof r.folder === 'string' ? ['publications/' + r.process + '/' + r.folder] : []));
|
|
733
|
+
return (p) => base(p) || p === 'followups' || p === 'followups/' + proc || p === 'followups/' + proc + '/' || String(p).startsWith('followups/' + proc + '/' + id + '.');
|
|
734
|
+
}
|
|
735
|
+
function dependsOn(fs, prefixes) {
|
|
736
|
+
const ok = covers(prefixes);
|
|
737
|
+
const v = {};
|
|
738
|
+
if (fs.list) v.list = (p, o) => (ok(p) ? fs.list(p, o) : Promise.resolve([]));
|
|
739
|
+
['read', 'stat', 'blob'].forEach((m) => { if (fs[m]) v[m] = (p, o) => (ok(p) ? fs[m](p, o) : Promise.resolve(null)); });
|
|
740
|
+
['write', 'append', 'remove'].forEach((m) => { v[m] = (...a) => fs[m](...a); });
|
|
741
|
+
return v;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/* eng: { PF, version } (loadEngine()). adapter: a folder with list, read, stat and blob (and links and skipped, as the
|
|
745
|
+
command line's has); it's only ever read through PF.repo.readOnly, inside a problem ledger (PF.repo.ledger) that names
|
|
746
|
+
every failed list or read, every listed file gone when read, and everything the loaders found malformed. req: { cmd,
|
|
747
|
+
proc, key, version, folder, status, person, contract }. Resolves to { code, json }: an envelope (exit 0 or 4), whose
|
|
748
|
+
problems are the ledger's within the command's scope (complete exactly when there are none), or an error object (exit 1
|
|
749
|
+
or 5) carrying every problem the ledger holds. */
|
|
750
|
+
async function readData(eng, adapter, req) {
|
|
751
|
+
const PF = eng.PF, A = PF.appjson, repo = PF.repo, F = PF.followups, app = eng.version || '';
|
|
752
|
+
const L = repo.ledger(repo.readOnly(adapter, 'Reading for other apps'));
|
|
753
|
+
const take = (list) => list.forEach((p) => L.note(p.path, p.kind, p.why));
|
|
754
|
+
/* What the command depends on (set once the command is known): problems anywhere else, a link met while listing a
|
|
755
|
+
folder above what it reads included, are never its problems. */
|
|
756
|
+
let relevant = () => true;
|
|
757
|
+
const ledger = () => { take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
|
|
758
|
+
const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger()) });
|
|
759
|
+
const notFound = (why) => err(EXIT.notFound, 'not-found', why);
|
|
760
|
+
if (req.contract != null && A.VERSIONS.indexOf(+req.contract) < 0) return err(EXIT.error, 'contract', 'This copy of Flightdeck reads contract ' + A.VERSIONS.join(', ') + ' only (' + A.CONTRACT + '). Ask for one of those, or update Flightdeck.');
|
|
761
|
+
const done = (keep, data, readAt) => { const e = A.envelope(app, readAt || new Date().toISOString(), keep(ledger()), data); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
|
|
762
|
+
try {
|
|
763
|
+
if (req.cmd === 'run' || req.cmd === 'next') {
|
|
764
|
+
const m = RUN_KEY.exec(req.key || '');
|
|
765
|
+
relevant = runCovers(m[1], m[2], null);
|
|
766
|
+
const one = await repo.readRunOnly(L.fs, m[1], m[2]);
|
|
767
|
+
if (!one.found) return notFound('There\'s no run ' + req.key + ' in the team folder.');
|
|
768
|
+
const r = one.run, key = m[1] + '/' + m[2];
|
|
769
|
+
if (one.nofile) L.note('runs/' + key + '/run.json', 'nofile', 'The run folder has no run.json.');
|
|
770
|
+
take(one.bad.map((b) => ({ path: b.path, kind: b.kind || 'unreadable', why: fileWhy(b.why) })));
|
|
771
|
+
take(one.fuUnread.map((u) => ({ path: u.path, kind: u.kind || 'malformed', why: fileWhy(u.why) })));
|
|
772
|
+
one.fuDecisions.forEach((x) => { const p = decisionDrift(x); if (p) take([p]); });
|
|
773
|
+
if (one.peopleWhy) L.note('people.json', one.peopleKind || 'unreadable', fileWhy(one.peopleWhy));
|
|
774
|
+
const pw = peopleWhy(one.people);
|
|
775
|
+
if (pw) L.note('people.json', 'malformed', pw);
|
|
776
|
+
if (r) { relevant = runCovers(m[1], m[2], r); take(judgeRun(PF, r, one.pub)); }
|
|
777
|
+
const usable = !!r && !newerRun(PF, r);
|
|
778
|
+
/* run and next read only what they depend on (readRunOnly), so every problem met counts. */
|
|
779
|
+
const keep = (list) => list, nameOf = A.names(one.people);
|
|
780
|
+
if (req.cmd === 'run') return done(keep, usable ? A.run(r, F.list([r], one.fuDecisions), nameOf) : { run: null });
|
|
781
|
+
return done(keep, usable ? A.next(r, nameOf, req.person || null) : { run: key, person: req.person ? nameOf(req.person) : null, next: null, mine: null });
|
|
782
|
+
}
|
|
783
|
+
/* Each command reads only what its output depends on (needs()), so every problem the ledger holds is one that output
|
|
784
|
+
depends on, and all of them are reported: there's no scope to fall behind what's read. */
|
|
785
|
+
relevant = covers(needs(req));
|
|
786
|
+
const d = await repo.loadAll(dependsOn(L.fs, needs(req)), { readOnly: true, explain: true, deep: true });
|
|
787
|
+
take(folderProblems(PF, d));
|
|
788
|
+
/* Every run judged as run judges it, against its own version (runs reads the versions; followups doesn't need them). */
|
|
789
|
+
const pubOf = (r) => { const proc = d.processes.filter((x) => x.process === r.process)[0], p = proc && proc.pubs.filter((x) => x.folder === r.folder)[0]; return p || missingVersion(PF, r); };
|
|
790
|
+
d.runs.forEach((r) => take(judgeRun(PF, r, req.cmd === 'runs' ? pubOf(r) : null)));
|
|
791
|
+
const nameOf = A.names(d.people), runs = d.runs.filter((r) => !newerRun(PF, r)), all = (list) => list;
|
|
792
|
+
const known = (p) => d.processes.some((x) => x.process === p) || d.runs.some((r) => r.process === p) || d.plans.some((x) => x.process === p);
|
|
793
|
+
if (req.proc && !known(req.proc)) return notFound('There\'s no process ' + req.proc + ' in the team folder.');
|
|
794
|
+
if (req.cmd === 'processes') return done(all, A.processes(d.processes), d.readAt);
|
|
795
|
+
if (req.cmd === 'process') {
|
|
796
|
+
const proc = d.processes.filter((x) => x.process === req.proc)[0];
|
|
797
|
+
if (!proc || !proc.pubs.length) return notFound('There\'s no published checklist for ' + req.proc + ' in the team folder.');
|
|
798
|
+
let pub = null;
|
|
799
|
+
const vOf = (p) => (p.info && typeof p.info.version === 'number' ? p.info.version : PF.publish.versionOf(p.folder));
|
|
800
|
+
if (req.folder) pub = proc.pubs.filter((p) => p.folder === req.folder)[0];
|
|
801
|
+
else if (req.version != null) pub = proc.pubs.filter((p) => vOf(p) === +req.version).sort((a, b) => (b.ok ? 1 : 0) - (a.ok ? 1 : 0) || (a.folder < b.folder ? -1 : 1))[0];
|
|
802
|
+
else pub = proc.index.latest || proc.pubs.slice().sort((a, b) => (vOf(b) || 0) - (vOf(a) || 0))[0];
|
|
803
|
+
if (!pub) return notFound('There\'s no version ' + (req.folder || req.version) + ' of ' + req.proc + ' in the team folder.');
|
|
804
|
+
return done(all, A.process(proc, pub), d.readAt);
|
|
805
|
+
}
|
|
806
|
+
if (req.cmd === 'runs') {
|
|
807
|
+
const st = req.status || 'all', mine = (p) => !req.proc || p === req.proc;
|
|
808
|
+
const list = runs.filter((r) => mine(r.process) && (st === 'all' || (st === 'running' && !r.ended) || (st === 'ended' && !!r.ended)));
|
|
809
|
+
const plans = st === 'all' || st === 'planned' ? d.plans.filter((p) => mine(p.process)) : [];
|
|
810
|
+
/* Which version a plan starts on is unknown (null), not missing, when any of its process's versions couldn't be read or
|
|
811
|
+
verified: only problems with the versions themselves count (a run that doesn't match its version is named at the run). */
|
|
812
|
+
const probs = ledger(), unsure = (proc) => probs.some((x) => ['', 'publications', 'publications/'].indexOf(x.path) >= 0 || x.path === 'publications/' + proc || x.path.indexOf('publications/' + proc + '/') === 0);
|
|
813
|
+
return done(all, A.runs(list, plans, nameOf, d.processes, unsure), d.readAt);
|
|
814
|
+
}
|
|
815
|
+
/* followups: every run's follow-ups come from its run.json, so any run that couldn't be read may hide some. */
|
|
816
|
+
const items = F.list(runs.filter((r) => !req.proc || r.process === req.proc), d.fuDecisions).filter((x) => req.status !== 'open' || F.isOpen(x));
|
|
817
|
+
return done(all, A.followUps(items, nameOf), d.readAt);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
return err(EXIT.error, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e)));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/* The command line around readData: flags, the folder, the engine, and JSON out. Usage problems before any reading exit 1. */
|
|
824
|
+
async function readMain(argv) {
|
|
825
|
+
const VAL = ['--process', '--status', '--person', '--version', '--folder', '--contract'], val = {};
|
|
826
|
+
const usage = (why) => ({ out: JSON.stringify(failureJson(null, 'usage', why), null, 2), code: EXIT.error });
|
|
827
|
+
const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
|
|
828
|
+
const cmd = args[0], asJson = argv.includes('--json');
|
|
829
|
+
for (const f of VAL) {
|
|
830
|
+
const i = argv.indexOf(f);
|
|
831
|
+
if (i < 0) continue;
|
|
832
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('--')) { if (asJson) return usage('say what goes after ' + f + '.'); fail('say what goes after ' + f); }
|
|
833
|
+
val[f] = argv[i + 1];
|
|
834
|
+
}
|
|
835
|
+
if (!asJson) fail(cmd + ' prints JSON for other apps. Add --json, for example: flightdeck ' + cmd + ' <team folder> --json');
|
|
836
|
+
const req = { cmd, proc: val['--process'] || null, status: val['--status'] || null, person: val['--person'] || null, version: val['--version'] || null, folder: val['--folder'] || null, contract: val['--contract'] || null };
|
|
837
|
+
if (cmd === 'process') { req.proc = args[2]; if (!req.proc || !PROC_ID.test(req.proc)) return usage('say which process, for example: flightdeck process <team folder> release --json'); }
|
|
838
|
+
if (cmd === 'run' || cmd === 'next') { req.key = args[2]; if (!req.key || !RUN_KEY.test(req.key)) return usage('say which run as <process>/<run id>, for example: flightdeck ' + cmd + ' <team folder> release/k3m9p2q8 --json'); }
|
|
839
|
+
if (req.proc && !PROC_ID.test(req.proc)) return usage('a process ID is lowercase letters, numbers and dashes.');
|
|
840
|
+
if (req.version != null && !/^\d{1,6}$/.test(req.version)) return usage('--version is a version number, like 3.');
|
|
841
|
+
if (req.folder != null && !/^v\d+-[a-z0-9]+$/.test(req.folder)) return usage('--folder is a version folder, like v3-k3m9p2q8.');
|
|
842
|
+
if (req.contract != null && !/^\d{1,4}$/.test(req.contract)) return usage('--contract is a contract number, like 1.');
|
|
843
|
+
const statuses = cmd === 'runs' ? ['running', 'planned', 'ended', 'all'] : cmd === 'followups' ? ['open', 'all'] : null;
|
|
844
|
+
if (req.status != null && (!statuses || statuses.indexOf(req.status) < 0)) return usage(statuses ? '--status is one of: ' + statuses.join(', ') + '.' : '--status works with runs and followups.');
|
|
845
|
+
let eng, dir;
|
|
846
|
+
try { eng = loadEngine(); dir = folderArg(args[1], cmd); }
|
|
847
|
+
catch (e) { return { out: JSON.stringify(failureJson(eng, 'failed', e.message), null, 2), code: EXIT.error }; }
|
|
848
|
+
try {
|
|
849
|
+
const r = await readData(eng, readAdapter(dir, { maxBytes: READ_MAX }), req);
|
|
850
|
+
return { out: JSON.stringify(r.json, null, 2), code: r.code };
|
|
851
|
+
} catch (e) {
|
|
852
|
+
return { out: JSON.stringify(failureJson(eng, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e))), null, 2), code: EXIT.error };
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function failureJson(eng, kind, why) { return eng ? eng.PF.appjson.failure(eng.version, kind, why, []) : { contract: 'flightdeck-read/1', app: versionOf(app()) || '', error: { kind, why }, problems: [] }; }
|
|
856
|
+
|
|
857
|
+
/* ---------- Writing drafts for AI assistants: flightdeck draft read | save | propose (contract flightdeck-draft/1) ----------
|
|
858
|
+
These commands change the team folder, and only ever write maps/drafts/<process>.md (a direct save), one new
|
|
859
|
+
proposed/<process>/<operation>.json and one new history/drafts/<process>/agent-<operation>.jsonl, all through folders()
|
|
860
|
+
(never through a link, never outside the folder). A draft open in an editor this computer can see is never written: the
|
|
861
|
+
change becomes a proposal a person applies in Flightdeck. docs/apps.md, docs/design.md section 27. */
|
|
862
|
+
const DRAFT_CONTRACT = 'flightdeck-draft/1';
|
|
863
|
+
/* Test hooks around the final compare of a direct save (test/cli.test.js injects a write there). */
|
|
864
|
+
const _hooks = {};
|
|
865
|
+
function draftJson(eng, o) { return Object.assign({ contract: DRAFT_CONTRACT, app: eng ? eng.version : versionOf(app()) || '' }, o); }
|
|
866
|
+
function draftFail(eng, kind, why, code, problems) { return { out: JSON.stringify(draftJson(eng, { error: { kind, why }, problems: problems || [] }), null, 2), code: code || EXIT.error }; }
|
|
867
|
+
/* A text an assistant hands over: at most the draft limit in UTF-8 bytes (the app's own constant), decoded strictly. */
|
|
868
|
+
function inputText(PF, file, what) {
|
|
869
|
+
let buf;
|
|
870
|
+
try { buf = file === '-' ? fs.readFileSync(0) : fs.readFileSync(expand(file)); } catch (e) { return { why: 'couldn\'t read ' + what + ' (' + e.message + ').' }; }
|
|
871
|
+
if (buf.length > PF.util.DRAFT_BYTES) return { why: what + ' is ' + buf.length + ' bytes; a draft is at most ' + PF.util.DRAFT_BYTES + ' bytes (1 MB) of UTF-8.' };
|
|
872
|
+
try { return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf) }; } catch (e) { return { why: what + ' isn\'t valid UTF-8 text.' }; }
|
|
873
|
+
}
|
|
874
|
+
/* Who has the draft open, as this computer's copy of the folder shows now. A record that can't be read, or a presence/ that
|
|
875
|
+
can't be listed, counts as open: the safe outcome is a proposal. */
|
|
876
|
+
function presenceNow(PF, dir, proc, now) {
|
|
877
|
+
const PP = PF.proposals, at = path.join(dir, PP.PRESENCE), out = { open: false, openIn: [] };
|
|
878
|
+
let st;
|
|
879
|
+
try { st = fs.lstatSync(at); } catch (e) { if (e.code === 'ENOENT') return out; out.open = true; return out; }
|
|
880
|
+
if (st.isSymbolicLink() || !st.isDirectory()) { out.open = true; return out; }
|
|
881
|
+
let names;
|
|
882
|
+
try { names = fs.readdirSync(at, { withFileTypes: true }); } catch (e) { out.open = true; return out; }
|
|
883
|
+
names.forEach((e) => {
|
|
884
|
+
if (!/\.json$/i.test(e.name)) return;
|
|
885
|
+
if (!e.isFile()) { out.open = true; return; }
|
|
886
|
+
let rec = null;
|
|
887
|
+
try { const t = fs.readFileSync(path.join(at, e.name)); if (t.length <= 65536) rec = PP.cleanPresence(JSON.parse(t.toString('utf8')), e.name); } catch (err) { rec = null; }
|
|
888
|
+
if (!rec) { out.open = true; return; }
|
|
889
|
+
if (rec.drafts.indexOf(proc) < 0 || !PP.freshCli(rec, now)) return;
|
|
890
|
+
out.open = true;
|
|
891
|
+
out.openIn.push({ by: rec.by, device: rec.device, seenAgo: Math.max(0, now - Date.parse(rec.at)) });
|
|
892
|
+
});
|
|
893
|
+
return out;
|
|
894
|
+
}
|
|
895
|
+
/* Everything a draft command looks at, read-only: the draft (and other drafts that say the same process), this process's
|
|
896
|
+
proposals with their records (this session's read whole), its published versions, and people.json. */
|
|
897
|
+
async function draftState(eng, dir, proc, session) {
|
|
898
|
+
const PF = eng.PF, PP = PF.proposals, ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(ra), rfs = L.fs, sha = PP.sha;
|
|
899
|
+
const dpath = 'maps/drafts/' + proc + '.md', draft = { exists: false, path: dpath, rev: null, text: null };
|
|
900
|
+
try {
|
|
901
|
+
const f = await rfs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES });
|
|
902
|
+
if (f) Object.assign(draft, { exists: true, rev: sha(f.text), text: f.text });
|
|
903
|
+
} catch (e) { draft.exists = true; }
|
|
904
|
+
const copies = [];
|
|
905
|
+
try {
|
|
906
|
+
for (const i of await rfs.list('maps/drafts')) {
|
|
907
|
+
if (i.kind !== 'file' || !/\.md$/i.test(i.name) || i.name === proc + '.md') continue;
|
|
908
|
+
let said = '';
|
|
909
|
+
try { const f = await rfs.read('maps/drafts/' + i.name, { maxBytes: PF.util.DRAFT_BYTES }); said = f ? PP.textProcess(f.text) : ''; } catch (e) { said = ''; }
|
|
910
|
+
if (said === proc) copies.push(i.name);
|
|
911
|
+
}
|
|
912
|
+
} catch (e) { /* named in the ledger */ }
|
|
913
|
+
const got = await PF.repo.loadProposed(rfs, {}, true), pr = got.procs[proc] || { all: [], bad: [], records: [] };
|
|
914
|
+
pr.bad.forEach((b) => L.note(b.path, 'malformed', b.why));
|
|
915
|
+
const recs = {}; pr.all.forEach((m) => { recs[m.id] = []; });
|
|
916
|
+
pr.records.forEach((r) => { const m = /^(p-[a-z0-9]{1,12}-[a-z0-9]{10})\./.exec(r.name); if (m && recs[m[1]]) recs[m[1]].push(r); });
|
|
917
|
+
const judged = PP.judge(pr.all, recs, { exists: draft.exists && draft.text != null, rev: draft.rev });
|
|
918
|
+
const mine = [];
|
|
919
|
+
for (const x of judged.filter((y) => y.meta.session === session)) {
|
|
920
|
+
const full = await PF.repo.readProposal(rfs, proc, x.id);
|
|
921
|
+
if (!full.ok) continue;
|
|
922
|
+
const status = x.fit === 'replaced' || x.fit === 'invalid' ? x.fit : x.rec.state;
|
|
923
|
+
mine.push({ id: x.id, rev: x.meta.rev, status, text: full.proposal.text, at: x.meta.at, base: full.proposal.base, pending: status === 'pending' });
|
|
924
|
+
}
|
|
925
|
+
const versions = [];
|
|
926
|
+
try {
|
|
927
|
+
for (const v of await rfs.list('publications/' + proc)) {
|
|
928
|
+
if (v.kind !== 'dir' || !PF.publish.versionOf(v.name)) continue;
|
|
929
|
+
const f = await rfs.read('publications/' + proc + '/' + v.name + '/map.md');
|
|
930
|
+
if (f) versions.push({ folder: v.name, version: PF.publish.versionOf(v.name), rev: sha(f.text), text: f.text });
|
|
931
|
+
}
|
|
932
|
+
} catch (e) { /* named in the ledger */ }
|
|
933
|
+
versions.sort((a, b) => b.version - a.version);
|
|
934
|
+
let people = null;
|
|
935
|
+
try { const f = await rfs.read('people.json'); people = f ? PF.people.clean(JSON.parse(f.text)) : null; } catch (e) { people = null; }
|
|
936
|
+
return { draft, copies, mine, versions, people, judged, problems: L.problems(), presence: presenceNow(PF, dir, proc, Date.now()) };
|
|
937
|
+
}
|
|
938
|
+
/* Write a new file once: created exclusively (never over a file that's there, never through a link), flushed, read back. */
|
|
939
|
+
function writeNew(dir, parts, text) {
|
|
940
|
+
const home = folders(dir, parts.slice(0, -1), true), target = path.join(home, parts[parts.length - 1]), data = Buffer.from(text, 'utf8');
|
|
941
|
+
let fd = null;
|
|
942
|
+
try {
|
|
943
|
+
fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0));
|
|
944
|
+
for (let off = 0; off < data.length;) { const n = fs.writeSync(fd, data, off, data.length - off); if (!(n > 0)) throw new Error('the disk accepted no more bytes'); off += n; }
|
|
945
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = null;
|
|
946
|
+
} catch (e) {
|
|
947
|
+
if (fd !== null) try { fs.closeSync(fd); } catch (err) { /* already failing */ }
|
|
948
|
+
if (e instanceof Refused) throw e;
|
|
949
|
+
fail('couldn\'t write ' + target + ': ' + e.message);
|
|
950
|
+
}
|
|
951
|
+
folders(dir, parts.slice(0, -1), false);
|
|
952
|
+
if (!fs.readFileSync(target).equals(data)) fail(target + ' didn\'t read back as written');
|
|
953
|
+
return parts.join('/');
|
|
954
|
+
}
|
|
955
|
+
async function draftMain(argv) {
|
|
956
|
+
const VAL = ['--session', '--expect', '--from', '--base', '--label', '--by', '--note'], val = {};
|
|
957
|
+
const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
|
|
958
|
+
const sub = args[1], usage = (why) => draftFail(null, 'usage', why);
|
|
959
|
+
for (const f of VAL) {
|
|
960
|
+
const i = argv.indexOf(f);
|
|
961
|
+
if (i < 0) continue;
|
|
962
|
+
if (argv[i + 1] == null || (argv[i + 1].startsWith('--') && argv[i + 1] !== '-')) return usage('say what goes after ' + f + '.');
|
|
963
|
+
val[f] = argv[i + 1];
|
|
964
|
+
}
|
|
965
|
+
if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save or propose, for example: flightdeck draft read <team folder> <process> --json');
|
|
966
|
+
if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
|
|
967
|
+
const proc = args[3];
|
|
968
|
+
if (!proc || !PROC_ID.test(proc)) return usage('a process ID is lowercase letters, numbers and dashes, for example: flightdeck draft ' + sub + ' <team folder> release --json');
|
|
969
|
+
if (val['--session'] != null && !/^[a-z0-9][a-z0-9-]{0,59}$/.test(val['--session'])) return usage('--session is lowercase letters, numbers and dashes, like the one draft read gave.');
|
|
970
|
+
let eng, dir;
|
|
971
|
+
try { eng = loadEngine(); dir = folderArg(args[2], 'draft ' + sub); } catch (e) { return draftFail(eng, 'failed', e.message); }
|
|
972
|
+
const PF = eng.PF, PP = PF.proposals, U = PF.util, M = PF.map, now = Date.now();
|
|
973
|
+
const session = val['--session'] || 's-' + U.randomId(12);
|
|
974
|
+
const checksOf = (t) => { const c = M.check(M.parse(t), { people: [], types: {}, workflows: {}, processes: [] }); return { blockers: c.blockers, suggestions: c.suggestions }; };
|
|
975
|
+
try {
|
|
976
|
+
if (sub === 'read') {
|
|
977
|
+
const s = await draftState(eng, dir, proc, session);
|
|
978
|
+
if (!s.draft.exists && !s.versions.length && !s.judged.length) return draftFail(eng, 'not-found', 'There\'s no draft, published version or proposal for "' + proc + '" in this folder.', EXIT.notFound, s.problems);
|
|
979
|
+
const pending = s.mine.filter((m) => m.pending).sort((a, b) => (a.at < b.at ? 1 : -1))[0];
|
|
980
|
+
const latest = argv.includes('--or-latest') && !s.draft.exists ? s.versions[0] : null;
|
|
981
|
+
const cf = pending ? { rev: pending.rev, kind: 'proposal', ref: pending.id, text: pending.text }
|
|
982
|
+
: s.draft.exists && s.draft.text != null ? { rev: s.draft.rev, kind: 'draft', ref: s.draft.path, text: s.draft.text }
|
|
983
|
+
: latest ? { rev: latest.rev, kind: 'version', ref: latest.folder, text: latest.text } : { rev: PP.sha(''), kind: 'none', ref: null, text: '' };
|
|
984
|
+
const out = draftJson(eng, { process: proc, session, draft: s.draft, openIn: s.presence.openIn, mine: s.mine.map((m) => ({ id: m.id, rev: m.rev, status: m.status, text: m.text })),
|
|
985
|
+
continueFrom: cf, checks: checksOf(cf.text), copies: s.copies, readAt: new Date(now).toISOString(), complete: s.problems.length === 0, problems: s.problems });
|
|
986
|
+
return { out: JSON.stringify(out, null, 2), code: out.complete ? 0 : EXIT.partial };
|
|
987
|
+
}
|
|
988
|
+
/* save and propose: the input and every flag are checked before anything is read or written. */
|
|
989
|
+
if (!val['--expect'] || !(val['--expect'] === 'none' || PP.SHA.test(val['--expect']))) return usage('--expect is the rev you read (a SHA-256 from draft read), or none for a new draft.');
|
|
990
|
+
if (!val['--from']) return usage('say where the new text is: --from <file>, or --from - to read it from standard input.');
|
|
991
|
+
if (val['--label'] != null && !/^[A-Za-z0-9-]{1,40}$/.test(val['--label'])) return usage('--label is letters, numbers and dashes, up to 40.');
|
|
992
|
+
if (val['--note'] != null && val['--note'].length > 200) return usage('--note is at most 200 characters.');
|
|
993
|
+
const input = inputText(PF, val['--from'], 'the new text');
|
|
994
|
+
if (input.why) return usage(input.why);
|
|
995
|
+
const text = input.text;
|
|
996
|
+
if (PP.textProcess(text) !== proc) return usage('the new text\'s process: line must say ' + proc + '.');
|
|
997
|
+
let baseText = null;
|
|
998
|
+
if (val['--base'] != null) { const b = inputText(PF, val['--base'], 'the --base text'); if (b.why) return usage(b.why); baseText = b.text; }
|
|
999
|
+
const s = await draftState(eng, dir, proc, session);
|
|
1000
|
+
if (val['--by'] != null && !(s.people || []).some((p) => p.id === val['--by'])) return usage('--by names a person in people.json, like person-avery.');
|
|
1001
|
+
const base = PP.baseFor({ expect: val['--expect'], draft: s.draft, mine: s.mine, versions: s.versions, baseText, afterEarlier: argv.includes('--after-earlier') });
|
|
1002
|
+
if (base.missing) return draftFail(eng, 'missing-base', 'The text you started from isn\'t the draft, one of this session\'s proposals or a published version any more. Pass the text you read with --base, or read the draft again.');
|
|
1003
|
+
const operation = PP.newId(now, U.randomId(10)), at = new Date(now).toISOString();
|
|
1004
|
+
const result = { process: proc, session, operation, outcome: null, why: null, link: base.link, rev: PP.sha(text), file: null, ids: PP.ids(base.text, text), checks: checksOf(text), warnings: [], history: null };
|
|
1005
|
+
if (result.ids.removed.length) result.warnings.push('This removes step or phase IDs: ' + result.ids.removed.join(', ') + '. Runs and skills that name them will need a look.');
|
|
1006
|
+
let why = sub === 'propose' ? 'asked' : s.presence.open ? 'open' : null;
|
|
1007
|
+
const draftNow = s.draft.exists ? s.draft.text : null, expectText = base.kind === 'none' || base.kind === 'version' ? null : base.text;
|
|
1008
|
+
if (!why && draftNow !== expectText) why = 'changed';
|
|
1009
|
+
if (!why) {
|
|
1010
|
+
try {
|
|
1011
|
+
if (_hooks.beforeCompare || _hooks.afterCompare) safeWrite.hooks = _hooks;
|
|
1012
|
+
safeWrite(dir, ['maps', 'drafts', proc + '.md'], text, expectText);
|
|
1013
|
+
Object.assign(result, { outcome: 'written', file: 'maps/drafts/' + proc + '.md' });
|
|
1014
|
+
} catch (e) { if (!(e instanceof Changed)) throw e; why = 'changed'; }
|
|
1015
|
+
finally { delete safeWrite.hooks; }
|
|
1016
|
+
if (result.outcome === 'written') {
|
|
1017
|
+
try {
|
|
1018
|
+
const hdir = 'history/drafts/' + proc, before = expectText == null ? null : U.hash(expectText);
|
|
1019
|
+
let seen = null;
|
|
1020
|
+
if (before) { const h = await PF.repo.loadEditHistory(readAdapter(dir, { maxBytes: READ_MAX }), hdir, before); const e = h.items.filter((x) => x.entry && x.entry.to === before)[0]; seen = e ? e.entry.id : null; }
|
|
1021
|
+
const line = PF.timeline.histLine({ kind: 'draft-edit', id: operation, process: proc, writer: operation, seq: 1, by: val['--by'] || '', at, from: before, to: U.hash(text), seen,
|
|
1022
|
+
before: expectText == null ? '' : expectText, after: text, source: 'agent', units: [val['--note'] || 'Changed by your AI assistant'], items: [] });
|
|
1023
|
+
writeNew(dir, ['history', 'drafts', proc, 'agent-' + operation + '.jsonl'], JSON.stringify(line) + '\n');
|
|
1024
|
+
result.history = { written: true };
|
|
1025
|
+
} catch (e) { result.history = { written: false, why: e.message }; }
|
|
1026
|
+
/* It continued from this session's own pending proposal: that one is replaced by what was just written, so a
|
|
1027
|
+
decision says so (written once, named for this operation) and nothing offers it again. */
|
|
1028
|
+
if (base.replaces) {
|
|
1029
|
+
try {
|
|
1030
|
+
const set = await PF.repo.loadProposalSet(readAdapter(dir, { maxBytes: READ_MAX }), proc), st = PP.recState(set.records[base.replaces] || []);
|
|
1031
|
+
const old = s.mine.filter((m) => m.id === base.replaces)[0];
|
|
1032
|
+
const d = PP.decision({ proposal: base.replaces, proposalRev: old ? old.rev : null, writer: operation, decisionRev: st.nextRev, seen: st.seen, status: 'replaced', settles: st.open, result: { rev: PP.sha(text) }, by: val['--by'] || '', at });
|
|
1033
|
+
writeNew(dir, [PP.DIR, proc, d.name], JSON.stringify(d.rec, null, 2) + '\n');
|
|
1034
|
+
} catch (e) { result.warnings.push('Your earlier proposal ' + base.replaces + ' couldn\'t be marked as replaced (' + e.message + '). Decline it on the process page.'); }
|
|
1035
|
+
}
|
|
1036
|
+
return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/* A proposal: write-once, holding its own base text, its SHA-256s and its link, measured before it's written. */
|
|
1040
|
+
const rec = PP.make({ id: operation, process: proc, session, label: val['--label'] || '', by: val['--by'] || '', at, app: eng.version || '', base: { kind: base.kind, ref: base.ref, text: base.text }, text, replaces: base.replaces, after: base.after, note: val['--note'] || '' });
|
|
1041
|
+
const json = JSON.stringify(rec, null, 2) + '\n';
|
|
1042
|
+
if (Buffer.byteLength(json) > PP.FILE_MAX) return draftFail(eng, 'too-big', 'This proposal would be ' + size(Buffer.byteLength(json)) + ' as a file, over the 4 MB a proposal can be. Nothing was written.');
|
|
1043
|
+
result.file = writeNew(dir, [PP.DIR, proc, operation + '.json'], json);
|
|
1044
|
+
Object.assign(result, { outcome: 'proposed', why });
|
|
1045
|
+
return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)));
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
574
1051
|
const HELP = `flightdeck: team process checklists, in one HTML file
|
|
575
1052
|
|
|
576
1053
|
flightdeck open open the app (it starts on built-in examples)
|
|
@@ -584,6 +1061,28 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
584
1061
|
flightdeck export <team folder> --process <process> --from YYYY-MM-DD --through YYYY-MM-DD [--tz Area/City]
|
|
585
1062
|
make one of the runs started on those days, both included, in --tz
|
|
586
1063
|
(this computer's time zone unless you say)
|
|
1064
|
+
flightdeck processes <team folder> --json
|
|
1065
|
+
for other apps: every process and its versions, as JSON
|
|
1066
|
+
flightdeck process <team folder> <process> [--version N | --folder v3-...] --json
|
|
1067
|
+
one version's phases, steps, gates and evidence (the latest usable one
|
|
1068
|
+
unless you say)
|
|
1069
|
+
flightdeck runs <team folder> [--process <id>] [--status running|planned|ended|all] --json
|
|
1070
|
+
runs and planned runs
|
|
1071
|
+
flightdeck run <team folder> <process>/<run id> --json
|
|
1072
|
+
one run: each step's state, who and when, its evidence, gates, follow-ups
|
|
1073
|
+
flightdeck next <team folder> <process>/<run id> [--person <id>] --json
|
|
1074
|
+
what's waiting in a run and on whom (with --person, that person's next step)
|
|
1075
|
+
flightdeck followups <team folder> [--process <id>] [--status open|all] --json
|
|
1076
|
+
every follow-up and what became of it
|
|
1077
|
+
flightdeck draft read <team folder> <process> [--session <id>] [--or-latest] --json
|
|
1078
|
+
for AI assistants: the draft's exact text and rev, who has it open,
|
|
1079
|
+
this session's proposals and the text to continue from
|
|
1080
|
+
flightdeck draft save <team folder> <process> --expect <rev|none> --from <file|-> [--base <file>]
|
|
1081
|
+
[--session <id>] [--label <name>] [--by <person id>] [--note "<text>"] [--after-earlier] --json
|
|
1082
|
+
write the draft, only when nobody has it open and it's exactly what you
|
|
1083
|
+
read; otherwise write a proposal a person applies in Flightdeck
|
|
1084
|
+
flightdeck draft propose <same as save>
|
|
1085
|
+
always write a proposal
|
|
587
1086
|
flightdeck path print where the app file is
|
|
588
1087
|
flightdeck version print this version
|
|
589
1088
|
|
|
@@ -593,6 +1092,13 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
593
1092
|
--force replace a newer app, an unrecognized file, or contract files your team changed
|
|
594
1093
|
--no-attachments export: list attachments with their sizes instead of packing them
|
|
595
1094
|
|
|
1095
|
+
The JSON commands only read: they never change a file in the team folder. Exit code 0: complete; 4: partial (the data
|
|
1096
|
+
is printed, and "problems" names every file or folder that couldn't be read); 1: no data; 5: no such process, version or
|
|
1097
|
+
run. People-directory email fields are omitted. See docs/apps.md in the Flightdeck repository.
|
|
1098
|
+
|
|
1099
|
+
flightdeck draft changes the team folder (contract flightdeck-draft/1). Exit code 0: written or proposed (read: complete);
|
|
1100
|
+
4: read, partial; 1: usage, missing-base, too-big or failed; 5: read, no such process.
|
|
1101
|
+
|
|
596
1102
|
export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
|
|
597
1103
|
separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
|
|
598
1104
|
|
|
@@ -603,6 +1109,10 @@ default goes to .flightdeck/updates/<version>/, with a README.md to hand to your
|
|
|
603
1109
|
|
|
604
1110
|
function main(argv) {
|
|
605
1111
|
const out = [];
|
|
1112
|
+
/* Reading for other apps has flags of its own (--version takes a number there), so it's parsed apart. */
|
|
1113
|
+
const first = argv.filter((a) => !a.startsWith('--'))[0];
|
|
1114
|
+
if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
|
|
1115
|
+
if (first === 'draft' && !argv.includes('--help')) return draftMain(argv);
|
|
606
1116
|
const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
|
|
607
1117
|
if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
|
|
608
1118
|
/* Flags that take a value: the value is never read as a command or a folder. */
|
|
@@ -720,4 +1230,4 @@ if (require.main === module) {
|
|
|
720
1230
|
if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
|
|
721
1231
|
} catch (e) { failed(e); }
|
|
722
1232
|
}
|
|
723
|
-
module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter,
|
|
1233
|
+
module.exports = { _hooks, main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };
|