@proagentstore/cli 0.4.77 → 0.4.78
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/dist/browser-runner/local-artifact/contract.js +188 -0
- package/dist/browser-runner/local-artifact/engine.js +166 -0
- package/dist/browser-runner/local-artifact/runtime.js +418 -0
- package/dist/browser-runner/runner.js +7 -1
- package/dist/browser-runner/server.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local artifact generation — the wire contract between PAGS and the runner (#956, epic #943).
|
|
3
|
+
*
|
|
4
|
+
* The Application Tailor turns an approved job lead (#955) into a tailored résumé and cover letter,
|
|
5
|
+
* written by a Codex or Claude Code CLI signed in on the owner's machine, from source files the
|
|
6
|
+
* owner keeps in a workspace folder (`~/jobs` by default). No repository, no browser, no Workers AI
|
|
7
|
+
* and no provider API key: the CLI runs on the machine's own login or subscription only.
|
|
8
|
+
*
|
|
9
|
+
* Like `local-browser/contract.ts`, this file is kept BYTE-IDENTICAL at
|
|
10
|
+
*
|
|
11
|
+
* workers/api/src/lib/local-artifact/contract.ts
|
|
12
|
+
* packages/browser-runner/src/local-artifact/contract.ts
|
|
13
|
+
*
|
|
14
|
+
* (`contract.test.ts` fails the moment they differ) and imports nothing, so it can be vendored.
|
|
15
|
+
*
|
|
16
|
+
* PULL, not push: the runner holds no API token, so PAGS reads a run's events and result with
|
|
17
|
+
* `status` — when an application is read and from the per-minute cron.
|
|
18
|
+
*/
|
|
19
|
+
export const LOCAL_ARTIFACT_TASK_TYPE = "local_artifact.generate";
|
|
20
|
+
export const LOCAL_ARTIFACT_RUN_PATH = "/local-artifact/run";
|
|
21
|
+
export const LOCAL_ARTIFACT_STATUS_PATH = "/local-artifact/status";
|
|
22
|
+
export const LOCAL_ARTIFACT_CANCEL_PATH = "/local-artifact/cancel";
|
|
23
|
+
/** The readiness event a completed tailoring run emits, once, through the connection outbox. */
|
|
24
|
+
export const MATERIALS_READY_EVENT = "job.application.materials_ready";
|
|
25
|
+
export const LOCAL_ARTIFACT_ENGINES = ["claude", "codex"];
|
|
26
|
+
export const LOCAL_ARTIFACT_AUTH_MODES = ["machine", "subscription"];
|
|
27
|
+
export const LOCAL_ARTIFACT_SOURCE_ROLES = ["resume", "profile", "answers"];
|
|
28
|
+
export const REQUIRED_SOURCE_ROLES = ["resume", "profile"];
|
|
29
|
+
export const LOCAL_ARTIFACT_BLOCK_REASONS = [
|
|
30
|
+
"missing_source",
|
|
31
|
+
"malformed_lead",
|
|
32
|
+
"workspace_unavailable",
|
|
33
|
+
"missing_information",
|
|
34
|
+
"uncertain_claim",
|
|
35
|
+
"engine_not_signed_in",
|
|
36
|
+
"api_key_refused",
|
|
37
|
+
];
|
|
38
|
+
export const LOCAL_ARTIFACT_EVENT_TYPES = [
|
|
39
|
+
"engine.auth_checked",
|
|
40
|
+
"engine.started",
|
|
41
|
+
"engine.ended",
|
|
42
|
+
"source.read",
|
|
43
|
+
"source.missing",
|
|
44
|
+
"claims.checked",
|
|
45
|
+
"artifact.written",
|
|
46
|
+
"run.needs_human",
|
|
47
|
+
"retention.swept",
|
|
48
|
+
"note",
|
|
49
|
+
];
|
|
50
|
+
export const LOCAL_ARTIFACT_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
51
|
+
export const LOCAL_ARTIFACT_CAPS = { questions: 20, questionChars: 300, sources: 6, sourceBytes: 256 * 1024 };
|
|
52
|
+
/** An id used as a folder name: no separators, no dots, nothing a path could be built out of. */
|
|
53
|
+
export const PATH_SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
|
|
54
|
+
/** A source path inside the workspace: relative, no `..`, no absolute, no NUL, no backslash. */
|
|
55
|
+
export function isWorkspaceRelative(path) {
|
|
56
|
+
if (!path || path.length > 300 || path.startsWith("/") || path.startsWith("~") || /[\0\\]/.test(path))
|
|
57
|
+
return false;
|
|
58
|
+
return path.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
|
|
59
|
+
}
|
|
60
|
+
/** A workspace folder: `~/…` with the same segment rules. */
|
|
61
|
+
export function isHomeRelative(path) {
|
|
62
|
+
return typeof path === "string" && path.startsWith("~/") && isWorkspaceRelative(path.slice(2));
|
|
63
|
+
}
|
|
64
|
+
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
65
|
+
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
66
|
+
const LEAD_FIELDS = ["company", "location", "url", "source", "posted_date", "match_rationale"];
|
|
67
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
68
|
+
/**
|
|
69
|
+
* The approved lead, validated — or why it is not one. A malformed lead never reaches a CLI: it
|
|
70
|
+
* pauses the application instead, because a résumé tailored to a half-read lead is a plausible guess.
|
|
71
|
+
*/
|
|
72
|
+
export function parseLocalArtifactLead(raw) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
74
|
+
return { error: "the lead must be an object" };
|
|
75
|
+
const o = raw;
|
|
76
|
+
const eventId = str(o.eventId, 300);
|
|
77
|
+
if (!eventId)
|
|
78
|
+
return { error: "the lead has no eventId" };
|
|
79
|
+
const sourceInstanceId = str(o.sourceInstanceId, 100);
|
|
80
|
+
if (!sourceInstanceId)
|
|
81
|
+
return { error: "the lead has no sourceInstanceId" };
|
|
82
|
+
const leadId = str(o.leadId, 100);
|
|
83
|
+
if (!leadId || !PATH_SEGMENT.test(leadId))
|
|
84
|
+
return { error: "the lead has no usable leadId" };
|
|
85
|
+
const lifecycleVersion = o.lifecycleVersion;
|
|
86
|
+
if (typeof lifecycleVersion !== "number" || !Number.isInteger(lifecycleVersion) || lifecycleVersion < 1)
|
|
87
|
+
return { error: "the lead has no lifecycleVersion" };
|
|
88
|
+
const leadUrl = str(o.leadUrl, 2000) ?? "";
|
|
89
|
+
if (leadUrl && !/^https?:\/\/[^\s/]+/i.test(leadUrl))
|
|
90
|
+
return { error: "the lead's URL is not an http(s) URL" };
|
|
91
|
+
const requestedAt = str(o.requestedAt, 40);
|
|
92
|
+
if (!requestedAt || Number.isNaN(Date.parse(requestedAt)))
|
|
93
|
+
return { error: "the lead has no requestedAt time" };
|
|
94
|
+
const body = o.lead && typeof o.lead === "object" && !Array.isArray(o.lead) ? o.lead : null;
|
|
95
|
+
const title = body ? str(body.title, 300) : null;
|
|
96
|
+
if (!body || !title)
|
|
97
|
+
return { error: "the lead has no job title" };
|
|
98
|
+
const lead = { title };
|
|
99
|
+
for (const f of LEAD_FIELDS) {
|
|
100
|
+
const v = str(body[f], f === "match_rationale" ? 2000 : 500);
|
|
101
|
+
if (v)
|
|
102
|
+
lead[f] = v;
|
|
103
|
+
}
|
|
104
|
+
return { lead: { eventId, sourceInstanceId, leadId, leadUrl, lifecycleVersion, requestedAt, lead } };
|
|
105
|
+
}
|
|
106
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status"]);
|
|
107
|
+
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
108
|
+
export function parseLocalArtifactEvent(raw) {
|
|
109
|
+
if (!raw || typeof raw !== "object")
|
|
110
|
+
return null;
|
|
111
|
+
const o = raw;
|
|
112
|
+
const type = oneOf(LOCAL_ARTIFACT_EVENT_TYPES, o.type);
|
|
113
|
+
const at = str(o.at, 40);
|
|
114
|
+
if (!type || !at || Number.isNaN(Date.parse(at)))
|
|
115
|
+
return null;
|
|
116
|
+
const event = { type, at };
|
|
117
|
+
if (o.detail && typeof o.detail === "object" && !Array.isArray(o.detail)) {
|
|
118
|
+
const detail = {};
|
|
119
|
+
for (const [k, v] of Object.entries(o.detail)) {
|
|
120
|
+
if (!DETAIL_KEYS.has(k))
|
|
121
|
+
continue;
|
|
122
|
+
if (typeof v === "string")
|
|
123
|
+
detail[k] = v.slice(0, 300);
|
|
124
|
+
else if (typeof v === "number" || typeof v === "boolean")
|
|
125
|
+
detail[k] = v;
|
|
126
|
+
}
|
|
127
|
+
if (Object.keys(detail).length)
|
|
128
|
+
event.detail = detail;
|
|
129
|
+
}
|
|
130
|
+
return event;
|
|
131
|
+
}
|
|
132
|
+
/** A validated result envelope, or the reason it is not one. */
|
|
133
|
+
export function parseLocalArtifactResult(raw) {
|
|
134
|
+
if (!raw || typeof raw !== "object")
|
|
135
|
+
return { error: "result must be an object" };
|
|
136
|
+
const o = raw;
|
|
137
|
+
const runId = str(o.runId, 100);
|
|
138
|
+
if (!runId)
|
|
139
|
+
return { error: "runId is required" };
|
|
140
|
+
const outcome = o.outcome === "completed" || o.outcome === "needs_human" || o.outcome === "failed" ? o.outcome : null;
|
|
141
|
+
if (!outcome)
|
|
142
|
+
return { error: "outcome must be completed, needs_human or failed" };
|
|
143
|
+
const traceId = str(o.traceId, 100);
|
|
144
|
+
if (!traceId)
|
|
145
|
+
return { error: "traceId is required" };
|
|
146
|
+
const engineAuth = oneOf(LOCAL_ARTIFACT_ENGINE_AUTHS, o.engineAuth);
|
|
147
|
+
if (!engineAuth)
|
|
148
|
+
return { error: `engineAuth must be one of ${LOCAL_ARTIFACT_ENGINE_AUTHS.join(", ")}` };
|
|
149
|
+
if (!Array.isArray(o.artifacts) || !Array.isArray(o.sourceHashes))
|
|
150
|
+
return { error: "artifacts and sourceHashes must be arrays" };
|
|
151
|
+
const artifacts = [];
|
|
152
|
+
for (const [i, a] of o.artifacts.slice(0, 10).entries()) {
|
|
153
|
+
const r = (a && typeof a === "object" ? a : {});
|
|
154
|
+
const kind = r.kind === "resume" || r.kind === "cover_letter" ? r.kind : null;
|
|
155
|
+
const path = str(r.path, 500);
|
|
156
|
+
const sha256 = str(r.sha256, 64);
|
|
157
|
+
if (!kind || !path || !isHomeRelative(path) || !sha256 || !HASH.test(sha256) || typeof r.bytes !== "number")
|
|
158
|
+
return { error: `artifacts[${i}] needs a kind, a ~/ path, a sha256 and bytes` };
|
|
159
|
+
artifacts.push({ kind, path, sha256, bytes: r.bytes });
|
|
160
|
+
}
|
|
161
|
+
const sourceHashes = [];
|
|
162
|
+
for (const [i, s] of o.sourceHashes.slice(0, LOCAL_ARTIFACT_CAPS.sources).entries()) {
|
|
163
|
+
const r = (s && typeof s === "object" ? s : {});
|
|
164
|
+
const role = oneOf(LOCAL_ARTIFACT_SOURCE_ROLES, r.role);
|
|
165
|
+
const path = str(r.path, 500);
|
|
166
|
+
const sha256 = str(r.sha256, 64);
|
|
167
|
+
if (!role || !path || !sha256 || !HASH.test(sha256) || typeof r.bytes !== "number")
|
|
168
|
+
return { error: `sourceHashes[${i}] needs a role, a path, a sha256 and bytes` };
|
|
169
|
+
sourceHashes.push({ role, path, sha256, bytes: r.bytes });
|
|
170
|
+
}
|
|
171
|
+
if (outcome === "completed" && (!artifacts.some((a) => a.kind === "resume") || !artifacts.some((a) => a.kind === "cover_letter"))) {
|
|
172
|
+
return { error: "a completed run must report both a resume and a cover_letter artifact" };
|
|
173
|
+
}
|
|
174
|
+
const out = { runId, outcome, artifacts, sourceHashes, profileVersion: str(o.profileVersion, 100), engineAuth, traceId };
|
|
175
|
+
const generatedAt = str(o.generatedAt, 40);
|
|
176
|
+
if (generatedAt && !Number.isNaN(Date.parse(generatedAt)))
|
|
177
|
+
out.generatedAt = generatedAt;
|
|
178
|
+
if (outcome === "needs_human") {
|
|
179
|
+
out.blockReason = oneOf(LOCAL_ARTIFACT_BLOCK_REASONS, o.blockReason) ?? "missing_information";
|
|
180
|
+
out.questions = (Array.isArray(o.questions) ? o.questions : [])
|
|
181
|
+
.filter((q) => typeof q === "string" && q.trim() !== "")
|
|
182
|
+
.slice(0, LOCAL_ARTIFACT_CAPS.questions)
|
|
183
|
+
.map((q) => q.trim().slice(0, LOCAL_ARTIFACT_CAPS.questionChars));
|
|
184
|
+
}
|
|
185
|
+
if (outcome === "failed")
|
|
186
|
+
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
187
|
+
return { result: out };
|
|
188
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a tailoring run launches its CLI, what it asks it for, and how the answer is checked (#956).
|
|
3
|
+
*
|
|
4
|
+
* The CLI gets NO tools. Claude Code runs with `--tools ""` and an empty, strict MCP config; Codex
|
|
5
|
+
* runs `--sandbox read-only` with user config and web search off, in an empty scratch folder. The
|
|
6
|
+
* owner's sources are read by the RUNTIME and handed over in the prompt, and the CLI answers with
|
|
7
|
+
* one JSON object; the runtime — not the CLI — writes the files. So the CLI cannot write anywhere,
|
|
8
|
+
* and every write is confined to the run's own folder by code the runner controls.
|
|
9
|
+
*
|
|
10
|
+
* Subscription-only: both modes remove the per-token provider keys from the inherited environment
|
|
11
|
+
* (an empty overlay value means remove — `mergeEnv`), and `machine` also removes the Claude
|
|
12
|
+
* subscription token so the CLI's own stored login is what runs. The env is then READ BACK
|
|
13
|
+
* (`resolveEngineAuth`) and a run whose engine would still see an API key is refused, not started.
|
|
14
|
+
*
|
|
15
|
+
* Truthfulness is checked, not requested: the CLI must cite, for every claim, a verbatim quote
|
|
16
|
+
* from a named source, and every quote is looked up in that source. Years, emails and phone
|
|
17
|
+
* numbers in the generated text must also appear in the sources. Anything that does not check
|
|
18
|
+
* out pauses the run with `needs_human` — no file is written from an unverified draft.
|
|
19
|
+
*/
|
|
20
|
+
import { mergeEnv } from "../coding/engine-env.js";
|
|
21
|
+
import { resolveEngineAuth } from "../coding/engine-auth.js";
|
|
22
|
+
import { finalText, missingLogin } from "../local-browser/engine.js";
|
|
23
|
+
export { finalText, missingLogin };
|
|
24
|
+
const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
25
|
+
const SUBSCRIPTION_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
26
|
+
/** The engine's environment: the machine's, never with a provider API key. */
|
|
27
|
+
export function artifactEngineEnv(authMode, base = process.env) {
|
|
28
|
+
const overlay = {};
|
|
29
|
+
for (const k of PROVIDER_KEYS)
|
|
30
|
+
overlay[k] = "";
|
|
31
|
+
if (authMode === "machine")
|
|
32
|
+
overlay[SUBSCRIPTION_TOKEN] = "";
|
|
33
|
+
return mergeEnv(base, overlay);
|
|
34
|
+
}
|
|
35
|
+
export function observedArtifactAuth(engine, env) {
|
|
36
|
+
return resolveEngineAuth(engine, env);
|
|
37
|
+
}
|
|
38
|
+
export function buildArtifactEngineSpec(input) {
|
|
39
|
+
const env = artifactEngineEnv(input.authMode, input.baseEnv);
|
|
40
|
+
if (input.engine === "claude") {
|
|
41
|
+
return {
|
|
42
|
+
command: "claude",
|
|
43
|
+
args: ["-p", input.prompt, "--output-format", "stream-json", "--verbose", "--tools", "", "--strict-mcp-config", "--permission-mode", "dontAsk"],
|
|
44
|
+
env,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
command: "codex",
|
|
49
|
+
args: ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--ignore-user-config", "-c", 'web_search="disabled"', "-c", "tools.web_search=false", input.prompt],
|
|
50
|
+
env,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The brief. The rules are stated, but the checks in `checkDraft` are what enforce them. */
|
|
54
|
+
export function tailorPrompt(lead, sources) {
|
|
55
|
+
const l = lead.lead;
|
|
56
|
+
const job = [
|
|
57
|
+
`Title: ${l.title}`,
|
|
58
|
+
l.company ? `Company: ${l.company}` : "",
|
|
59
|
+
l.location ? `Location: ${l.location}` : "",
|
|
60
|
+
lead.leadUrl ? `URL: ${lead.leadUrl}` : "",
|
|
61
|
+
l.match_rationale ? `Why it matched: ${l.match_rationale}` : "",
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join("\n");
|
|
65
|
+
const blocks = sources.map((s) => `<source role="${s.role}">\n${s.text}\n</source>`).join("\n\n");
|
|
66
|
+
return [
|
|
67
|
+
"You tailor a job application for the owner of this machine: a résumé variant and a cover letter for ONE job.",
|
|
68
|
+
"You have no tools. Everything you may use is below. Answer with ONE JSON object and nothing else.",
|
|
69
|
+
"",
|
|
70
|
+
"<job>",
|
|
71
|
+
job,
|
|
72
|
+
"</job>",
|
|
73
|
+
"",
|
|
74
|
+
blocks,
|
|
75
|
+
"",
|
|
76
|
+
"Rules — these are checked mechanically, and a draft that breaks one is discarded:",
|
|
77
|
+
"- Use ONLY facts stated in the sources. Never invent or embellish an employer, title, qualification, date, skill, metric, work authorisation, salary expectation or answer.",
|
|
78
|
+
'- For EVERY factual claim about the owner in either document, add an entry to "claims" with the claim, the source role it comes from, and a short VERBATIM quote copied exactly from that source.',
|
|
79
|
+
"- Every year, email address and phone number you write must appear in the sources.",
|
|
80
|
+
"- Reword and reorder to fit the job; do not add what the sources do not say. Leave out what does not fit.",
|
|
81
|
+
'- If something the application needs is missing or uncertain, do not guess: answer {"status":"needs_human","questions":["…"]} instead.',
|
|
82
|
+
"",
|
|
83
|
+
"Answer shape when you can do it:",
|
|
84
|
+
'{"status":"ready","resume_markdown":"…","cover_letter_markdown":"…","claims":[{"text":"…","source":"resume","quote":"…"}]}',
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
/** The CLI's answer: the first JSON object in its final text, fences and prose around it ignored. */
|
|
88
|
+
export function parseDraft(text) {
|
|
89
|
+
const start = text.indexOf("{");
|
|
90
|
+
const end = text.lastIndexOf("}");
|
|
91
|
+
if (start < 0 || end <= start)
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
const v = JSON.parse(text.slice(start, end + 1));
|
|
95
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Case, whitespace, quote-style and dash-style insensitive — so a faithful quote is never failed on typography. */
|
|
102
|
+
export function normalize(s) {
|
|
103
|
+
return s
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.replace(/[‘’]/g, "'")
|
|
106
|
+
.replace(/[“”]/g, '"')
|
|
107
|
+
.replace(/[–—]/g, "-")
|
|
108
|
+
.replace(/\s+/g, " ")
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
const YEAR = /\b(?:19|20)\d{2}\b/g;
|
|
112
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
113
|
+
/** Nine digits or more, so a year range ("2019 - 2021") is not read as a phone number. */
|
|
114
|
+
const PHONE = /\+?\d[\d\s().-]{7,}\d/g;
|
|
115
|
+
const digits = (s) => s.replace(/\D/g, "");
|
|
116
|
+
/**
|
|
117
|
+
* Check the CLI's draft against the sources. Questions name WHAT to confirm, bounded, and are
|
|
118
|
+
* returned only to the owner's own record — never to a trace.
|
|
119
|
+
*/
|
|
120
|
+
export function checkDraft(draft, sources) {
|
|
121
|
+
if (!draft)
|
|
122
|
+
return { ok: false, reason: "uncertain_claim", questions: ["The CLI did not return a readable draft. Run it again, or check the source files."], claims: 0, unmatched: 0 };
|
|
123
|
+
if (draft.status === "needs_human") {
|
|
124
|
+
const qs = (Array.isArray(draft.questions) ? draft.questions : []).filter((q) => typeof q === "string" && q.trim() !== "").map((q) => q.trim());
|
|
125
|
+
return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0 };
|
|
126
|
+
}
|
|
127
|
+
const resume = typeof draft.resume_markdown === "string" ? draft.resume_markdown.trim() : "";
|
|
128
|
+
const coverLetter = typeof draft.cover_letter_markdown === "string" ? draft.cover_letter_markdown.trim() : "";
|
|
129
|
+
const claims = Array.isArray(draft.claims) ? draft.claims : [];
|
|
130
|
+
if (draft.status !== "ready" || !resume || !coverLetter || !claims.length) {
|
|
131
|
+
return { ok: false, reason: "uncertain_claim", questions: ["The draft was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0 };
|
|
132
|
+
}
|
|
133
|
+
const byRole = new Map(sources.map((s) => [s.role, normalize(s.text)]));
|
|
134
|
+
const all = normalize(sources.map((s) => s.text).join("\n"));
|
|
135
|
+
const questions = [];
|
|
136
|
+
let unmatched = 0;
|
|
137
|
+
const flag = (question) => {
|
|
138
|
+
unmatched++;
|
|
139
|
+
if (questions.length < 20)
|
|
140
|
+
questions.push(question);
|
|
141
|
+
};
|
|
142
|
+
for (const raw of claims) {
|
|
143
|
+
const c = (raw && typeof raw === "object" ? raw : {});
|
|
144
|
+
const quote = typeof c.quote === "string" ? normalize(c.quote) : "";
|
|
145
|
+
const src = typeof c.source === "string" ? byRole.get(c.source) : undefined;
|
|
146
|
+
if (quote.length >= 3 && src?.includes(quote))
|
|
147
|
+
continue;
|
|
148
|
+
const claim = typeof c.text === "string" ? c.text.trim() : "";
|
|
149
|
+
flag(`Confirm or correct: “${claim.slice(0, 200) || "(an unnamed claim)"}” — no matching text was found in your ${typeof c.source === "string" ? c.source : "sources"}.`);
|
|
150
|
+
}
|
|
151
|
+
const text = `${resume}\n${coverLetter}`;
|
|
152
|
+
const sourceDigits = digits(sources.map((s) => s.text).join("\n"));
|
|
153
|
+
for (const y of new Set(text.match(YEAR) ?? []))
|
|
154
|
+
if (!all.includes(y))
|
|
155
|
+
flag(`Confirm the year ${y} — it does not appear in your sources.`);
|
|
156
|
+
for (const e of new Set(text.match(EMAIL) ?? []))
|
|
157
|
+
if (!all.includes(e.toLowerCase()))
|
|
158
|
+
flag("Confirm the email address in the draft — it does not appear in your sources.");
|
|
159
|
+
// Nine digits or more, so a year range ("2019 - 2021") is not read as a phone number.
|
|
160
|
+
for (const p of new Set(text.match(PHONE) ?? []))
|
|
161
|
+
if (digits(p).length >= 9 && !sourceDigits.includes(digits(p)))
|
|
162
|
+
flag("Confirm the phone number in the draft — it does not appear in your sources.");
|
|
163
|
+
if (unmatched)
|
|
164
|
+
return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched };
|
|
165
|
+
return { ok: true, resume, coverLetter, claims: claims.length };
|
|
166
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local artifact generation on the runner (#956) — the Application Tailor's hands.
|
|
3
|
+
*
|
|
4
|
+
* One run = one Codex or Claude Code process, signed in on this machine, that turns ONE approved
|
|
5
|
+
* job lead plus the owner's own source files into a tailored résumé and cover letter. No browser,
|
|
6
|
+
* no repository, no provider API key. The runtime, not the CLI, does every read and every write:
|
|
7
|
+
*
|
|
8
|
+
* - reads: only the configured source files, each resolved on the real disk inside the workspace
|
|
9
|
+
* (`..`, absolute paths and symlinks out are refused), and never under `applications/`, where
|
|
10
|
+
* generated material — this instance's or another's — lives;
|
|
11
|
+
* - writes: only new files in a fresh `<workspace>/applications/<leadId>/<runId>/`, created
|
|
12
|
+
* exclusively (`wx`), so a master source is never rewritten and two runs never share a folder;
|
|
13
|
+
* - the trace carries handles (role, owner-visible path, sha256, byte count), never content.
|
|
14
|
+
*
|
|
15
|
+
* A missing source, an unusable workspace, a CLI that is not signed in, an API key in the env, a
|
|
16
|
+
* malformed lead or a draft whose claims do not check out ends the run `needs_human` — a pause
|
|
17
|
+
* the owner resolves — never a plausible guess written to disk.
|
|
18
|
+
*
|
|
19
|
+
* Runs live in memory, like local browser runs: a restart loses them and `status` answers 404,
|
|
20
|
+
* which PAGS reads as "the runner lost this run". Generated folders carry a manifest with their
|
|
21
|
+
* retention date; a later run on the same workspace removes those past it.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join, resolve, sep } from "node:path";
|
|
28
|
+
import { RunnerInputError } from "../errors.js";
|
|
29
|
+
import { redactText, secretEnvValues } from "../local-browser/contract.js";
|
|
30
|
+
import { resolveWorkspacePath } from "../local-browser/runtime.js";
|
|
31
|
+
import { LOCAL_ARTIFACT_AUTH_MODES, LOCAL_ARTIFACT_CAPS, LOCAL_ARTIFACT_ENGINES, LOCAL_ARTIFACT_SOURCE_ROLES, LOCAL_ARTIFACT_TASK_TYPE, PATH_SEGMENT, REQUIRED_SOURCE_ROLES, isHomeRelative, isWorkspaceRelative, parseLocalArtifactLead, } from "./contract.js";
|
|
32
|
+
import { buildArtifactEngineSpec, checkDraft, finalText, missingLogin, observedArtifactAuth, parseDraft, tailorPrompt } from "./engine.js";
|
|
33
|
+
/** The manifest in every generated folder — what retention reads, and proof the folder is ours. */
|
|
34
|
+
export const ARTIFACT_MANIFEST = ".pags-artifact.json";
|
|
35
|
+
const MAX_EVENTS = 200;
|
|
36
|
+
const OUTPUT_LINES = 400;
|
|
37
|
+
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
|
|
38
|
+
const DAY_MS = 86_400_000;
|
|
39
|
+
const intIn = (v, min, max) => (typeof v === "number" && Number.isInteger(v) && v >= min && v <= max ? v : null);
|
|
40
|
+
/** The envelope, validated at the runner's boundary. A bad LEAD is not thrown: it pauses the run. */
|
|
41
|
+
export function parseArtifactEnvelope(raw) {
|
|
42
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
43
|
+
const bad = (what) => new RunnerInputError(`Invalid local artifact task: ${what}`);
|
|
44
|
+
if (o.type !== LOCAL_ARTIFACT_TASK_TYPE)
|
|
45
|
+
throw bad(`type must be ${LOCAL_ARTIFACT_TASK_TYPE}`);
|
|
46
|
+
if (typeof o.runId !== "string" || !PATH_SEGMENT.test(o.runId))
|
|
47
|
+
throw bad("runId is required");
|
|
48
|
+
for (const k of ["requestId", "instanceId"])
|
|
49
|
+
if (typeof o[k] !== "string" || !SAFE_ID.test(o[k]))
|
|
50
|
+
throw bad(`${k} is required`);
|
|
51
|
+
if (!LOCAL_ARTIFACT_ENGINES.includes(o.engine))
|
|
52
|
+
throw bad("engine must be claude or codex");
|
|
53
|
+
if (!LOCAL_ARTIFACT_AUTH_MODES.includes(o.authMode))
|
|
54
|
+
throw bad("authMode must be machine or subscription — a provider API key is never used");
|
|
55
|
+
if (typeof o.workspace !== "string" || !isHomeRelative(o.workspace))
|
|
56
|
+
throw bad('workspace must be a folder written "~/…"');
|
|
57
|
+
if (!Array.isArray(o.sources) || o.sources.length > LOCAL_ARTIFACT_CAPS.sources)
|
|
58
|
+
throw bad("sources must be a list");
|
|
59
|
+
const sources = [];
|
|
60
|
+
for (const s of o.sources) {
|
|
61
|
+
const r = (s && typeof s === "object" ? s : {});
|
|
62
|
+
if (!LOCAL_ARTIFACT_SOURCE_ROLES.includes(r.role) || typeof r.path !== "string" || !isWorkspaceRelative(r.path))
|
|
63
|
+
throw bad("each source needs a role and a path inside the workspace");
|
|
64
|
+
if (r.path.split("/")[0] === "applications")
|
|
65
|
+
throw bad("a source may not be inside applications/, where generated material lives");
|
|
66
|
+
if (sources.some((x) => x.role === r.role))
|
|
67
|
+
throw bad(`source ${String(r.role)} is listed twice`);
|
|
68
|
+
sources.push({ role: r.role, path: r.path });
|
|
69
|
+
}
|
|
70
|
+
const p = (o.policy && typeof o.policy === "object" ? o.policy : {});
|
|
71
|
+
const retainDays = intIn(p.retainDays, 0, 3650);
|
|
72
|
+
const maxMinutes = intIn(p.maxMinutes, 1, 60);
|
|
73
|
+
const maxConcurrent = intIn(p.maxConcurrent, 1, 3);
|
|
74
|
+
if (retainDays === null || maxMinutes === null || maxConcurrent === null)
|
|
75
|
+
throw bad("policy limits are out of range");
|
|
76
|
+
const lead = parseLocalArtifactLead(o.lead);
|
|
77
|
+
return {
|
|
78
|
+
type: LOCAL_ARTIFACT_TASK_TYPE,
|
|
79
|
+
runId: o.runId,
|
|
80
|
+
requestId: o.requestId,
|
|
81
|
+
instanceId: o.instanceId,
|
|
82
|
+
engine: o.engine,
|
|
83
|
+
authMode: o.authMode,
|
|
84
|
+
workspace: o.workspace,
|
|
85
|
+
sources,
|
|
86
|
+
policy: { retainDays, maxMinutes, maxConcurrent },
|
|
87
|
+
...("lead" in lead ? { lead: lead.lead } : { lead: null, leadError: lead.error }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
91
|
+
/** A source file resolved on the real disk, strictly inside the workspace. */
|
|
92
|
+
function resolveSource(workspace, rel) {
|
|
93
|
+
const real = realpathSync(resolve(workspace, rel));
|
|
94
|
+
if (!real.startsWith(workspace + sep))
|
|
95
|
+
throw new Error("outside the workspace");
|
|
96
|
+
if (real.slice(workspace.length + 1).split(sep)[0] === "applications")
|
|
97
|
+
throw new Error("inside applications/");
|
|
98
|
+
if (!statSync(real).isFile())
|
|
99
|
+
throw new Error("not a file");
|
|
100
|
+
return real;
|
|
101
|
+
}
|
|
102
|
+
class Pause extends Error {
|
|
103
|
+
reason;
|
|
104
|
+
questions;
|
|
105
|
+
constructor(reason, questions) {
|
|
106
|
+
super(reason);
|
|
107
|
+
this.reason = reason;
|
|
108
|
+
this.questions = questions;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class LocalArtifactRuntime {
|
|
112
|
+
deps;
|
|
113
|
+
runs = new Map();
|
|
114
|
+
root;
|
|
115
|
+
now;
|
|
116
|
+
spawn;
|
|
117
|
+
retentionMs;
|
|
118
|
+
constructor(deps) {
|
|
119
|
+
this.deps = deps;
|
|
120
|
+
this.root = join(deps.dataDir, "local-artifact");
|
|
121
|
+
this.now = deps.now ?? Date.now;
|
|
122
|
+
this.spawn = deps.spawn ?? nodeSpawn;
|
|
123
|
+
this.retentionMs = deps.retentionMs ?? DAY_MS;
|
|
124
|
+
}
|
|
125
|
+
/** Start a run. A requestId already held for the instance returns that run instead. */
|
|
126
|
+
start(raw) {
|
|
127
|
+
const envelope = parseArtifactEnvelope(raw);
|
|
128
|
+
this.sweep();
|
|
129
|
+
const same = [...this.runs.values()].find((r) => r.envelope.instanceId === envelope.instanceId && r.envelope.requestId === envelope.requestId);
|
|
130
|
+
if (same)
|
|
131
|
+
return { runId: same.envelope.runId, taskId: same.envelope.runId, status: same.state, existing: true };
|
|
132
|
+
if (this.runs.has(envelope.runId))
|
|
133
|
+
throw new RunnerInputError(`Run ${envelope.runId} already exists with another requestId`, 409);
|
|
134
|
+
const active = [...this.runs.values()].filter((r) => r.envelope.instanceId === envelope.instanceId && r.state !== "ended").length;
|
|
135
|
+
if (active >= envelope.policy.maxConcurrent)
|
|
136
|
+
throw new RunnerInputError(`This machine is already tailoring ${active} application(s) for this agent (limit ${envelope.policy.maxConcurrent}).`, 409);
|
|
137
|
+
const scratch = join(this.root, envelope.instanceId.replace(/[^A-Za-z0-9_-]/g, "_"), envelope.runId);
|
|
138
|
+
mkdirSync(scratch, { recursive: true });
|
|
139
|
+
const run = { envelope, scratch, state: "running", events: [], seq: 0, output: [], secrets: secretEnvValues(process.env), engineAuth: "unknown", cancelled: false, timedOut: false };
|
|
140
|
+
this.runs.set(envelope.runId, run);
|
|
141
|
+
void this.launch(run).catch((err) => {
|
|
142
|
+
if (err instanceof Pause)
|
|
143
|
+
this.pauseEnd(run, err.reason, err.questions);
|
|
144
|
+
else
|
|
145
|
+
this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
146
|
+
});
|
|
147
|
+
return { runId: envelope.runId, taskId: envelope.runId, status: run.state, existing: false };
|
|
148
|
+
}
|
|
149
|
+
async launch(run) {
|
|
150
|
+
const e = run.envelope;
|
|
151
|
+
if (!e.lead)
|
|
152
|
+
throw new Pause("malformed_lead", [`The approved lead could not be read (${e.leadError ?? "malformed"}). Re-approve it from the Scout.`]);
|
|
153
|
+
const lead = e.lead;
|
|
154
|
+
let workspace;
|
|
155
|
+
try {
|
|
156
|
+
workspace = resolveWorkspacePath(e.workspace, this.deps.homeDir ?? homedir());
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw new Pause("workspace_unavailable", [`The workspace ${e.workspace} cannot be used: ${err instanceof Error ? err.message : String(err)}`]);
|
|
160
|
+
}
|
|
161
|
+
this.sweepArtifacts(run, workspace);
|
|
162
|
+
// Sources: read by the runtime, hashed, handed to the CLI in its prompt. Content never leaves this function except into the prompt.
|
|
163
|
+
const texts = [];
|
|
164
|
+
const hashes = [];
|
|
165
|
+
const missing = [];
|
|
166
|
+
for (const s of e.sources) {
|
|
167
|
+
const shown = `${e.workspace}/${s.path}`;
|
|
168
|
+
try {
|
|
169
|
+
const file = resolveSource(workspace, s.path);
|
|
170
|
+
const buf = readFileSync(file);
|
|
171
|
+
if (buf.length > LOCAL_ARTIFACT_CAPS.sourceBytes)
|
|
172
|
+
throw new Error(`larger than ${LOCAL_ARTIFACT_CAPS.sourceBytes / 1024} KB`);
|
|
173
|
+
if (!buf.length)
|
|
174
|
+
throw new Error("empty");
|
|
175
|
+
texts.push({ role: s.role, text: buf.toString("utf8") });
|
|
176
|
+
const hash = { role: s.role, path: shown, sha256: sha256(buf), bytes: buf.length };
|
|
177
|
+
hashes.push(hash);
|
|
178
|
+
this.emit(run, { type: "source.read", detail: hash });
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
this.emit(run, { type: "source.missing", detail: { role: s.role, path: shown } });
|
|
182
|
+
missing.push(`Your ${s.role} at ${shown} could not be read (${err instanceof Error && !/ENOENT/.test(err.message) ? err.message : "not found"}). Add it, or choose another file in the Application Tailor settings.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const role of REQUIRED_SOURCE_ROLES) {
|
|
186
|
+
if (!e.sources.some((s) => s.role === role))
|
|
187
|
+
missing.push(`No ${role} source is configured. Choose one in the Application Tailor settings.`);
|
|
188
|
+
}
|
|
189
|
+
if (missing.length)
|
|
190
|
+
throw new Pause("missing_source", missing);
|
|
191
|
+
const outRel = `applications/${lead.leadId}/${e.runId}`;
|
|
192
|
+
const outDir = join(workspace, "applications", lead.leadId, e.runId);
|
|
193
|
+
try {
|
|
194
|
+
mkdirSync(join(workspace, "applications", lead.leadId), { recursive: true });
|
|
195
|
+
mkdirSync(outDir); // exclusive: a run never writes into a folder it did not create
|
|
196
|
+
if (!realpathSync(outDir).startsWith(workspace + sep))
|
|
197
|
+
throw new Error("the output folder resolves outside the workspace");
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
throw new Pause("workspace_unavailable", [`Cannot create ${e.workspace}/${outRel}: ${err instanceof Error ? err.message : String(err)}`]);
|
|
201
|
+
}
|
|
202
|
+
const spec = buildArtifactEngineSpec({ engine: e.engine, authMode: e.authMode, prompt: tailorPrompt(lead, texts) });
|
|
203
|
+
run.secrets = secretEnvValues({ ...process.env, ...spec.env });
|
|
204
|
+
run.engineAuth = observedArtifactAuth(e.engine, spec.env);
|
|
205
|
+
this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
|
|
206
|
+
if (run.engineAuth === "api-key") {
|
|
207
|
+
rmdirSync(outDir);
|
|
208
|
+
throw new Pause("api_key_refused", ["A provider API key would reach the CLI. The Application Tailor runs on your machine's sign-in only — remove the key from the runner's environment."]);
|
|
209
|
+
}
|
|
210
|
+
const child = this.spawn(spec.command, spec.args, { cwd: run.scratch, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
211
|
+
run.child = child;
|
|
212
|
+
this.emit(run, { type: "engine.started", detail: { engine: e.engine } });
|
|
213
|
+
let buf = "";
|
|
214
|
+
const keep = (chunk) => {
|
|
215
|
+
buf += chunk.toString();
|
|
216
|
+
const lines = buf.split("\n");
|
|
217
|
+
buf = lines.pop() ?? "";
|
|
218
|
+
for (const line of lines)
|
|
219
|
+
if (line.trim())
|
|
220
|
+
run.output.push(line.slice(0, 200_000));
|
|
221
|
+
if (run.output.length > OUTPUT_LINES)
|
|
222
|
+
run.output.splice(0, run.output.length - OUTPUT_LINES);
|
|
223
|
+
};
|
|
224
|
+
child.stdout?.on("data", keep);
|
|
225
|
+
child.stderr?.on("data", keep);
|
|
226
|
+
child.on("error", (err) => {
|
|
227
|
+
this.removeIfEmpty(outDir);
|
|
228
|
+
this.end(run, { outcome: "failed", error: err.code === "ENOENT" ? `The ${e.engine === "claude" ? "Claude Code" : "Codex"} CLI is not installed on this machine (\`${spec.command}\` was not found).` : err.message });
|
|
229
|
+
});
|
|
230
|
+
child.on("close", (code) => {
|
|
231
|
+
if (buf.trim())
|
|
232
|
+
run.output.push(buf);
|
|
233
|
+
this.emit(run, { type: "engine.ended", detail: { exitCode: code ?? -1 } });
|
|
234
|
+
try {
|
|
235
|
+
this.finish(run, code ?? -1, { workspace, outDir, outRel, lead, hashes, texts });
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
this.removeIfEmpty(outDir);
|
|
239
|
+
this.end(run, { outcome: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
run.timer = setTimeout(() => {
|
|
243
|
+
run.timedOut = true;
|
|
244
|
+
this.kill(run);
|
|
245
|
+
}, e.policy.maxMinutes * 60_000);
|
|
246
|
+
run.timer.unref?.();
|
|
247
|
+
}
|
|
248
|
+
/** The CLI exited: check its draft against the sources, and only then write the artifacts. */
|
|
249
|
+
finish(run, code, ctx) {
|
|
250
|
+
if (run.state === "ended")
|
|
251
|
+
return;
|
|
252
|
+
const e = run.envelope;
|
|
253
|
+
const output = run.output.join("\n");
|
|
254
|
+
if (run.cancelled || run.timedOut) {
|
|
255
|
+
this.removeIfEmpty(ctx.outDir);
|
|
256
|
+
this.end(run, { outcome: "failed", error: run.cancelled ? "Cancelled by the owner" : `Stopped at the ${e.policy.maxMinutes}-minute limit.` });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const text = finalText(e.engine, run.output);
|
|
260
|
+
if (!text && missingLogin(e.engine, output)) {
|
|
261
|
+
this.removeIfEmpty(ctx.outDir);
|
|
262
|
+
run.engineAuth = "missing_login";
|
|
263
|
+
this.pauseEnd(run, "engine_not_signed_in", [e.engine === "claude" ? "Sign in to Claude Code on this machine (run `claude`, then /login), then retry." : "Sign in to Codex on this machine (`codex login`), then retry."]);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (code !== 0 && !text) {
|
|
267
|
+
this.removeIfEmpty(ctx.outDir);
|
|
268
|
+
this.end(run, { outcome: "failed", error: `The ${e.engine} CLI exited with code ${code}: ${run.output.slice(-5).join("\n").slice(-800)}` });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const check = checkDraft(parseDraft(text), ctx.texts);
|
|
272
|
+
this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched } });
|
|
273
|
+
if (!check.ok) {
|
|
274
|
+
this.removeIfEmpty(ctx.outDir);
|
|
275
|
+
this.pauseEnd(run, check.reason, check.questions);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const at = new Date(this.now()).toISOString();
|
|
279
|
+
const artifacts = [];
|
|
280
|
+
for (const [kind, name, body] of [
|
|
281
|
+
["resume", "resume.md", check.resume],
|
|
282
|
+
["cover_letter", "cover-letter.md", check.coverLetter],
|
|
283
|
+
]) {
|
|
284
|
+
const content = `${body}\n`;
|
|
285
|
+
writeFileSync(join(ctx.outDir, name), content, { flag: "wx", mode: 0o600 });
|
|
286
|
+
const file = { kind, path: `${e.workspace}/${ctx.outRel}/${name}`, sha256: sha256(content), bytes: Buffer.byteLength(content) };
|
|
287
|
+
artifacts.push(file);
|
|
288
|
+
this.emit(run, { type: "artifact.written", detail: { ...file } });
|
|
289
|
+
}
|
|
290
|
+
const profileVersion = sha256(ctx.hashes.map((h) => `${h.role}:${h.sha256}`).sort().join("\n")).slice(0, 16);
|
|
291
|
+
const retainUntil = e.policy.retainDays > 0 ? this.now() + e.policy.retainDays * DAY_MS : null;
|
|
292
|
+
writeFileSync(join(ctx.outDir, ARTIFACT_MANIFEST), JSON.stringify({ runId: e.runId, instanceId: e.instanceId, leadId: ctx.lead.leadId, eventId: ctx.lead.eventId, generatedAt: at, retainUntil, profileVersion, artifacts, sources: ctx.hashes }, null, 2), { flag: "wx", mode: 0o600 });
|
|
293
|
+
this.end(run, { outcome: "completed", artifacts, sourceHashes: ctx.hashes, profileVersion, generatedAt: at });
|
|
294
|
+
}
|
|
295
|
+
/** Retention: remove generated folders whose manifest says they are past their date. Only ours — a folder without a manifest is the owner's. */
|
|
296
|
+
sweepArtifacts(run, workspace) {
|
|
297
|
+
const apps = join(workspace, "applications");
|
|
298
|
+
if (!existsSync(apps))
|
|
299
|
+
return;
|
|
300
|
+
let removed = 0;
|
|
301
|
+
for (const lead of readdirSync(apps)) {
|
|
302
|
+
const leadDir = join(apps, lead);
|
|
303
|
+
if (!statSync(leadDir).isDirectory())
|
|
304
|
+
continue;
|
|
305
|
+
for (const runId of readdirSync(leadDir)) {
|
|
306
|
+
const manifest = join(leadDir, runId, ARTIFACT_MANIFEST);
|
|
307
|
+
if (!existsSync(manifest))
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const m = JSON.parse(readFileSync(manifest, "utf8"));
|
|
311
|
+
if (typeof m.retainUntil === "number" && m.retainUntil < this.now()) {
|
|
312
|
+
rmSync(join(leadDir, runId), { recursive: true, force: true });
|
|
313
|
+
removed++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// An unreadable manifest is left alone: deleting on a guess is the one thing retention must not do.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.removeIfEmpty(leadDir);
|
|
321
|
+
}
|
|
322
|
+
if (removed)
|
|
323
|
+
this.emit(run, { type: "retention.swept", detail: { removed } });
|
|
324
|
+
}
|
|
325
|
+
removeIfEmpty(dir) {
|
|
326
|
+
try {
|
|
327
|
+
if (existsSync(dir) && readdirSync(dir).length === 0)
|
|
328
|
+
rmdirSync(dir);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// best-effort
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
emit(run, ev) {
|
|
335
|
+
if (run.events.length >= MAX_EVENTS)
|
|
336
|
+
return;
|
|
337
|
+
run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
338
|
+
}
|
|
339
|
+
pauseEnd(run, reason, questions) {
|
|
340
|
+
this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length } });
|
|
341
|
+
this.end(run, { outcome: "needs_human", blockReason: reason, questions });
|
|
342
|
+
}
|
|
343
|
+
end(run, r) {
|
|
344
|
+
if (run.state === "ended")
|
|
345
|
+
return;
|
|
346
|
+
if (run.timer)
|
|
347
|
+
clearTimeout(run.timer);
|
|
348
|
+
run.state = "ended";
|
|
349
|
+
run.endedAt = this.now();
|
|
350
|
+
run.result = {
|
|
351
|
+
runId: run.envelope.runId,
|
|
352
|
+
outcome: r.outcome,
|
|
353
|
+
artifacts: r.artifacts ?? [],
|
|
354
|
+
sourceHashes: r.sourceHashes ?? [],
|
|
355
|
+
profileVersion: r.profileVersion ?? null,
|
|
356
|
+
engineAuth: run.engineAuth,
|
|
357
|
+
traceId: run.envelope.runId,
|
|
358
|
+
...(r.generatedAt ? { generatedAt: r.generatedAt } : {}),
|
|
359
|
+
...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
|
|
360
|
+
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
361
|
+
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
362
|
+
};
|
|
363
|
+
if (run.child && run.child.exitCode === null)
|
|
364
|
+
this.kill(run);
|
|
365
|
+
}
|
|
366
|
+
kill(run) {
|
|
367
|
+
const child = run.child;
|
|
368
|
+
if (!child || child.exitCode !== null)
|
|
369
|
+
return;
|
|
370
|
+
child.kill("SIGTERM");
|
|
371
|
+
setTimeout(() => {
|
|
372
|
+
if (child.exitCode === null)
|
|
373
|
+
child.kill("SIGKILL");
|
|
374
|
+
}, 5_000).unref?.();
|
|
375
|
+
}
|
|
376
|
+
get(runId) {
|
|
377
|
+
const run = this.runs.get(runId);
|
|
378
|
+
if (!run)
|
|
379
|
+
throw new RunnerInputError(`No local artifact run ${runId} on this runner (it may have restarted)`, 404);
|
|
380
|
+
return run;
|
|
381
|
+
}
|
|
382
|
+
status(raw) {
|
|
383
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
384
|
+
const run = this.get(String(o.runId ?? ""));
|
|
385
|
+
const after = typeof o.afterSeq === "number" && o.afterSeq > 0 ? o.afterSeq : 0;
|
|
386
|
+
return { runId: run.envelope.runId, state: run.state, events: run.events.filter((e) => e.seq > after), lastSeq: run.seq, ...(run.result ? { result: run.result } : {}) };
|
|
387
|
+
}
|
|
388
|
+
cancel(raw) {
|
|
389
|
+
const runId = String(raw?.runId ?? "");
|
|
390
|
+
const run = this.get(runId);
|
|
391
|
+
if (run.state !== "ended") {
|
|
392
|
+
run.cancelled = true;
|
|
393
|
+
if (run.child && run.child.exitCode === null)
|
|
394
|
+
this.kill(run);
|
|
395
|
+
else
|
|
396
|
+
this.end(run, { outcome: "failed", error: "Cancelled by the owner" });
|
|
397
|
+
}
|
|
398
|
+
return { runId, state: run.state };
|
|
399
|
+
}
|
|
400
|
+
/** Drop ended runs past retention with their scratch folders. Generated artifacts are swept by their own manifest, not here. */
|
|
401
|
+
sweep() {
|
|
402
|
+
const cutoff = this.now() - this.retentionMs;
|
|
403
|
+
for (const [id, run] of this.runs) {
|
|
404
|
+
if (run.state === "ended" && (run.endedAt ?? 0) < cutoff) {
|
|
405
|
+
rmSync(run.scratch, { recursive: true, force: true });
|
|
406
|
+
this.runs.delete(id);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
closeAll() {
|
|
411
|
+
for (const run of this.runs.values()) {
|
|
412
|
+
if (run.state !== "ended") {
|
|
413
|
+
run.cancelled = true;
|
|
414
|
+
this.end(run, { outcome: "failed", error: "The runner shut down during the run" });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -14,6 +14,8 @@ import { RunnerStore } from "./store.js";
|
|
|
14
14
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
15
15
|
import { LocalBrowserRuntime } from "./local-browser/runtime.js";
|
|
16
16
|
import { LOCAL_BROWSER_TASK_TYPE } from "./local-browser/contract.js";
|
|
17
|
+
import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
|
|
18
|
+
import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
|
|
17
19
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
18
20
|
/** True for a plain object. */
|
|
19
21
|
function isRecord(value) {
|
|
@@ -77,6 +79,8 @@ export class LocalRunner {
|
|
|
77
79
|
coding;
|
|
78
80
|
/** Local CLI browser research (#944): a signed-in Codex / Claude Code CLI researching through the policy bridge. */
|
|
79
81
|
localBrowser;
|
|
82
|
+
/** Local artifact generation (#956): the Application Tailor's résumé + cover letter, from the owner's own files. */
|
|
83
|
+
localArtifact;
|
|
80
84
|
/** This runner's own local URL, once the server listens — the bridge forwarder calls back to it. */
|
|
81
85
|
selfUrl = null;
|
|
82
86
|
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
@@ -99,6 +103,7 @@ export class LocalRunner {
|
|
|
99
103
|
return { tools: mcp, stop: () => mcp.stop() };
|
|
100
104
|
},
|
|
101
105
|
});
|
|
106
|
+
this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
|
|
102
107
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
103
108
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
104
109
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -113,7 +118,7 @@ export class LocalRunner {
|
|
|
113
118
|
runtimePlane: "pags",
|
|
114
119
|
runnerRole: "tool-executor",
|
|
115
120
|
capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
|
|
116
|
-
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, ...CodingRuntime.taskTypes()],
|
|
121
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", LOCAL_BROWSER_TASK_TYPE, LOCAL_ARTIFACT_TASK_TYPE, ...CodingRuntime.taskTypes()],
|
|
117
122
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
118
123
|
};
|
|
119
124
|
}
|
|
@@ -250,6 +255,7 @@ export class LocalRunner {
|
|
|
250
255
|
*/
|
|
251
256
|
async close() {
|
|
252
257
|
this.localBrowser.closeAll();
|
|
258
|
+
this.localArtifact.closeAll();
|
|
253
259
|
try {
|
|
254
260
|
this.coding.closeAll();
|
|
255
261
|
}
|
|
@@ -177,6 +177,16 @@ async function route(runner, req, res) {
|
|
|
177
177
|
if (req.method === "POST" && path === "/local-browser/cancel") {
|
|
178
178
|
return json(res, 200, runner.localBrowser.cancel(await readJson(req)));
|
|
179
179
|
}
|
|
180
|
+
// Local artifact generation (#956) — the same PULL shape: run, status, cancel.
|
|
181
|
+
if (req.method === "POST" && path === "/local-artifact/run") {
|
|
182
|
+
return json(res, 202, runner.localArtifact.start(await readJson(req)));
|
|
183
|
+
}
|
|
184
|
+
if (req.method === "POST" && path === "/local-artifact/status") {
|
|
185
|
+
return json(res, 200, runner.localArtifact.status(await readJson(req)));
|
|
186
|
+
}
|
|
187
|
+
if (req.method === "POST" && path === "/local-artifact/cancel") {
|
|
188
|
+
return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
|
|
189
|
+
}
|
|
180
190
|
if (req.method === "POST" && path === "/coding/start") {
|
|
181
191
|
const b = await readJson(req);
|
|
182
192
|
return json(res, 200, runner.coding.start(b));
|