@felixdotgo/aidlc-kit 0.2.0 → 0.3.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/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Client scripts, loaded as classic scripts in this order: `common` first, page scripts after. */
|
|
2
|
+
export type ClientScript = "common" | "dashboard" | "detail" | "live-log" | "changes";
|
|
3
|
+
/** The whitelisted asset for a request path, or undefined (the caller answers 404). */
|
|
4
|
+
export declare const staticAsset: (route: string) => {
|
|
5
|
+
contentType: string;
|
|
6
|
+
body: string;
|
|
7
|
+
} | undefined;
|
|
8
|
+
/** Stylesheets served from `/assets`, loaded in this order. */
|
|
9
|
+
export type Stylesheet = "bulma" | "dashboard";
|
|
10
|
+
/** `<script src>` for a client script; `?v=<content hash>` makes an upgrade bust the browser cache. */
|
|
11
|
+
export declare const scriptTag: (name: ClientScript) => string;
|
|
12
|
+
/** `<link rel="stylesheet">` for a stylesheet, versioned like `scriptTag` so an upgrade is not
|
|
13
|
+
* stuck behind the cached theme while the hashed scripts already changed. */
|
|
14
|
+
export declare const stylesheetTag: (name: Stylesheet) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored static assets under `assets/ui` (copied to `dist/assets/ui` by the build): Bulma, the
|
|
3
|
+
* dashboard theme layer, and the classic client scripts. Only whitelisted routes are served; each
|
|
4
|
+
* file is read once and cached in memory.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const CSS = "text/css; charset=utf-8";
|
|
10
|
+
const JS = "text/javascript; charset=utf-8";
|
|
11
|
+
const ASSETS = new Map([
|
|
12
|
+
["/assets/bulma.css", { file: "bulma.min.css", contentType: CSS }],
|
|
13
|
+
["/assets/dashboard.css", { file: "dashboard.css", contentType: CSS }],
|
|
14
|
+
...["common", "dashboard", "detail", "live-log", "changes"].map((name) => [`/assets/${name}.js`, { file: `${name}.js`, contentType: JS }]),
|
|
15
|
+
]);
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
const load = (route, file) => {
|
|
18
|
+
const cached = cache.get(route);
|
|
19
|
+
if (cached)
|
|
20
|
+
return cached;
|
|
21
|
+
let body = "";
|
|
22
|
+
try {
|
|
23
|
+
body = readFileSync(fileURLToPath(new URL(`../../assets/ui/${file}`, import.meta.url)), "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch { /* serve an empty asset rather than failing the page */ }
|
|
26
|
+
const entry = { body, version: createHash("sha256").update(body).digest("hex").slice(0, 12) };
|
|
27
|
+
cache.set(route, entry);
|
|
28
|
+
return entry;
|
|
29
|
+
};
|
|
30
|
+
/** The whitelisted asset for a request path, or undefined (the caller answers 404). */
|
|
31
|
+
export const staticAsset = (route) => {
|
|
32
|
+
const asset = ASSETS.get(route);
|
|
33
|
+
return asset ? { contentType: asset.contentType, body: load(route, asset.file).body } : undefined;
|
|
34
|
+
};
|
|
35
|
+
/** `<script src>` for a client script; `?v=<content hash>` makes an upgrade bust the browser cache. */
|
|
36
|
+
export const scriptTag = (name) => {
|
|
37
|
+
const route = `/assets/${name}.js`;
|
|
38
|
+
return `<script src="${route}?v=${load(route, `${name}.js`).version}"></script>`;
|
|
39
|
+
};
|
|
40
|
+
/** `<link rel="stylesheet">` for a stylesheet, versioned like `scriptTag` so an upgrade is not
|
|
41
|
+
* stuck behind the cached theme while the hashed scripts already changed. */
|
|
42
|
+
export const stylesheetTag = (name) => {
|
|
43
|
+
const route = `/assets/${name}.css`;
|
|
44
|
+
return `<link rel="stylesheet" href="${route}?v=${load(route, ASSETS.get(route).file).version}">`;
|
|
45
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { TaskState, WorkflowState } from "../model.js";
|
|
2
|
+
import { type UiOptions } from "./options.js";
|
|
3
|
+
export declare const ID_PATTERN: RegExp;
|
|
4
|
+
export declare const SHA_PATTERN: RegExp;
|
|
5
|
+
export declare const MAX_FILES = 300;
|
|
6
|
+
export declare const MAX_PATCH_BYTES: number;
|
|
7
|
+
/** Total patch bytes the whole-task view renders; further items list files only. */
|
|
8
|
+
export declare const MAX_TASK_PATCH_BYTES: number;
|
|
9
|
+
/** Pseudo item ids for sources without per-item records. */
|
|
10
|
+
export declare const WORKING_TREE_ITEM = "working-tree";
|
|
11
|
+
export declare const INTEGRATION_ITEM = "integration";
|
|
12
|
+
export declare const TASK_RANGE_ITEM = "task";
|
|
13
|
+
export type ChangeSource = "assignments" | "fleet" | "task-range" | "working-tree" | "none";
|
|
14
|
+
/** `whitespaceOnly`: listed by git but without a diff while whitespace is ignored (`ws=ignore`). */
|
|
15
|
+
export interface ChangedFile {
|
|
16
|
+
path: string;
|
|
17
|
+
oldPath?: string;
|
|
18
|
+
status: string;
|
|
19
|
+
added: number;
|
|
20
|
+
removed: number;
|
|
21
|
+
binary: boolean;
|
|
22
|
+
whitespaceOnly?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** `workingTree`: the diff ends at the working tree (uncommitted tracked changes and untracked files included) rather than at `commit`. */
|
|
25
|
+
export interface ChangeItem {
|
|
26
|
+
itemId: string;
|
|
27
|
+
workerId?: string;
|
|
28
|
+
commit: string;
|
|
29
|
+
base?: string;
|
|
30
|
+
files: ChangedFile[];
|
|
31
|
+
patch: string;
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
note?: string;
|
|
34
|
+
patchOmitted?: boolean;
|
|
35
|
+
workingTree?: true;
|
|
36
|
+
}
|
|
37
|
+
export interface ChangesPayload {
|
|
38
|
+
ok: true;
|
|
39
|
+
source: ChangeSource;
|
|
40
|
+
items: ChangeItem[];
|
|
41
|
+
note?: string;
|
|
42
|
+
}
|
|
43
|
+
export type ChangesResult = {
|
|
44
|
+
status: 200;
|
|
45
|
+
payload: ChangesPayload;
|
|
46
|
+
task: TaskState;
|
|
47
|
+
state: WorkflowState;
|
|
48
|
+
} | {
|
|
49
|
+
status: 400 | 404;
|
|
50
|
+
payload: {
|
|
51
|
+
ok: false;
|
|
52
|
+
error: string;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** Request options beyond the ids: `ws` is the raw `?ws=` query value (only `ignore` is accepted). */
|
|
56
|
+
export interface ChangesQuery {
|
|
57
|
+
ws?: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** `--name-status -z`: `S\0path\0` or `R<score>\0old\0new\0`. */
|
|
60
|
+
export declare const parseNameStatus: (raw: string) => Array<{
|
|
61
|
+
status: string;
|
|
62
|
+
path: string;
|
|
63
|
+
oldPath?: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** `--numstat -z`: `added\tremoved\tpath\0`, or `added\tremoved\t\0old\0new\0` for a rename; binary is `-\t-`. */
|
|
66
|
+
export declare const parseNumstat: (raw: string) => Array<{
|
|
67
|
+
path: string;
|
|
68
|
+
added: number;
|
|
69
|
+
removed: number;
|
|
70
|
+
binary: boolean;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* Whole-task budget: keep patches until MAX_TASK_PATCH_BYTES is spent (the focused
|
|
74
|
+
* item is always kept and counted first); the rest list their files only and are
|
|
75
|
+
* marked `patchOmitted` so the page can load them one at a time.
|
|
76
|
+
*/
|
|
77
|
+
export declare const capTaskPatch: (items: ChangeItem[], focus: string | null) => ChangeItem[];
|
|
78
|
+
/**
|
|
79
|
+
* Changes of `taskId` (optionally one item). 400 for a malformed id or `ws` value, 404 for
|
|
80
|
+
* an unknown task or item; git problems are reported as notes inside a 200 payload, never
|
|
81
|
+
* as a 500. `focusItem` is the page's selected item (kept in full by `capTaskPatch`).
|
|
82
|
+
*/
|
|
83
|
+
export declare const taskChanges: (options: UiOptions, taskId: string, itemId: string | null, known?: WorkflowState, focusItem?: string | null, query?: ChangesQuery) => Promise<ChangesResult>;
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code changes of one task for `/tasks/:id/changes` and `/api/tasks/:id/changes`.
|
|
3
|
+
*
|
|
4
|
+
* Requests carry only a task id, an optional item id, and an optional whitespace mode
|
|
5
|
+
* (`ws=ignore`, validated against a fixed list). Every commit comes from a record (the
|
|
6
|
+
* task's `baseCommit`/`finalCommit`, local assignments, or the fleet gate snapshot), is
|
|
7
|
+
* validated as a hex SHA and resolved with `rev-parse --verify` before git ever sees it;
|
|
8
|
+
* no URL value reaches git.
|
|
9
|
+
*
|
|
10
|
+
* One source order for every mode:
|
|
11
|
+
* 1. `fleet` — fleet mode only: the gate snapshot's integration commit;
|
|
12
|
+
* 2. `assignments` — local mode: loop assignment records, when at least one reported
|
|
13
|
+
* or integrated attempt still resolves to a commit in this repository
|
|
14
|
+
* (records whose commits are all gone are still shown, with notes,
|
|
15
|
+
* when the task has no base commit to fall back to);
|
|
16
|
+
* 3. `task-range` — any mode, when the task records a `baseCommit`: one item `task`
|
|
17
|
+
* diffing `baseCommit` against `finalCommit` (task done) or against
|
|
18
|
+
* HEAD plus the working tree (task still open);
|
|
19
|
+
* 4. `working-tree` — tracked changes against HEAD plus untracked files (old tasks
|
|
20
|
+
* without a base commit, or a base commit that no longer resolves);
|
|
21
|
+
* 5. `none` — no git repository.
|
|
22
|
+
* A source that cannot be used falls through to the next one with a payload note.
|
|
23
|
+
* git runs via `execFile` (no shell) with a scrubbed environment, no external
|
|
24
|
+
* diff/textconv, a timeout, and output caps.
|
|
25
|
+
*/
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { fleetGate, readUiState, readUiTask } from "./data.js";
|
|
30
|
+
import { scrubbedEnv, uiMode } from "./options.js";
|
|
31
|
+
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
32
|
+
export const SHA_PATTERN = /^[0-9a-f]{7,64}$/;
|
|
33
|
+
export const MAX_FILES = 300;
|
|
34
|
+
export const MAX_PATCH_BYTES = 1024 * 1024;
|
|
35
|
+
/** Total patch bytes the whole-task view renders; further items list files only. */
|
|
36
|
+
export const MAX_TASK_PATCH_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
38
|
+
const STATS_MAX_BUFFER = 8 * 1024 * 1024;
|
|
39
|
+
/** Pseudo item ids for sources without per-item records. */
|
|
40
|
+
export const WORKING_TREE_ITEM = "working-tree";
|
|
41
|
+
export const INTEGRATION_ITEM = "integration";
|
|
42
|
+
export const TASK_RANGE_ITEM = "task";
|
|
43
|
+
/* ---------------------------------------------------------------------------
|
|
44
|
+
* git execution
|
|
45
|
+
* ------------------------------------------------------------------------- */
|
|
46
|
+
/** Variables that would point git at another repository, index, or diff program. */
|
|
47
|
+
const GIT_REDIRECT_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_EXTERNAL_DIFF", "GIT_DIFF_OPTS", "GIT_PAGER"];
|
|
48
|
+
const gitEnv = () => {
|
|
49
|
+
const env = scrubbedEnv({ GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" });
|
|
50
|
+
for (const name of GIT_REDIRECT_ENV)
|
|
51
|
+
delete env[name];
|
|
52
|
+
return env;
|
|
53
|
+
};
|
|
54
|
+
const runGit = (root, args, maxBuffer = STATS_MAX_BUFFER) => new Promise((done) => {
|
|
55
|
+
execFile("git", ["-c", "core.quotepath=off", ...args], { cwd: root, shell: false, env: gitEnv(), maxBuffer, timeout: GIT_TIMEOUT_MS, encoding: "buffer", windowsHide: true }, (error, stdout) => {
|
|
56
|
+
const code = error?.code;
|
|
57
|
+
const overflow = code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
58
|
+
done({
|
|
59
|
+
ok: !error,
|
|
60
|
+
stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? ""),
|
|
61
|
+
overflow,
|
|
62
|
+
missing: code === "ENOENT",
|
|
63
|
+
timedOut: Boolean(error && !overflow && error.killed),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
/** Full SHA of a recorded commit, or undefined when the value is not a SHA or the object is missing. */
|
|
68
|
+
const resolveCommit = async (root, sha) => {
|
|
69
|
+
if (!SHA_PATTERN.test(sha))
|
|
70
|
+
return undefined;
|
|
71
|
+
const result = await runGit(root, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], 4096);
|
|
72
|
+
const full = result.ok ? result.stdout.toString("utf8").trim() : "";
|
|
73
|
+
return SHA_PATTERN.test(full) ? full : undefined;
|
|
74
|
+
};
|
|
75
|
+
/** Full SHA of HEAD (the only fixed revision used; validated like any recorded value), or undefined without commits. */
|
|
76
|
+
const resolveHead = async (root) => {
|
|
77
|
+
const resolved = await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], 4096);
|
|
78
|
+
const sha = resolved.ok ? resolved.stdout.toString("utf8").trim() : "";
|
|
79
|
+
return SHA_PATTERN.test(sha) ? sha : undefined;
|
|
80
|
+
};
|
|
81
|
+
/** First parent of a resolved commit (undefined for a root commit). */
|
|
82
|
+
const firstParent = async (root, full) => {
|
|
83
|
+
const result = await runGit(root, ["rev-parse", "--verify", "--quiet", `${full}^1`], 4096);
|
|
84
|
+
const parent = result.ok ? result.stdout.toString("utf8").trim() : "";
|
|
85
|
+
return SHA_PATTERN.test(parent) ? parent : undefined;
|
|
86
|
+
};
|
|
87
|
+
/* ---------------------------------------------------------------------------
|
|
88
|
+
* Parsing `-z` output
|
|
89
|
+
* ------------------------------------------------------------------------- */
|
|
90
|
+
const STATUS_NAMES = { A: "added", M: "modified", D: "deleted", R: "renamed", C: "copied", T: "type-changed", U: "unmerged", X: "unknown" };
|
|
91
|
+
/** `--name-status -z`: `S\0path\0` or `R<score>\0old\0new\0`. */
|
|
92
|
+
export const parseNameStatus = (raw) => {
|
|
93
|
+
const tokens = raw.split("\0");
|
|
94
|
+
const entries = [];
|
|
95
|
+
for (let i = 0; i < tokens.length;) {
|
|
96
|
+
const code = tokens[i++] ?? "";
|
|
97
|
+
if (!code)
|
|
98
|
+
continue;
|
|
99
|
+
const status = STATUS_NAMES[code[0] ?? ""] ?? "modified";
|
|
100
|
+
if (code[0] === "R" || code[0] === "C") {
|
|
101
|
+
const oldPath = tokens[i++];
|
|
102
|
+
const path = tokens[i++];
|
|
103
|
+
if (oldPath === undefined || path === undefined)
|
|
104
|
+
break;
|
|
105
|
+
entries.push({ status, path, oldPath });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const path = tokens[i++];
|
|
109
|
+
if (path === undefined)
|
|
110
|
+
break;
|
|
111
|
+
entries.push({ status, path });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
};
|
|
116
|
+
/** `--numstat -z`: `added\tremoved\tpath\0`, or `added\tremoved\t\0old\0new\0` for a rename; binary is `-\t-`. */
|
|
117
|
+
export const parseNumstat = (raw) => {
|
|
118
|
+
const tokens = raw.split("\0");
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (let i = 0; i < tokens.length;) {
|
|
121
|
+
const token = tokens[i++] ?? "";
|
|
122
|
+
const match = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(token);
|
|
123
|
+
if (!match)
|
|
124
|
+
continue;
|
|
125
|
+
let path = match[3] ?? "";
|
|
126
|
+
if (!path) {
|
|
127
|
+
i++;
|
|
128
|
+
path = tokens[i++] ?? "";
|
|
129
|
+
}
|
|
130
|
+
const binary = match[1] === "-" && match[2] === "-";
|
|
131
|
+
entries.push({ path, added: binary ? 0 : Number(match[1]), removed: binary ? 0 : Number(match[2]), binary });
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
};
|
|
135
|
+
/** Untracked paths from `git status --porcelain=v1 -z` (`XY path\0`, renames carry an extra `\0orig`). */
|
|
136
|
+
const parseUntracked = (raw) => {
|
|
137
|
+
const tokens = raw.split("\0");
|
|
138
|
+
const paths = [];
|
|
139
|
+
for (let i = 0; i < tokens.length;) {
|
|
140
|
+
const token = tokens[i++] ?? "";
|
|
141
|
+
if (token.length < 4)
|
|
142
|
+
continue;
|
|
143
|
+
const x = token[0];
|
|
144
|
+
const y = token[1];
|
|
145
|
+
if (x === "R" || x === "C" || y === "R" || y === "C")
|
|
146
|
+
i++;
|
|
147
|
+
if (x === "?" && y === "?")
|
|
148
|
+
paths.push(token.slice(3));
|
|
149
|
+
}
|
|
150
|
+
return paths;
|
|
151
|
+
};
|
|
152
|
+
/** Cuts a patch that hit the byte cap at its last complete file section. */
|
|
153
|
+
const cutAtFileBoundary = (partial) => {
|
|
154
|
+
const text = partial.toString("utf8");
|
|
155
|
+
const last = text.lastIndexOf("\ndiff --git ");
|
|
156
|
+
return last > 0 ? text.slice(0, last + 1) : "";
|
|
157
|
+
};
|
|
158
|
+
/* ---------------------------------------------------------------------------
|
|
159
|
+
* Diff collection
|
|
160
|
+
* ------------------------------------------------------------------------- */
|
|
161
|
+
const DIFF_FLAGS = ["--no-color", "--no-ext-diff", "--no-textconv", "-M"];
|
|
162
|
+
/** `?ws=ignore` adds `-w` (`--ignore-all-space`) for that request only. */
|
|
163
|
+
const IGNORE_WS_FLAGS = [...DIFF_FLAGS, "-w"];
|
|
164
|
+
/** Diff flags for a request, or an error for any `ws` value other than empty or `ignore`. */
|
|
165
|
+
const diffFlags = (query) => query.ws === undefined || query.ws === null || query.ws === "" ? { flags: DIFF_FLAGS }
|
|
166
|
+
: query.ws === "ignore" ? { flags: IGNORE_WS_FLAGS }
|
|
167
|
+
: { error: "Invalid ws parameter (expected `ignore`)" };
|
|
168
|
+
/** `git diff <base> <commit>` between two resolved commits. */
|
|
169
|
+
const rangeDiff = (base, commit, flags) => (output) => ["diff", ...flags, ...output, base, commit, "--"];
|
|
170
|
+
/** A root commit against the empty tree. */
|
|
171
|
+
const rootDiff = (commit, flags) => (output) => ["diff-tree", "-r", "--root", "--no-commit-id", ...flags, ...output, commit, "--"];
|
|
172
|
+
/** Tracked working-tree changes against a resolved commit (HEAD, or a task's base commit). */
|
|
173
|
+
const treeDiff = (commit, flags) => (output) => ["diff", ...flags, ...output, commit, "--"];
|
|
174
|
+
const gitFailure = (result, what) => result.missing ? "git is not available" : result.timedOut ? `git timed out while reading ${what}` : `git could not read ${what}`;
|
|
175
|
+
/** File list (numstat + name-status) and patch, within the file and byte caps. */
|
|
176
|
+
const collectDiff = async (root, command, extra = []) => {
|
|
177
|
+
const numstat = await runGit(root, command(["--numstat", "-z"]));
|
|
178
|
+
if (!numstat.ok && !numstat.overflow)
|
|
179
|
+
return { files: [], patch: "", truncated: false, note: gitFailure(numstat, "the diff") };
|
|
180
|
+
const names = await runGit(root, command(["--name-status", "-z"]));
|
|
181
|
+
if (!names.ok && !names.overflow)
|
|
182
|
+
return { files: [], patch: "", truncated: false, note: gitFailure(names, "the diff") };
|
|
183
|
+
// Paired by path, not by index: with `-w`, numstat (like the patch) skips files whose
|
|
184
|
+
// only changes are whitespace while name-status still lists them.
|
|
185
|
+
const stats = new Map(parseNumstat(numstat.stdout.toString("utf8")).map((stat) => [stat.path, stat]));
|
|
186
|
+
const tracked = [];
|
|
187
|
+
let withPatch = 0;
|
|
188
|
+
for (const entry of parseNameStatus(names.stdout.toString("utf8"))) {
|
|
189
|
+
const stat = stats.get(entry.path);
|
|
190
|
+
if (stat)
|
|
191
|
+
withPatch++;
|
|
192
|
+
tracked.push({
|
|
193
|
+
path: entry.path, ...(entry.oldPath !== undefined ? { oldPath: entry.oldPath } : {}), status: entry.status,
|
|
194
|
+
added: stat?.added ?? 0, removed: stat?.removed ?? 0, binary: stat?.binary ?? false, ...(stat ? {} : { whitespaceOnly: true }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const all = [...tracked, ...extra];
|
|
198
|
+
let truncated = numstat.overflow || names.overflow || all.length > MAX_FILES;
|
|
199
|
+
const files = all.slice(0, MAX_FILES);
|
|
200
|
+
if (truncated || !withPatch)
|
|
201
|
+
return { files, patch: "", truncated };
|
|
202
|
+
const patch = await runGit(root, command(["--patch"]), MAX_PATCH_BYTES);
|
|
203
|
+
if (patch.overflow) {
|
|
204
|
+
truncated = true;
|
|
205
|
+
return { files, patch: cutAtFileBoundary(patch.stdout), truncated };
|
|
206
|
+
}
|
|
207
|
+
if (!patch.ok)
|
|
208
|
+
return { files, patch: "", truncated, note: gitFailure(patch, "the patch") };
|
|
209
|
+
return { files, patch: patch.stdout.toString("utf8"), truncated };
|
|
210
|
+
};
|
|
211
|
+
const MISSING_NOTE = "commit not available in the local repository";
|
|
212
|
+
/** One recorded commit diffed against its first parent (empty tree for a root commit). */
|
|
213
|
+
const commitItem = async (root, itemId, recorded, flags, extra = {}) => {
|
|
214
|
+
const full = await resolveCommit(root, recorded);
|
|
215
|
+
if (!full)
|
|
216
|
+
return { itemId, ...extra, commit: recorded, files: [], patch: "", truncated: false, note: MISSING_NOTE };
|
|
217
|
+
const parent = await firstParent(root, full);
|
|
218
|
+
const collected = await collectDiff(root, parent ? rangeDiff(parent, full, flags) : rootDiff(full, flags));
|
|
219
|
+
return { itemId, ...extra, commit: full, ...(parent ? { base: parent } : {}), ...collected };
|
|
220
|
+
};
|
|
221
|
+
/** Walk `parts` under `root`, refusing symlinked components; `undefined` when a component is missing. */
|
|
222
|
+
const safeFile = (root, parts) => {
|
|
223
|
+
let path = resolve(root);
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
path = join(path, part);
|
|
226
|
+
try {
|
|
227
|
+
if (lstatSync(path).isSymbolicLink())
|
|
228
|
+
return { error: "Assignment records path crosses a symlink; ignored" };
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error.code === "ENOENT")
|
|
232
|
+
return undefined;
|
|
233
|
+
return { error: "Assignment records are unreadable" };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { path };
|
|
237
|
+
};
|
|
238
|
+
/** Local assignment records (read-only): the task's `runtime/assignments.json`,
|
|
239
|
+
* else the pre-runtime `.agents/data/loop/assignments/<task>.json` until
|
|
240
|
+
* `loop migrate-data` has moved it. Refuses symlinked path components like `assignmentPath`. */
|
|
241
|
+
const readAssignments = (root, taskId) => {
|
|
242
|
+
const found = safeFile(root, [".agents", "data", "tasks", taskId, "runtime", "assignments.json"]) ?? safeFile(root, [".agents", "data", "loop", "assignments", `${taskId}.json`]);
|
|
243
|
+
if (!found)
|
|
244
|
+
return undefined;
|
|
245
|
+
if ("error" in found)
|
|
246
|
+
return found;
|
|
247
|
+
const { path } = found;
|
|
248
|
+
try {
|
|
249
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
250
|
+
if (value?.taskId !== taskId || !Array.isArray(value.assignments))
|
|
251
|
+
return { error: "Assignment records are malformed" };
|
|
252
|
+
return { records: value.assignments.filter((entry) => entry !== null && typeof entry === "object") };
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return { error: "Assignment records are unreadable" };
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/** Latest reported or integrated attempt per item, in first-seen order. */
|
|
259
|
+
const latestAssignments = (records) => {
|
|
260
|
+
const latest = new Map();
|
|
261
|
+
for (const record of records) {
|
|
262
|
+
if (typeof record.itemId !== "string" || !ID_PATTERN.test(record.itemId))
|
|
263
|
+
continue;
|
|
264
|
+
if (record.state !== "reported" && record.state !== "integrated")
|
|
265
|
+
continue;
|
|
266
|
+
const attempt = (value) => Number.isSafeInteger(value?.attempt) ? value.attempt : 0;
|
|
267
|
+
const previous = latest.get(record.itemId);
|
|
268
|
+
if (!previous || attempt(record) >= attempt(previous))
|
|
269
|
+
latest.set(record.itemId, record);
|
|
270
|
+
}
|
|
271
|
+
return [...latest.values()];
|
|
272
|
+
};
|
|
273
|
+
/** A recorded SHA field: absent, a valid hex SHA, or invalid (never passed to git). */
|
|
274
|
+
const recordedSha = (value) => value === undefined || value === null || value === "" ? { invalid: false }
|
|
275
|
+
: typeof value === "string" && SHA_PATTERN.test(value) ? { sha: value, invalid: false }
|
|
276
|
+
: { invalid: true };
|
|
277
|
+
/**
|
|
278
|
+
* Whether an attempt's recorded commits exist in this repository (cheap `rev-parse`s,
|
|
279
|
+
* no diff). When no attempt resolves (worker commits gone after a rewrite) and the task
|
|
280
|
+
* records a base commit, the task range is shown instead of a list of missing commits.
|
|
281
|
+
*/
|
|
282
|
+
const assignmentResolves = async (root, record) => {
|
|
283
|
+
const integration = recordedSha(record.integrationCommit);
|
|
284
|
+
if (integration.sha && await resolveCommit(root, integration.sha))
|
|
285
|
+
return true;
|
|
286
|
+
const commit = recordedSha(record.commit);
|
|
287
|
+
const base = recordedSha(record.baseCommit);
|
|
288
|
+
if (!commit.sha || base.invalid || !await resolveCommit(root, commit.sha))
|
|
289
|
+
return false;
|
|
290
|
+
return !base.sha || Boolean(await resolveCommit(root, base.sha));
|
|
291
|
+
};
|
|
292
|
+
const assignmentItem = async (root, record, flags) => {
|
|
293
|
+
const itemId = record.itemId;
|
|
294
|
+
const extra = typeof record.workerId === "string" && ID_PATTERN.test(record.workerId) ? { workerId: record.workerId } : {};
|
|
295
|
+
const integration = recordedSha(record.integrationCommit);
|
|
296
|
+
const commit = recordedSha(record.commit);
|
|
297
|
+
const base = recordedSha(record.baseCommit);
|
|
298
|
+
const empty = (note, fields = {}) => ({ itemId, ...extra, commit: "", files: [], patch: "", truncated: false, ...fields, note });
|
|
299
|
+
let integrationMissing = false;
|
|
300
|
+
if (integration.sha) {
|
|
301
|
+
const integrated = await commitItem(root, itemId, integration.sha, flags, extra);
|
|
302
|
+
// A rewritten integration branch can drop the recorded commit: fall back to the worker commit.
|
|
303
|
+
if (integrated.note !== MISSING_NOTE || !commit.sha)
|
|
304
|
+
return integrated;
|
|
305
|
+
integrationMissing = true;
|
|
306
|
+
}
|
|
307
|
+
// An invalid integration commit is ignored (never passed to git); the worker commit is shown instead.
|
|
308
|
+
const fallbackNote = integrationMissing ? "recorded integration commit is not in the local repository; showing the worker commit" : "recorded integration commit is not a valid SHA; showing the worker commit";
|
|
309
|
+
const withNote = (item) => (integration.invalid || integrationMissing) && !item.note ? { ...item, note: fallbackNote } : item;
|
|
310
|
+
if (commit.invalid || base.invalid)
|
|
311
|
+
return empty("recorded commit is not a valid SHA; ignored");
|
|
312
|
+
if (!commit.sha)
|
|
313
|
+
return empty(integration.invalid ? "recorded integration commit is not a valid SHA; ignored" : "no commit recorded for this item");
|
|
314
|
+
if (!base.sha)
|
|
315
|
+
return withNote(await commitItem(root, itemId, commit.sha, flags, extra));
|
|
316
|
+
const full = await resolveCommit(root, commit.sha);
|
|
317
|
+
const baseFull = await resolveCommit(root, base.sha);
|
|
318
|
+
if (!full || !baseFull)
|
|
319
|
+
return empty(MISSING_NOTE, { commit: commit.sha, base: base.sha });
|
|
320
|
+
return withNote({ itemId, ...extra, commit: full, base: baseFull, ...await collectDiff(root, rangeDiff(baseFull, full, flags)) });
|
|
321
|
+
};
|
|
322
|
+
/** Untracked files of the working tree (listed without content), or a git failure note. */
|
|
323
|
+
const untrackedFiles = async (root) => {
|
|
324
|
+
const status = await runGit(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
325
|
+
if (!status.ok && !status.overflow)
|
|
326
|
+
return { error: gitFailure(status, "the working tree") };
|
|
327
|
+
return { files: parseUntracked(status.stdout.toString("utf8")).map((path) => ({ path, status: "untracked", added: 0, removed: 0, binary: false })), overflow: status.overflow };
|
|
328
|
+
};
|
|
329
|
+
const workingTreeItem = async (root, flags) => {
|
|
330
|
+
const head = await resolveHead(root);
|
|
331
|
+
const untracked = await untrackedFiles(root);
|
|
332
|
+
if ("error" in untracked)
|
|
333
|
+
return { itemId: WORKING_TREE_ITEM, commit: head ?? "", files: [], patch: "", truncated: false, note: untracked.error };
|
|
334
|
+
if (!head) {
|
|
335
|
+
const truncated = untracked.overflow || untracked.files.length > MAX_FILES;
|
|
336
|
+
return { itemId: WORKING_TREE_ITEM, commit: "", files: untracked.files.slice(0, MAX_FILES), patch: "", truncated, note: "the repository has no commits yet; only untracked files are listed" };
|
|
337
|
+
}
|
|
338
|
+
const collected = await collectDiff(root, treeDiff(head, flags), untracked.files);
|
|
339
|
+
return { itemId: WORKING_TREE_ITEM, commit: head, workingTree: true, ...collected, truncated: collected.truncated || untracked.overflow };
|
|
340
|
+
};
|
|
341
|
+
const TASK_RANGE_NOTE = "task range from the task's base commit; may include commits made outside this task";
|
|
342
|
+
const NO_RANGE_CHANGES_NOTE = "no changes since the task's base commit";
|
|
343
|
+
/**
|
|
344
|
+
* The `task` item of the `task-range` source: `baseCommit` → `finalCommit` once the task
|
|
345
|
+
* is done, otherwise `baseCommit` → HEAD plus the working tree (`git diff <base> --`
|
|
346
|
+
* covers committed and uncommitted tracked changes in one patch; untracked files are
|
|
347
|
+
* listed). Returns a fallback reason instead when the base commit cannot be used.
|
|
348
|
+
*/
|
|
349
|
+
const taskRangeItem = async (root, task, flags) => {
|
|
350
|
+
const base = recordedSha(task.baseCommit);
|
|
351
|
+
if (base.invalid)
|
|
352
|
+
return { fallback: "recorded base commit is not a valid SHA" };
|
|
353
|
+
if (!base.sha)
|
|
354
|
+
return { fallback: "no base commit recorded for this task" };
|
|
355
|
+
const baseFull = await resolveCommit(root, base.sha);
|
|
356
|
+
if (!baseFull)
|
|
357
|
+
return { fallback: "recorded base commit is not in the local repository" };
|
|
358
|
+
const final = recordedSha(task.finalCommit);
|
|
359
|
+
const finalFull = final.sha ? await resolveCommit(root, final.sha) : undefined;
|
|
360
|
+
const finalNote = final.invalid ? "recorded final commit is not a valid SHA; showing HEAD and the working tree"
|
|
361
|
+
: final.sha && !finalFull ? "recorded final commit is not in the local repository; showing HEAD and the working tree" : undefined;
|
|
362
|
+
const item = (fields) => {
|
|
363
|
+
// A git failure note stands alone; otherwise describe the range (or its emptiness).
|
|
364
|
+
const rangeNote = fields.note ? undefined : fields.files.length ? `${TASK_RANGE_NOTE}${fields.workingTree ? "; working tree included" : ""}` : NO_RANGE_CHANGES_NOTE;
|
|
365
|
+
const note = [fields.note, finalNote, rangeNote].filter((part) => Boolean(part)).join("; ");
|
|
366
|
+
return { itemId: TASK_RANGE_ITEM, base: baseFull, ...fields, note };
|
|
367
|
+
};
|
|
368
|
+
if (finalFull)
|
|
369
|
+
return { item: item({ commit: finalFull, ...await collectDiff(root, rangeDiff(baseFull, finalFull, flags)) }) };
|
|
370
|
+
const head = await resolveHead(root);
|
|
371
|
+
if (!head)
|
|
372
|
+
return { fallback: "HEAD is not a commit" };
|
|
373
|
+
const untracked = await untrackedFiles(root);
|
|
374
|
+
if ("error" in untracked)
|
|
375
|
+
return { item: item({ commit: head, workingTree: true, files: [], patch: "", truncated: false, note: untracked.error }) };
|
|
376
|
+
const collected = await collectDiff(root, treeDiff(baseFull, flags), untracked.files);
|
|
377
|
+
return { item: item({ commit: head, workingTree: true, ...collected, truncated: collected.truncated || untracked.overflow }) };
|
|
378
|
+
};
|
|
379
|
+
/* ---------------------------------------------------------------------------
|
|
380
|
+
* Entry point
|
|
381
|
+
* ------------------------------------------------------------------------- */
|
|
382
|
+
/**
|
|
383
|
+
* Whole-task budget: keep patches until MAX_TASK_PATCH_BYTES is spent (the focused
|
|
384
|
+
* item is always kept and counted first); the rest list their files only and are
|
|
385
|
+
* marked `patchOmitted` so the page can load them one at a time.
|
|
386
|
+
*/
|
|
387
|
+
export const capTaskPatch = (items, focus) => {
|
|
388
|
+
let budget = MAX_TASK_PATCH_BYTES;
|
|
389
|
+
const omitted = new Set();
|
|
390
|
+
const order = [...items].sort((left, right) => Number(right.itemId === focus) - Number(left.itemId === focus));
|
|
391
|
+
for (const item of order) {
|
|
392
|
+
const size = Buffer.byteLength(item.patch);
|
|
393
|
+
if (item.itemId !== focus && size > budget)
|
|
394
|
+
omitted.add(item);
|
|
395
|
+
else
|
|
396
|
+
budget -= size;
|
|
397
|
+
}
|
|
398
|
+
return items.map((item) => omitted.has(item)
|
|
399
|
+
? { ...item, patch: "", patchOmitted: true }
|
|
400
|
+
: item);
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Changes of `taskId` (optionally one item). 400 for a malformed id or `ws` value, 404 for
|
|
404
|
+
* an unknown task or item; git problems are reported as notes inside a 200 payload, never
|
|
405
|
+
* as a 500. `focusItem` is the page's selected item (kept in full by `capTaskPatch`).
|
|
406
|
+
*/
|
|
407
|
+
export const taskChanges = async (options, taskId, itemId, known, focusItem = null, query = {}) => {
|
|
408
|
+
if (!ID_PATTERN.test(taskId))
|
|
409
|
+
return { status: 400, payload: { ok: false, error: "Invalid task id" } };
|
|
410
|
+
if (itemId !== null && !ID_PATTERN.test(itemId))
|
|
411
|
+
return { status: 400, payload: { ok: false, error: "Invalid item id" } };
|
|
412
|
+
const diff = diffFlags(query);
|
|
413
|
+
if ("error" in diff)
|
|
414
|
+
return { status: 400, payload: { ok: false, error: diff.error } };
|
|
415
|
+
const { flags } = diff;
|
|
416
|
+
const state = known ?? await readUiState(options);
|
|
417
|
+
const task = readUiTask(options, taskId, state);
|
|
418
|
+
if (!task)
|
|
419
|
+
return { status: 404, payload: { ok: false, error: `Task not found: ${taskId}` } };
|
|
420
|
+
const root = options.root;
|
|
421
|
+
const done = (payload) => {
|
|
422
|
+
if (itemId !== null && !payload.items.some((item) => item.itemId === itemId))
|
|
423
|
+
return { status: 404, payload: { ok: false, error: `Item not found: ${itemId}` } };
|
|
424
|
+
return { status: 200, payload: { ok: true, ...payload }, task, state };
|
|
425
|
+
};
|
|
426
|
+
const joinNotes = (...parts) => { const kept = parts.filter((part) => Boolean(part)); return kept.length ? kept.join("; ") : undefined; };
|
|
427
|
+
const repo = await runGit(root, ["rev-parse", "--is-inside-work-tree"], 4096);
|
|
428
|
+
if (!repo.ok || repo.stdout.toString("utf8").trim() !== "true") {
|
|
429
|
+
const note = repo.missing ? "git is not available" : repo.timedOut ? "git timed out" : "the project is not a git repository";
|
|
430
|
+
if (itemId !== null)
|
|
431
|
+
return { status: 404, payload: { ok: false, error: `Item not found: ${itemId}` } };
|
|
432
|
+
return done({ source: "none", items: [], note });
|
|
433
|
+
}
|
|
434
|
+
/** Sources 3 and 4: the task range when the task records a usable base commit, else the working tree. `reason` explains why the earlier source was skipped. */
|
|
435
|
+
const rangeOrWorkingTree = async (reason) => {
|
|
436
|
+
let fallback = reason;
|
|
437
|
+
if (task.baseCommit !== undefined && task.baseCommit !== "") {
|
|
438
|
+
const range = await taskRangeItem(root, task, flags);
|
|
439
|
+
if ("item" in range) {
|
|
440
|
+
if (itemId !== null && itemId !== TASK_RANGE_ITEM)
|
|
441
|
+
return done({ source: "task-range", items: [] });
|
|
442
|
+
const note = joinNotes(fallback, fallback ? "showing the task range instead" : undefined);
|
|
443
|
+
return done({ source: "task-range", items: [range.item], ...(note ? { note } : {}) });
|
|
444
|
+
}
|
|
445
|
+
fallback = joinNotes(fallback, range.fallback);
|
|
446
|
+
}
|
|
447
|
+
if (itemId !== null && itemId !== WORKING_TREE_ITEM)
|
|
448
|
+
return done({ source: "working-tree", items: [] });
|
|
449
|
+
const note = joinNotes(fallback, fallback ? "showing the working tree instead" : undefined);
|
|
450
|
+
return done({ source: "working-tree", items: [await workingTreeItem(root, flags)], ...(note ? { note } : {}) });
|
|
451
|
+
};
|
|
452
|
+
if (uiMode(options) === "fleet") {
|
|
453
|
+
let recorded;
|
|
454
|
+
let note = "No integration commit in the current review snapshot yet (it is recorded at the code review gate)";
|
|
455
|
+
try {
|
|
456
|
+
recorded = (await fleetGate(options, taskId)).snapshot?.integrationCommit;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
note = "The fleet review snapshot is unavailable";
|
|
460
|
+
}
|
|
461
|
+
const sha = recordedSha(recorded);
|
|
462
|
+
if (sha.invalid)
|
|
463
|
+
return done({ source: "fleet", items: [], note: "The snapshot's integration commit is not a valid SHA; ignored" });
|
|
464
|
+
if (!sha.sha) {
|
|
465
|
+
// Before the gate (or without a snapshot) the task range stands in; tasks without a base commit keep the plain note.
|
|
466
|
+
if (task.baseCommit)
|
|
467
|
+
return rangeOrWorkingTree(note);
|
|
468
|
+
return done({ source: "fleet", items: [], note });
|
|
469
|
+
}
|
|
470
|
+
// An integration commit the local repository does not have (not fetched yet, or gone) falls to the task range too.
|
|
471
|
+
if (task.baseCommit && !(await resolveCommit(root, sha.sha)))
|
|
472
|
+
return rangeOrWorkingTree("The snapshot's integration commit is not in the local repository");
|
|
473
|
+
if (itemId !== null && itemId !== INTEGRATION_ITEM)
|
|
474
|
+
return done({ source: "fleet", items: [] });
|
|
475
|
+
return done({ source: "fleet", items: [await commitItem(root, INTEGRATION_ITEM, sha.sha, flags)] });
|
|
476
|
+
}
|
|
477
|
+
const records = readAssignments(root, taskId);
|
|
478
|
+
const latest = records && "records" in records ? latestAssignments(records.records) : [];
|
|
479
|
+
const hasRange = task.baseCommit !== undefined && task.baseCommit !== "";
|
|
480
|
+
let resolvable = !hasRange && latest.length > 0;
|
|
481
|
+
for (const record of latest) {
|
|
482
|
+
if (resolvable)
|
|
483
|
+
break;
|
|
484
|
+
resolvable = await assignmentResolves(root, record);
|
|
485
|
+
}
|
|
486
|
+
if (resolvable) {
|
|
487
|
+
const wanted = itemId === null ? latest : latest.filter((record) => record.itemId === itemId);
|
|
488
|
+
const items = [];
|
|
489
|
+
for (const record of wanted)
|
|
490
|
+
items.push(await assignmentItem(root, record, flags));
|
|
491
|
+
return done({ source: "assignments", items: itemId === null ? capTaskPatch(items, focusItem) : items });
|
|
492
|
+
}
|
|
493
|
+
const reason = records && "error" in records ? records.error
|
|
494
|
+
: latest.length ? "No reported or integrated assignment commit of this task is in the local repository"
|
|
495
|
+
: records ? "No reported or integrated assignments for this task yet"
|
|
496
|
+
: undefined;
|
|
497
|
+
return rangeOrWorkingTree(reason);
|
|
498
|
+
};
|