@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/bin/codeflow.mjs +8 -0
- package/bin/launcher.mjs +127 -0
- package/dist/remote-host.json +1 -0
- package/licenses/inter.txt +92 -0
- package/licenses/preact.txt +21 -0
- package/licenses/signals-core.txt +21 -0
- package/licenses/signals.txt +21 -0
- package/licenses/typescript-notices.txt +193 -0
- package/licenses/typescript.txt +55 -0
- package/licenses/uqr.txt +22 -0
- package/licenses/xterm-fit.txt +19 -0
- package/licenses/xterm.txt +21 -0
- package/package.json +34 -4
- package/public/assets/browser-7XI52A6V.js +8 -0
- package/public/assets/changes-EYFPASEI.js +1 -0
- package/public/assets/chunk-7H3ZLTKA.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-LLMQG23J.js +163 -0
- package/public/assets/main-KIRM2OEK.js +1 -0
- package/public/assets/settings-QJZVW22Y.js +9 -0
- package/public/assets/styles-615EBB20.css +32 -0
- package/public/assets/terminal-A2FI2NAX.js +62 -0
- package/public/assets.json +1 -0
- package/public/bundle-meta.json +1 -0
- package/public/docs/.nojekyll +0 -0
- package/public/docs/accessibility.html +19 -0
- package/public/docs/acp-agents.html +117 -0
- package/public/docs/agent-containers.html +79 -0
- package/public/docs/api.html +1130 -0
- package/public/docs/app.js +342 -0
- package/public/docs/application-improvements.html +275 -0
- package/public/docs/architecture-explorer.html +19 -0
- package/public/docs/architecture.html +149 -0
- package/public/docs/auto-team.html +142 -0
- package/public/docs/automations.html +109 -0
- package/public/docs/checkpoint-review.html +37 -0
- package/public/docs/code-hosts.html +35 -0
- package/public/docs/code-intelligence.html +35 -0
- package/public/docs/configuration.html +346 -0
- package/public/docs/context-inspection.html +23 -0
- package/public/docs/coordinated-changes.html +28 -0
- package/public/docs/deferred-runs.html +25 -0
- package/public/docs/dependency-discovery.html +19 -0
- package/public/docs/documentation.html +84 -0
- package/public/docs/dynamic-teams-evaluation.html +174 -0
- package/public/docs/dynamic-teams-implementation-plan.html +882 -0
- package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
- package/public/docs/dynamic-teams-plan-review-0.html +204 -0
- package/public/docs/extensions.html +42 -0
- package/public/docs/external-sources.html +96 -0
- package/public/docs/filesystem-policy.html +26 -0
- package/public/docs/filtered-tasks.html +44 -0
- package/public/docs/font-license.txt +92 -0
- package/public/docs/getting-started.html +118 -0
- package/public/docs/github-review-threads.html +26 -0
- package/public/docs/github.html +157 -0
- package/public/docs/help.html +69 -0
- package/public/docs/index.html +77 -0
- package/public/docs/installation.html +74 -0
- package/public/docs/launch-profiles.html +48 -0
- package/public/docs/maintenance.html +60 -0
- package/public/docs/memory-claims.html +31 -0
- package/public/docs/memory-storage.html +58 -0
- package/public/docs/notifications.html +39 -0
- package/public/docs/patterns.html +195 -0
- package/public/docs/performance-results.html +210 -0
- package/public/docs/performance.html +166 -0
- package/public/docs/project-conventions.html +22 -0
- package/public/docs/project-first-navigation-plan.html +587 -0
- package/public/docs/project-teams-implementation-plan.html +772 -0
- package/public/docs/project-teams.html +99 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +61 -0
- package/public/docs/remote-workers.html +64 -0
- package/public/docs/review-inbox.html +26 -0
- package/public/docs/run-storage.html +23 -0
- package/public/docs/runs.html +141 -0
- package/public/docs/search.json +1 -0
- package/public/docs/security.html +133 -0
- package/public/docs/shared-projects.html +31 -0
- package/public/docs/ssh-projects.html +60 -0
- package/public/docs/style.css +768 -0
- package/public/docs/task-reruns.html +30 -0
- package/public/docs/team-collaboration.html +379 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +122 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/pty.py +273 -0
- package/src/acp-connections.js +110 -0
- package/src/adapters/acp-presets.js +37 -0
- package/src/adapters/acp-types.js +0 -0
- package/src/adapters/acp.js +328 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +223 -0
- package/src/adapters/agy.js +297 -0
- package/src/adapters/claude.js +220 -0
- package/src/adapters/codex-app-server.js +518 -0
- package/src/adapters/codex.js +299 -0
- package/src/adapters/errors.js +57 -0
- package/src/adapters/index.js +91 -0
- package/src/adapters/managed-permissions.js +37 -0
- package/src/adapters/model-discovery.js +100 -0
- package/src/adapters/process.js +418 -0
- package/src/adapters/team-tools.js +102 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-container.js +155 -0
- package/src/approvals.js +121 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-handoff.js +55 -0
- package/src/checkpoint-review.js +72 -0
- package/src/checks.js +303 -0
- package/src/code-host-git.js +102 -0
- package/src/code-hosts-api.js +99 -0
- package/src/code-hosts.js +482 -0
- package/src/code-intelligence-worker.mjs +452 -0
- package/src/code-intelligence.js +141 -0
- package/src/collab/api.js +365 -0
- package/src/collab/checkpoints.js +198 -0
- package/src/collab/connections.js +139 -0
- package/src/collab/host-execution.js +347 -0
- package/src/collab/hub-client.js +210 -0
- package/src/collab/link-api.js +229 -0
- package/src/collab/link.js +595 -0
- package/src/collab/model.js +496 -0
- package/src/collab/runner.js +1014 -0
- package/src/collab/store.js +1452 -0
- package/src/collab/terminals.js +361 -0
- package/src/collaboration-reviews.js +116 -0
- package/src/config.js +48 -0
- package/src/context-budget.js +14 -0
- package/src/context-inspection.js +204 -0
- package/src/context.js +154 -0
- package/src/conventions.js +283 -0
- package/src/coordinated-api.js +247 -0
- package/src/deferred-launch.js +82 -0
- package/src/deferred-runs.js +515 -0
- package/src/dependency-health.js +35 -0
- package/src/diff.js +43 -0
- package/src/docs-help.js +316 -0
- package/src/doctor.js +232 -0
- package/src/engine.js +4366 -0
- package/src/env.js +44 -0
- package/src/experience.js +50 -0
- package/src/extensions.js +144 -0
- package/src/filesystem-access.js +107 -0
- package/src/filesystem-policy.js +238 -0
- package/src/filtered-task-limits.js +93 -0
- package/src/filtered-tasks-api.js +53 -0
- package/src/filtered-tasks.js +576 -0
- package/src/fs.js +57 -0
- package/src/git.js +626 -0
- package/src/github-review-threads.js +181 -0
- package/src/github.js +538 -0
- package/src/http.js +69 -0
- package/src/improve-settings.js +38 -0
- package/src/improve.js +187 -0
- package/src/integrations.js +373 -0
- package/src/integrity.js +259 -0
- package/src/journey-api.js +77 -0
- package/src/journey-replay.js +65 -0
- package/src/journey-store.js +97 -0
- package/src/journeys.js +60 -0
- package/src/json.js +81 -0
- package/src/launch-profiles.js +292 -0
- package/src/learnings.js +80 -0
- package/src/log.js +39 -0
- package/src/maintenance.js +335 -0
- package/src/memory-analysis.js +182 -0
- package/src/memory-claims.js +153 -0
- package/src/memory-sqlite.js +236 -0
- package/src/memory.js +552 -0
- package/src/merge-validation.js +58 -0
- package/src/model-catalog.js +238 -0
- package/src/model-research.js +91 -0
- package/src/notes.js +176 -0
- package/src/notifications.js +413 -0
- package/src/patterns/auto-team-model.js +212 -0
- package/src/patterns/auto-team.js +659 -0
- package/src/patterns/council.js +199 -0
- package/src/patterns/debate.js +188 -0
- package/src/patterns/findings.js +141 -0
- package/src/patterns/index.js +19 -0
- package/src/patterns/loop.js +158 -0
- package/src/patterns/pipeline.js +125 -0
- package/src/patterns/prompts.js +181 -0
- package/src/patterns/race.js +147 -0
- package/src/patterns/schemas.js +40 -0
- package/src/patterns/solo.js +49 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/tdd.js +231 -0
- package/src/patterns/team-strategies.js +67 -0
- package/src/performance.js +43 -0
- package/src/persistence-status.js +35 -0
- package/src/phone-sessions.js +96 -0
- package/src/preview-overlay.js +383 -0
- package/src/preview.js +219 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-model.js +178 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +213 -0
- package/src/project-teams-api.js +36 -0
- package/src/project-teams.js +551 -0
- package/src/projects.js +286 -0
- package/src/provider-eligibility.js +9 -0
- package/src/readiness.js +98 -0
- package/src/remote-tls.js +66 -0
- package/src/remote-workers/bundles.js +198 -0
- package/src/remote-workers/client.js +332 -0
- package/src/remote-workers/durability.js +37 -0
- package/src/remote-workers/protocol.js +68 -0
- package/src/remote-workers/stdio.js +138 -0
- package/src/remote-workers/worker.js +304 -0
- package/src/remote.js +204 -0
- package/src/repo-config.js +201 -0
- package/src/repo-coordination.js +732 -0
- package/src/repo-tree.js +106 -0
- package/src/retention-policy.js +466 -0
- package/src/review-inbox.js +38 -0
- package/src/routing.js +49 -0
- package/src/run-analysis.js +111 -0
- package/src/run-cold-store.js +231 -0
- package/src/run-environment.js +15 -0
- package/src/run-history.js +91 -0
- package/src/run-log.js +160 -0
- package/src/run-permissions.js +110 -0
- package/src/run-projection.js +75 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +54 -0
- package/src/serve.js +138 -0
- package/src/server.js +2738 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +301 -0
- package/src/shared-api.js +239 -0
- package/src/shell.js +346 -0
- package/src/ship.js +344 -0
- package/src/slots.js +74 -0
- package/src/snapshot.js +388 -0
- package/src/source-context.js +17 -0
- package/src/sse-writer.js +70 -0
- package/src/ssh-projects/bootstrap.mjs +95 -0
- package/src/ssh-projects/config.js +45 -0
- package/src/ssh-projects/gateway.js +143 -0
- package/src/ssh-projects/manager.js +320 -0
- package/src/ssh-projects/team-autostart.mjs +93 -0
- package/src/ssh-projects/team-remove.mjs +69 -0
- package/src/ssh-projects/team-status.mjs +39 -0
- package/src/ssh-projects/transport.js +116 -0
- package/src/stats.js +139 -0
- package/src/store.js +544 -0
- package/src/stream.js +91 -0
- package/src/sweeps.js +241 -0
- package/src/tailscale.js +101 -0
- package/src/task-draft.js +23 -0
- package/src/team-evaluation.js +242 -0
- package/src/team-mailbox.js +160 -0
- package/src/team-memory.js +84 -0
- package/src/team-rerun-graph.js +15 -0
- package/src/team-reruns.js +130 -0
- package/src/team-scheduler.js +121 -0
- package/src/team-template-store.js +98 -0
- package/src/team-templates.js +117 -0
- package/src/team-tool-client.mjs +79 -0
- package/src/team-tools.js +176 -0
- package/src/teams.js +0 -0
- package/src/templates.js +62 -0
- package/src/transcript.js +134 -0
- package/src/verification-integrity.js +27 -0
- package/src/verification.js +183 -0
- package/src/watch.js +195 -0
- package/src/work-queue.js +47 -0
- package/src/workflow-control.js +83 -0
- package/src/workspace-api.js +207 -0
- package/src/workspace-changes.js +61 -0
- package/src/workspace.js +146 -0
package/src/projects.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { CODEFLOW_HOME } from "./config.js";
|
|
5
|
+
import { repoRoot } from "./git.js";
|
|
6
|
+
import { logError } from "./log.js";
|
|
7
|
+
const MAX_NAME = 60;
|
|
8
|
+
const MAX_REPOS = 50;
|
|
9
|
+
function projectOf(run, projects) {
|
|
10
|
+
if (run.project) return projects.some((p) => p.id === run.project) ? run.project : void 0;
|
|
11
|
+
return projects.find((p) => p.repos.includes(run.repo))?.id;
|
|
12
|
+
}
|
|
13
|
+
class Projects {
|
|
14
|
+
constructor(file = join(CODEFLOW_HOME, "projects.json")) {
|
|
15
|
+
this.file = file;
|
|
16
|
+
}
|
|
17
|
+
projects = [];
|
|
18
|
+
/** Public reads retain the last committed snapshot while an async mutation writes. */
|
|
19
|
+
committedWhileWriting;
|
|
20
|
+
listeners = /* @__PURE__ */ new Set();
|
|
21
|
+
/**
|
|
22
|
+
* Changes happen one at a time, so two runs starting in a new repo make one project, not two,
|
|
23
|
+
* and a change counts only once it's saved (see `change`).
|
|
24
|
+
*/
|
|
25
|
+
queue = Promise.resolve();
|
|
26
|
+
/** False while projects.json can't be read (or set aside): it's never written over then. */
|
|
27
|
+
writable = true;
|
|
28
|
+
/** Projects can change, and runs record theirs (see `writable`). */
|
|
29
|
+
get available() {
|
|
30
|
+
return this.writable;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read the projects. The first time (no file yet), every repo that already has runs gets a
|
|
34
|
+
* project named after it, so the runs you have are grouped from the start.
|
|
35
|
+
*
|
|
36
|
+
* A file that isn't valid JSON, or isn't a list, is moved aside to
|
|
37
|
+
* `projects.json.corrupt-<time>` (and logged), and code-flow starts with no projects; so is one
|
|
38
|
+
* with entries it can't use, keeping the usable ones. A file that can't be read at all, or
|
|
39
|
+
* moved aside, is left alone: projects are off until it's fixed, and runs record none.
|
|
40
|
+
*/
|
|
41
|
+
async load(runs = []) {
|
|
42
|
+
this.projects = [];
|
|
43
|
+
this.writable = false;
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
text = await readFile(this.file, "utf8");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.code !== "ENOENT") {
|
|
49
|
+
logError(`Couldn't read ${this.file}; projects are off and it won't be written over until it can be read`, err);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (text === void 0) {
|
|
54
|
+
this.writable = true;
|
|
55
|
+
const firstRun = /* @__PURE__ */ new Map();
|
|
56
|
+
for (const r of [...runs].sort((a, b) => a.createdAt - b.createdAt)) if (!firstRun.has(r.repo)) firstRun.set(r.repo, r.createdAt);
|
|
57
|
+
for (const [repo, at] of firstRun) this.add(repo, at);
|
|
58
|
+
await this.write().catch((err) => logError(`Couldn't save ${this.file}`, err));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
let why = "isn't a list of projects";
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
why = `isn't valid JSON (${err.message})`;
|
|
67
|
+
}
|
|
68
|
+
const entries = Array.isArray(parsed) ? parsed : [];
|
|
69
|
+
const usable = entries.filter(isProject);
|
|
70
|
+
const rejected = entries.length - usable.length;
|
|
71
|
+
if ((!Array.isArray(parsed) || rejected) && !await this.setAside(Array.isArray(parsed) ? `has ${rejected} project(s) code-flow can't read` : why)) return;
|
|
72
|
+
this.projects = usable;
|
|
73
|
+
this.writable = true;
|
|
74
|
+
if (rejected) await this.write().catch((err) => logError(`Couldn't save ${this.file}`, err));
|
|
75
|
+
}
|
|
76
|
+
/** Move projects.json aside so it isn't lost when the usable projects are saved. */
|
|
77
|
+
async setAside(why) {
|
|
78
|
+
const aside = `${this.file}.corrupt-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
79
|
+
try {
|
|
80
|
+
await rename(this.file, aside);
|
|
81
|
+
logError(`${this.file} ${why}: moved it to ${aside}`);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
logError(`${this.file} ${why}, and it couldn't be moved aside; projects are off until it's fixed`, err);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
list() {
|
|
89
|
+
return (this.committedWhileWriting ?? this.projects).map(copy);
|
|
90
|
+
}
|
|
91
|
+
/** Membership and metadata changes notify authorization streams after persistence succeeds. */
|
|
92
|
+
onChange(listener) {
|
|
93
|
+
this.listeners.add(listener);
|
|
94
|
+
return () => this.listeners.delete(listener);
|
|
95
|
+
}
|
|
96
|
+
get(id) {
|
|
97
|
+
const p = (this.committedWhileWriting ?? this.projects).find((project) => project.id === id);
|
|
98
|
+
return p && copy(p);
|
|
99
|
+
}
|
|
100
|
+
/** The project a repo's runs go in when none is named: the first one holding it. */
|
|
101
|
+
forRepo(repo) {
|
|
102
|
+
const p = (this.committedWhileWriting ?? this.projects).find((x) => x.repos.includes(repo));
|
|
103
|
+
return p && copy(p);
|
|
104
|
+
}
|
|
105
|
+
create(input) {
|
|
106
|
+
return this.change(async () => {
|
|
107
|
+
const project = { id: `p-${randomBytes(4).toString("hex")}`, name: this.checkName(input.name), repos: await checkRepos(input.repos ?? []), createdAt: Date.now() };
|
|
108
|
+
this.projects.push(project);
|
|
109
|
+
return copy(project);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Rename a project or change its repos. Runs already in it stay, even in a repo you take out
|
|
114
|
+
* (RunManager.updateProject records the project on older runs that relied on the repo).
|
|
115
|
+
*/
|
|
116
|
+
update(id, patch) {
|
|
117
|
+
return this.change(async () => {
|
|
118
|
+
const p = this.require(id);
|
|
119
|
+
const name = patch.name !== void 0 ? this.checkName(patch.name, id) : p.name;
|
|
120
|
+
const repos = patch.repos !== void 0 ? await checkRepos(patch.repos, p.repos) : p.repos;
|
|
121
|
+
p.name = name;
|
|
122
|
+
p.repos = repos;
|
|
123
|
+
return copy(p);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Delete a project. Its runs are kept and belong to no project. */
|
|
127
|
+
remove(id) {
|
|
128
|
+
return this.change(() => {
|
|
129
|
+
this.require(id);
|
|
130
|
+
this.projects = this.projects.filter((p) => p.id !== id);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The project a new run in `repo` goes in: the one asked for, else the project of the run it
|
|
135
|
+
* continues, else the repo's project, else (the first run in a repo no project holds) a new
|
|
136
|
+
* project named after the repo. The repo joins the project if it isn't in it yet, so a
|
|
137
|
+
* project's runs always work in its repos.
|
|
138
|
+
*
|
|
139
|
+
* A project that was asked for but no longer exists is an error, unless `lenient` (an
|
|
140
|
+
* automation saved before you deleted it): then the repo decides.
|
|
141
|
+
*/
|
|
142
|
+
place(repo, opts = {}) {
|
|
143
|
+
return this.change(() => {
|
|
144
|
+
if (opts.requireRepo && (!opts.requested || !this.find(opts.requested)?.repos.includes(repo))) throw new Error("The repository is no longer an explicit member of the requested project");
|
|
145
|
+
let project = opts.requested ? this.find(opts.requested) : void 0;
|
|
146
|
+
if (opts.requested && !project && !opts.lenient) throw new Error("That project no longer exists; pick another one");
|
|
147
|
+
project ??= (opts.parent ? this.find(opts.parent) : void 0) ?? this.projects.find((p) => p.repos.includes(repo));
|
|
148
|
+
const created = !project;
|
|
149
|
+
project ??= this.add(repo);
|
|
150
|
+
const added = !created && !project.repos.includes(repo);
|
|
151
|
+
if (added) project.repos.push(repo);
|
|
152
|
+
return { project: copy(project), created, added };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** A run moved into a project brings its repo with it. */
|
|
156
|
+
include(id, repo) {
|
|
157
|
+
return this.change(() => {
|
|
158
|
+
const p = this.require(id);
|
|
159
|
+
const added = !p.repos.includes(repo);
|
|
160
|
+
if (added) {
|
|
161
|
+
if (p.repos.length >= MAX_REPOS) throw new Error(`A project holds at most ${MAX_REPOS} repositories`);
|
|
162
|
+
p.repos.push(repo);
|
|
163
|
+
}
|
|
164
|
+
return { project: copy(p), added };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Remember the pattern and team of a run you started here: the composer starts from them next
|
|
169
|
+
* time. A convenience, so it never fails the run: a save that fails is only logged.
|
|
170
|
+
*/
|
|
171
|
+
async remember(id, run) {
|
|
172
|
+
await this.change(() => {
|
|
173
|
+
const p = this.find(id);
|
|
174
|
+
if (!p) return;
|
|
175
|
+
const team = { roles: run.roles };
|
|
176
|
+
if (run.roleSettings && Object.keys(run.roleSettings).length) team.roleSettings = run.roleSettings;
|
|
177
|
+
p.defaults = {
|
|
178
|
+
pattern: run.pattern,
|
|
179
|
+
teams: { ...p.defaults?.teams ?? {}, [run.pattern]: team },
|
|
180
|
+
agentSettings: run.agentSettings && Object.keys(run.agentSettings).length ? run.agentSettings : void 0
|
|
181
|
+
};
|
|
182
|
+
}).catch((err) => logError("Couldn't remember the team for the project", err));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Make a change and save it, one change at a time. If the save fails (a full disk, a folder
|
|
186
|
+
* that isn't writable), the change is undone and the caller gets the error: nothing is
|
|
187
|
+
* reported done, or written into a run's log, that a restart would lose.
|
|
188
|
+
*/
|
|
189
|
+
change(fn) {
|
|
190
|
+
const next = this.queue.then(async () => {
|
|
191
|
+
if (!this.writable) throw new Error(`Projects are off: ${this.file} couldn't be read (see server.log)`);
|
|
192
|
+
const before = JSON.stringify(this.projects);
|
|
193
|
+
this.committedWhileWriting = JSON.parse(before);
|
|
194
|
+
try {
|
|
195
|
+
const result = await fn();
|
|
196
|
+
if (JSON.stringify(this.projects) === before) return result;
|
|
197
|
+
try {
|
|
198
|
+
await this.write();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
throw new Error(`code-flow couldn't save projects: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
this.committedWhileWriting = void 0;
|
|
203
|
+
for (const listener of this.listeners) {
|
|
204
|
+
try {
|
|
205
|
+
listener();
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this.projects = JSON.parse(before);
|
|
212
|
+
throw err;
|
|
213
|
+
} finally {
|
|
214
|
+
this.committedWhileWriting = void 0;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
this.queue = next.catch(() => void 0);
|
|
218
|
+
return next;
|
|
219
|
+
}
|
|
220
|
+
async write() {
|
|
221
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
222
|
+
const tmp = `${this.file}.tmp`;
|
|
223
|
+
await writeFile(tmp, JSON.stringify(this.projects, null, 2), { mode: 384 });
|
|
224
|
+
await rename(tmp, this.file);
|
|
225
|
+
}
|
|
226
|
+
find(id) {
|
|
227
|
+
return this.projects.find((p) => p.id === id);
|
|
228
|
+
}
|
|
229
|
+
require(id) {
|
|
230
|
+
const p = this.find(id);
|
|
231
|
+
if (!p) throw new Error("Project not found");
|
|
232
|
+
return p;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A new project for a repo, named after it, or after it and its parent folder if that's taken,
|
|
236
|
+
* or numbered. Every candidate is cut to the length limit before it's checked, so two long
|
|
237
|
+
* names can't end up the same once shortened.
|
|
238
|
+
*/
|
|
239
|
+
add(repo, createdAt = Date.now()) {
|
|
240
|
+
const base = basename(repo) || repo;
|
|
241
|
+
const fit = (suffix = "") => `${base.slice(0, MAX_NAME - suffix.length)}${suffix}`;
|
|
242
|
+
const taken = (name2) => this.projects.some((p) => p.name.toLowerCase() === name2.toLowerCase());
|
|
243
|
+
let name = fit();
|
|
244
|
+
if (taken(name)) name = fit(` (${basename(dirname(repo)).slice(0, 20)})`);
|
|
245
|
+
for (let i = 2; taken(name); i++) name = fit(` ${i}`);
|
|
246
|
+
const project = { id: `p-${randomBytes(4).toString("hex")}`, name, repos: [repo], createdAt };
|
|
247
|
+
this.projects.push(project);
|
|
248
|
+
return project;
|
|
249
|
+
}
|
|
250
|
+
checkName(input, self) {
|
|
251
|
+
const name = String(input ?? "").replace(/\s+/g, " ").trim();
|
|
252
|
+
if (!name) throw new Error("Name the project");
|
|
253
|
+
if (name.length > MAX_NAME) throw new Error(`Keep the name under ${MAX_NAME} characters`);
|
|
254
|
+
if (this.projects.some((p) => p.id !== self && p.name.toLowerCase() === name.toLowerCase())) throw new Error(`There's already a project called \u201C${name}\u201D`);
|
|
255
|
+
return name;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function checkRepos(input, current = []) {
|
|
259
|
+
if (!Array.isArray(input)) throw new Error("Repositories must be a list of folders");
|
|
260
|
+
if (input.length > MAX_REPOS) throw new Error(`A project holds at most ${MAX_REPOS} repositories`);
|
|
261
|
+
const repos = [];
|
|
262
|
+
for (const raw of input) {
|
|
263
|
+
const path = String(raw ?? "").trim();
|
|
264
|
+
if (current.includes(path)) {
|
|
265
|
+
if (!repos.includes(path)) repos.push(path);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!path.startsWith("/")) throw new Error("Repositories must be absolute paths");
|
|
269
|
+
const root = await repoRoot(path).catch(() => {
|
|
270
|
+
throw new Error(`${path} isn't a git repository`);
|
|
271
|
+
});
|
|
272
|
+
if (!repos.includes(root)) repos.push(root);
|
|
273
|
+
}
|
|
274
|
+
return repos;
|
|
275
|
+
}
|
|
276
|
+
function copy(p) {
|
|
277
|
+
return structuredClone(p);
|
|
278
|
+
}
|
|
279
|
+
function isProject(p) {
|
|
280
|
+
const x = p;
|
|
281
|
+
return !!x && typeof x.id === "string" && typeof x.name === "string" && typeof x.createdAt === "number" && Array.isArray(x.repos) && x.repos.every((r) => typeof r === "string");
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
Projects,
|
|
285
|
+
projectOf
|
|
286
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
function providerEligibility(id, input) {
|
|
2
|
+
const trust = input.unattended && input.trust === "full" ? "trusted" : input.trust;
|
|
3
|
+
if (id.startsWith("acp:") && (trust !== "full" || input.unattended || input.remote || input.container)) return "ACP connections are unavailable for scheduled teams; they require local interactive Full access";
|
|
4
|
+
if (input.projectTeam && !["claude", "codex", "agy"].includes(id)) return "This provider has not been validated for unattended project teams";
|
|
5
|
+
if (id.startsWith("plugin:") && (input.remote || input.container)) return "Extension providers require local execution";
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
providerEligibility
|
|
9
|
+
};
|
package/src/readiness.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { repoRoot, resolveCommit, git, openDetachedWorktree, removeDetachedWorktree } from "./git.js";
|
|
5
|
+
import { readRepoConfig } from "./repo-config.js";
|
|
6
|
+
import { checkGit, doctor } from "./doctor.js";
|
|
7
|
+
import { detectCheckCommand, runCheck } from "./checks.js";
|
|
8
|
+
import { Slots } from "./slots.js";
|
|
9
|
+
import { AGENT_IDS } from "./adapters/index.js";
|
|
10
|
+
import { redactText } from "./privacy.js";
|
|
11
|
+
const validationSlots = new Slots(1);
|
|
12
|
+
async function inspectReadiness(input) {
|
|
13
|
+
const repo = await repoRoot(input.repo);
|
|
14
|
+
const base = await resolveCommit(repo, input.baseRef || "HEAD");
|
|
15
|
+
const [{ config, found, error }, health, gitHealth, detection, dirty] = await Promise.all([
|
|
16
|
+
readRepoConfig(repo),
|
|
17
|
+
doctor({}),
|
|
18
|
+
checkGit(),
|
|
19
|
+
detectCheckCommand(repo),
|
|
20
|
+
git(["status", "--porcelain"], repo)
|
|
21
|
+
]);
|
|
22
|
+
const setup = input.setup === void 0 ? config.setup ?? detection?.setup : input.setup.trim();
|
|
23
|
+
const check = input.check === void 0 ? config.check ?? detection?.command : input.check.trim();
|
|
24
|
+
const items = [
|
|
25
|
+
{ id: "git", label: "Git", status: gitHealth.ok ? "passed" : "failed", detail: gitHealth.warning ?? gitHealth.version },
|
|
26
|
+
{ id: "repository", label: "Repository and base", status: "passed", detail: `${repo} \xB7 ${base.slice(0, 12)}` },
|
|
27
|
+
{ id: "configuration", label: "Repository configuration", status: error ? "failed" : "passed", detail: error ?? (found ? "Using .codeflow/config.json" : "Using detected defaults") },
|
|
28
|
+
{ id: "working-copy", label: "Working copy", status: dirty.stdout.trim() ? "warning" : "passed", detail: dirty.stdout.trim() ? "Uncommitted changes are not part of validation at the selected commit." : "Clean" },
|
|
29
|
+
...providerReadiness(input, health),
|
|
30
|
+
{ id: "check", label: "Verification command", status: check ? "passed" : "warning", detail: check || "No check configured; completion cannot be independently verified." }
|
|
31
|
+
];
|
|
32
|
+
if (input.execute && !error) {
|
|
33
|
+
await inTemporaryWorktree(repo, base, async (path, opened) => {
|
|
34
|
+
if (opened.warning) items.push({ id: "submodules", label: "Submodules", status: "warning", detail: opened.warning });
|
|
35
|
+
let setupOk = true;
|
|
36
|
+
for (const [id, command] of [["setup-run", setup], ["check-run", check]]) {
|
|
37
|
+
if (!command || !setupOk) {
|
|
38
|
+
items.push({ id, label: id === "setup-run" ? "Setup execution" : "Check execution", status: "skipped", detail: !setupOk ? "Setup failed" : "No command configured" });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const result = await runCheck(path, command, { timeoutMs: 12e4, signal: input.signal });
|
|
42
|
+
items.push({ id, label: id === "setup-run" ? "Setup execution" : "Check execution", status: result.ok ? "passed" : "failed", detail: redactText(result.output.slice(-8e3) || `${command}: exit ${result.exitCode}`) });
|
|
43
|
+
if (id === "setup-run") setupOk = result.ok;
|
|
44
|
+
}
|
|
45
|
+
}, input.signal);
|
|
46
|
+
}
|
|
47
|
+
return { ok: !items.some((item) => item.status === "failed"), repo, base, setup, check, executed: input.execute === true, items, checkedAt: Date.now() };
|
|
48
|
+
}
|
|
49
|
+
function providerReadiness(input, health) {
|
|
50
|
+
const scoped = input.roles !== void 0 || input.providers !== void 0;
|
|
51
|
+
const roles = Object.entries(input.roles ?? {}).map(([role, values]) => [role, typeof values === "string" ? [values] : values]);
|
|
52
|
+
const required = new Set(roles.filter(([role]) => input.pattern !== "auto-team" || role !== "pool").flatMap(([, values]) => values).filter((id) => id !== "auto"));
|
|
53
|
+
if (input.roles === void 0) {
|
|
54
|
+
for (const id of input.providers ?? []) if (id !== "auto") required.add(id);
|
|
55
|
+
}
|
|
56
|
+
const autoTeam = input.pattern === "auto-team" && input.roles !== void 0;
|
|
57
|
+
const pool = autoTeam ? roles.find(([role]) => role === "pool")?.[1] ?? [] : input.providers ?? AGENT_IDS;
|
|
58
|
+
const needsAuto = autoTeam || roles.some(([, values]) => values.includes("auto")) || input.providers?.includes("auto") === true;
|
|
59
|
+
const eligible = new Set(pool.filter((id) => id !== "auto" && (!autoTeam || input.providers === void 0 || input.providers.includes(id))));
|
|
60
|
+
const usable = (agent) => agent.installed && agent.compatible !== false && agent.signedIn !== false;
|
|
61
|
+
const items = health.map((agent) => {
|
|
62
|
+
const needed = !scoped || required.has(agent.id);
|
|
63
|
+
const candidate = scoped && needsAuto && eligible.has(agent.id);
|
|
64
|
+
let status;
|
|
65
|
+
if (!needed && !(candidate && usable(agent))) status = "skipped";
|
|
66
|
+
else if (!agent.installed) status = scoped ? "failed" : "skipped";
|
|
67
|
+
else if (!usable(agent)) status = "failed";
|
|
68
|
+
else status = agent.signedIn === null ? "warning" : "passed";
|
|
69
|
+
return { id: `agent-${agent.id}`, label: agent.name, status, detail: !needed && !candidate ? "Not selected for this workflow" : agent.fix ?? [agent.version, agent.signedIn === true ? "Signed in" : agent.signedIn === null ? "Authentication unknown" : "Not signed in"].filter(Boolean).join(" \xB7 ") };
|
|
70
|
+
});
|
|
71
|
+
for (const id of required) if (!health.some((agent) => agent.id === id)) items.push({ id: `agent-${id}`, label: id, status: "failed", detail: "Selected provider is unavailable" });
|
|
72
|
+
if (!scoped || needsAuto || !required.size) {
|
|
73
|
+
const available = health.some((agent) => usable(agent) && (!scoped || needsAuto && eligible.has(agent.id)));
|
|
74
|
+
if (!available) items.push({ id: "available-agent", label: "Usable agent", status: "failed", detail: scoped ? "Select at least one usable provider within the permitted pool." : "Install and sign in to at least one compatible coding agent." });
|
|
75
|
+
}
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
async function inTemporaryWorktree(repo, ref, run, signal) {
|
|
79
|
+
const release = await validationSlots.acquire(signal);
|
|
80
|
+
let folder;
|
|
81
|
+
try {
|
|
82
|
+
folder = await mkdtemp(join(tmpdir(), "codeflow-validation-"));
|
|
83
|
+
const path = join(folder, "checkout");
|
|
84
|
+
try {
|
|
85
|
+
const opened = await openDetachedWorktree(repo, path, ref, { signal });
|
|
86
|
+
return await run(path, opened);
|
|
87
|
+
} finally {
|
|
88
|
+
await removeDetachedWorktree(repo, path);
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
if (folder) await rm(folder, { recursive: true, force: true });
|
|
92
|
+
release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export {
|
|
96
|
+
inTemporaryWorktree,
|
|
97
|
+
inspectReadiness
|
|
98
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { X509Certificate, createPrivateKey, randomBytes } from "node:crypto";
|
|
2
|
+
import { createSecureContext } from "node:tls";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { CODEFLOW_HOME } from "./config.js";
|
|
7
|
+
const FILE = join(CODEFLOW_HOME, "remote-tls.json");
|
|
8
|
+
function certificateHostname(raw) {
|
|
9
|
+
if (typeof raw !== "string") throw new Error("Enter the hostname or IP address your other device will use");
|
|
10
|
+
const host = raw.trim().toLowerCase();
|
|
11
|
+
if (!host || host.length > 253 || !isIP(host) && !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) throw new Error("Enter a hostname or IP address without https://, a port or a path");
|
|
12
|
+
return host;
|
|
13
|
+
}
|
|
14
|
+
function validateRemoteCertificate(input, now = Date.now(), allowExpired = false) {
|
|
15
|
+
const hostname = certificateHostname(input.hostname);
|
|
16
|
+
if (input.source !== "import" && input.source !== "tailscale") throw new Error("Choose Tailscale or import a certificate");
|
|
17
|
+
if (typeof input.cert !== "string" || typeof input.key !== "string" || input.cert.length > 128 * 1024 || input.key.length > 32 * 1024) throw new Error("Choose a PEM certificate and private key within the size limits");
|
|
18
|
+
let cert;
|
|
19
|
+
try {
|
|
20
|
+
cert = new X509Certificate(input.cert);
|
|
21
|
+
const key = createPrivateKey(input.key);
|
|
22
|
+
if (!cert.checkPrivateKey(key)) throw new Error("mismatch");
|
|
23
|
+
createSecureContext({ cert: input.cert, key: input.key, minVersion: "TLSv1.2" });
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error("The certificate and private key could not be read or do not match. Use PEM files with an unencrypted private key.");
|
|
26
|
+
}
|
|
27
|
+
const expiresAt = Date.parse(cert.validTo);
|
|
28
|
+
if (!Number.isFinite(expiresAt) || !allowExpired && expiresAt <= now || Date.parse(cert.validFrom) > now) throw new Error("The certificate is expired or not valid yet");
|
|
29
|
+
if (!(isIP(hostname) ? cert.checkIP(hostname) : cert.checkHost(hostname, { subject: "never" }))) throw new Error("The certificate does not cover this hostname or IP address");
|
|
30
|
+
return { hostname, source: input.source, expiresAt };
|
|
31
|
+
}
|
|
32
|
+
async function loadRemoteCertificate(file = FILE, allowExpired = false) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(file, "utf8");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "ENOENT") return;
|
|
38
|
+
throw new Error("Saved HTTPS configuration could not be read. Import or renew the certificate in Remote access settings.");
|
|
39
|
+
}
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(raw);
|
|
43
|
+
validateRemoteCertificate(value, Date.now(), allowExpired);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("Saved HTTPS configuration is invalid or expired. Import or renew the certificate in Remote access settings.");
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
async function saveRemoteCertificate(value, file = FILE) {
|
|
50
|
+
validateRemoteCertificate(value);
|
|
51
|
+
await mkdir(dirname(file), { recursive: true, mode: 448 });
|
|
52
|
+
const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp`;
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(temporary, JSON.stringify(value), { mode: 384, flag: "wx" });
|
|
55
|
+
await chmod(temporary, 384);
|
|
56
|
+
await rename(temporary, file);
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(temporary, { force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
certificateHostname,
|
|
63
|
+
loadRemoteCertificate,
|
|
64
|
+
saveRemoteCertificate,
|
|
65
|
+
validateRemoteCertificate
|
|
66
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { git, resolveCommit, withRepoLock } from "../git.js";
|
|
8
|
+
import { verificationIntegrity } from "../verification-integrity.js";
|
|
9
|
+
import { childEnv } from "../env.js";
|
|
10
|
+
import { CHUNK_BYTES, COMMIT_ID, contextPath, MAX_BUNDLE_BYTES, MAX_CONTEXT_BYTES, RemoteWorkerError, REQUEST_ID } from "./protocol.js";
|
|
11
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
12
|
+
const identity = ["-c", "core.hooksPath=/dev/null", "-c", "fetch.fsckObjects=true", "-c", "transfer.fsckObjects=true", "-c", "submodule.recurse=false"];
|
|
13
|
+
async function boundedBundle(repo, path, revisions) {
|
|
14
|
+
const data = await new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn("git", [...identity, "bundle", "create", "-", ...revisions], { cwd: repo, env: childEnv({ GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" }), detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
16
|
+
const parts = [];
|
|
17
|
+
let size = 0, stderr = "", error;
|
|
18
|
+
const kill = () => {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(-child.pid, "SIGKILL");
|
|
21
|
+
} catch {
|
|
22
|
+
child.kill("SIGKILL");
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
error = new RemoteWorkerError("Git bundle creation exceeded 30 seconds. Sync the provisioned worker mapping, then retry explicitly.");
|
|
27
|
+
kill();
|
|
28
|
+
}, 3e4);
|
|
29
|
+
child.stdout.on("data", (part) => {
|
|
30
|
+
size += part.length;
|
|
31
|
+
if (size > MAX_BUNDLE_BYTES) {
|
|
32
|
+
error = new RemoteWorkerError("Git bundle exceeds 64 MiB. Sync the provisioned worker mapping to the repository base, then retry explicitly.");
|
|
33
|
+
kill();
|
|
34
|
+
} else if (!error) parts.push(part);
|
|
35
|
+
});
|
|
36
|
+
child.stderr.on("data", (part) => {
|
|
37
|
+
stderr = (stderr + part.toString()).slice(-2e3);
|
|
38
|
+
});
|
|
39
|
+
child.on("error", (failure) => {
|
|
40
|
+
error = failure;
|
|
41
|
+
});
|
|
42
|
+
child.on("close", (code) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
if (error || code) reject(error ?? new RemoteWorkerError(stderr.trim() || "Cannot create Git bundle"));
|
|
45
|
+
else resolve(Buffer.concat(parts));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
await writeFile(path, data, { flag: "wx", mode: 384 });
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
async function gitChecked(repo, args) {
|
|
52
|
+
const result = await git([...identity, ...args], repo, { timeoutMs: 3e4 });
|
|
53
|
+
if (result.code) throw new RemoteWorkerError(result.stderr.trim().slice(-1e3) || `Remote Git ${args[0]} failed`);
|
|
54
|
+
return result.stdout.trim();
|
|
55
|
+
}
|
|
56
|
+
async function assertTransferTree(repo, revision) {
|
|
57
|
+
if (!COMMIT_ID.test(revision)) throw new RemoteWorkerError("Remote transfer needs an exact Git object ID");
|
|
58
|
+
const entries = (await gitChecked(repo, ["ls-tree", "-r", "-l", "-z", revision])).split("\0").filter(Boolean);
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
const attributes = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const at = entry.indexOf(" "), fields = entry.slice(0, at).split(/\s+/), path = entry.slice(at + 1);
|
|
63
|
+
if (fields[0] === "160000") throw new RemoteWorkerError("Remote Git bundle turns do not transfer submodule contents. Use local execution for this repository.");
|
|
64
|
+
bytes += Number(fields[3]) || 0;
|
|
65
|
+
if (path === ".gitattributes" || path.endsWith("/.gitattributes")) attributes.push(path);
|
|
66
|
+
}
|
|
67
|
+
if (entries.length > 2e4 || bytes > 256 * 1024 * 1024 || attributes.length > 100) throw new RemoteWorkerError("Remote source/result tree exceeds 20,000 files, 256 MiB or 100 attribute files");
|
|
68
|
+
let attributeBytes = 0;
|
|
69
|
+
for (const path of attributes) {
|
|
70
|
+
const value = await gitChecked(repo, ["show", `${revision}:${path}`]);
|
|
71
|
+
attributeBytes += Buffer.byteLength(value);
|
|
72
|
+
if (attributeBytes > 256 * 1024) throw new RemoteWorkerError("Remote checkout attributes exceed 256 KiB");
|
|
73
|
+
if (value.split(/\r?\n/).some((row) => !row.trimStart().startsWith("#") && row.split(/\s+/).some((part) => /^[-!]?filter(?:=|$)/.test(part)))) throw new RemoteWorkerError("Remote Git bundle turns do not materialize checkout filters such as Git LFS. Use local execution or a repository without checkout filters.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function disabledCheckoutFilters(repo) {
|
|
77
|
+
const result = await git(["config", "--name-only", "--get-regexp", "^filter\\..*\\.(smudge|clean|process|required)$"], repo, { timeoutMs: 1e4 });
|
|
78
|
+
if (result.code > 1) throw new RemoteWorkerError("Cannot inspect checkout filter policy");
|
|
79
|
+
const keys = [...new Set(result.stdout.split(/\r?\n/).filter(Boolean))];
|
|
80
|
+
if (keys.length > 100 || keys.some((key) => !/^filter\.[^\x00-\x20\x7f]+\.(?:smudge|clean|process|required)$/.test(key))) throw new RemoteWorkerError("Worker Git checkout filter configuration exceeds its supported bound");
|
|
81
|
+
return keys.flatMap((key) => ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`]);
|
|
82
|
+
}
|
|
83
|
+
async function sourceBundle(repo, baseCommit, requestId, exclude) {
|
|
84
|
+
if (!COMMIT_ID.test(baseCommit) || !REQUEST_ID.test(requestId) || exclude && !COMMIT_ID.test(exclude)) throw new RemoteWorkerError("Choose exact transfer commits and a valid request ID");
|
|
85
|
+
const folder = await mkdtemp(join(tmpdir(), "codeflow-remote-source-")), path = join(folder, "source.bundle"), ref = `refs/codeflow/transfers/${requestId}`;
|
|
86
|
+
let claimed = false;
|
|
87
|
+
try {
|
|
88
|
+
await gitChecked(repo, ["update-ref", ref, baseCommit, "0".repeat(baseCommit.length)]);
|
|
89
|
+
claimed = true;
|
|
90
|
+
const data = await boundedBundle(repo, path, [ref, ...exclude && exclude !== baseCommit ? [`^${exclude}`] : []]);
|
|
91
|
+
return { data, size: data.length, sha256: sha256(data), ref };
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
if (claimed) await gitChecked(repo, ["update-ref", "-d", ref, baseCommit]);
|
|
95
|
+
} finally {
|
|
96
|
+
await rm(folder, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function contextManifest(repo) {
|
|
101
|
+
const root = join(repo, ".codeflow-context"), files = [], data = /* @__PURE__ */ new Map();
|
|
102
|
+
let total = 0, entries = 0;
|
|
103
|
+
const info = await lstat(root).catch((error) => {
|
|
104
|
+
if (error.code === "ENOENT") return void 0;
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
if (!info) return { files, data };
|
|
108
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new RemoteWorkerError("Remote staged context must be a regular directory");
|
|
109
|
+
const visit = async (directory, relative) => {
|
|
110
|
+
for (const name of await readdir(directory)) {
|
|
111
|
+
if (++entries > 1e3) throw new RemoteWorkerError("Remote context contains too many filesystem entries");
|
|
112
|
+
const path = `${relative}/${name}`, absolute = join(directory, name), info2 = await lstat(absolute);
|
|
113
|
+
if (info2.isSymbolicLink()) throw new RemoteWorkerError("Remote context cannot contain symbolic links");
|
|
114
|
+
if (info2.isDirectory()) {
|
|
115
|
+
if (path.split("/").length > 32) throw new RemoteWorkerError("Remote context nesting exceeds its bound");
|
|
116
|
+
await visit(absolute, path);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!info2.isFile() || !contextPath(path) || info2.size > 8 * 1024 * 1024 || files.length >= 100) throw new RemoteWorkerError("Remote context contains an unsupported, sensitive or oversized file");
|
|
120
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
121
|
+
let value;
|
|
122
|
+
try {
|
|
123
|
+
const current = await handle.stat();
|
|
124
|
+
if (!current.isFile() || current.size > 8 * 1024 * 1024 || current.ino !== info2.ino || current.dev !== info2.dev) throw new RemoteWorkerError("Staged context changed while being inspected");
|
|
125
|
+
value = await handle.readFile();
|
|
126
|
+
const after = await handle.stat();
|
|
127
|
+
if (after.size !== current.size || after.mtimeMs !== current.mtimeMs || value.length > 8 * 1024 * 1024) throw new RemoteWorkerError("Staged context changed while being copied");
|
|
128
|
+
} finally {
|
|
129
|
+
await handle.close();
|
|
130
|
+
}
|
|
131
|
+
total += value.length;
|
|
132
|
+
if (total > MAX_CONTEXT_BYTES) throw new RemoteWorkerError("Remote staged context exceeds 16 MiB");
|
|
133
|
+
files.push({ path, size: value.length, sha256: sha256(value) });
|
|
134
|
+
data.set(path, value);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
await visit(root, ".codeflow-context");
|
|
138
|
+
return { files, data };
|
|
139
|
+
}
|
|
140
|
+
async function importWorkerArtifact(repo, artifact, expectedHead) {
|
|
141
|
+
if (!REQUEST_ID.test(artifact.requestId) || !COMMIT_ID.test(artifact.head) || artifact.baseCommit !== expectedHead || artifact.ref !== `refs/codeflow/results/${artifact.requestId}` || artifact.size > MAX_BUNDLE_BYTES) throw new RemoteWorkerError("Invalid remote result artifact");
|
|
142
|
+
const info = await lstat(artifact.file);
|
|
143
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size !== artifact.size || sha256(await readFile(artifact.file)) !== artifact.sha256) throw new RemoteWorkerError("Remote result bundle failed its integrity check");
|
|
144
|
+
return withRepoLock(typeof repo === "string" ? repo : repo.path, async () => {
|
|
145
|
+
if (await resolveCommit(repo) !== expectedHead) throw new RemoteWorkerError("The local worktree changed while remote work was running");
|
|
146
|
+
const integrity = await verificationIntegrity(repo);
|
|
147
|
+
const ref = `refs/codeflow/imports/${artifact.requestId}`;
|
|
148
|
+
let claimed = false;
|
|
149
|
+
try {
|
|
150
|
+
const heads = await gitChecked(repo, ["bundle", "list-heads", artifact.file]);
|
|
151
|
+
if (heads !== `${artifact.head} ${artifact.ref}`) throw new RemoteWorkerError("Remote result bundle has unexpected references");
|
|
152
|
+
await gitChecked(repo, ["bundle", "verify", artifact.file]);
|
|
153
|
+
const exists = await git(["show-ref", "--verify", "--quiet", ref], repo);
|
|
154
|
+
if (!exists.code) throw new RemoteWorkerError("This remote import reference is already claimed");
|
|
155
|
+
await gitChecked(repo, ["fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", artifact.file, `${artifact.ref}:${ref}`]);
|
|
156
|
+
claimed = true;
|
|
157
|
+
const imported = await resolveCommit(repo, ref);
|
|
158
|
+
if (imported !== artifact.head || (await git(["merge-base", "--is-ancestor", expectedHead, imported], repo)).code) throw new RemoteWorkerError("Remote result is not a descendant of the accepted base");
|
|
159
|
+
await assertTransferTree(repo, imported);
|
|
160
|
+
const filters = await disabledCheckoutFilters(repo);
|
|
161
|
+
await integrity.assert("remote result import");
|
|
162
|
+
await gitChecked(repo, [...filters, "merge", "--ff-only", "--no-edit", imported]);
|
|
163
|
+
return { head: imported };
|
|
164
|
+
} finally {
|
|
165
|
+
if (claimed) await gitChecked(repo, ["update-ref", "-d", ref, artifact.head]);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async function writeContext(root, path, value) {
|
|
170
|
+
if (!contextPath(path)) throw new RemoteWorkerError("Invalid remote context path");
|
|
171
|
+
let current = root;
|
|
172
|
+
for (const part of path.split("/").slice(0, -1)) {
|
|
173
|
+
current = join(current, part);
|
|
174
|
+
await mkdir(current, { recursive: false, mode: 448 }).catch((error) => {
|
|
175
|
+
if (error.code !== "EEXIST") throw error;
|
|
176
|
+
});
|
|
177
|
+
const info = await lstat(current);
|
|
178
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new RemoteWorkerError("Remote context directory is not regular");
|
|
179
|
+
}
|
|
180
|
+
await writeFile(join(root, path), value, { flag: "wx", mode: 384 });
|
|
181
|
+
}
|
|
182
|
+
function chunks(data) {
|
|
183
|
+
const parts = [];
|
|
184
|
+
for (let offset = 0; offset < data.length; offset += CHUNK_BYTES) parts.push({ offset, data: Buffer.from(data.subarray(offset, offset + CHUNK_BYTES)).toString("base64") });
|
|
185
|
+
return parts;
|
|
186
|
+
}
|
|
187
|
+
export {
|
|
188
|
+
assertTransferTree,
|
|
189
|
+
boundedBundle,
|
|
190
|
+
chunks,
|
|
191
|
+
contextManifest,
|
|
192
|
+
disabledCheckoutFilters,
|
|
193
|
+
gitChecked,
|
|
194
|
+
importWorkerArtifact,
|
|
195
|
+
sha256,
|
|
196
|
+
sourceBundle,
|
|
197
|
+
writeContext
|
|
198
|
+
};
|