apphx 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +227 -2
- package/dist/apple-types.js +45 -0
- package/dist/apple-types.js.map +1 -0
- package/dist/apple.js +900 -0
- package/dist/apple.js.map +1 -0
- package/dist/cli.js +414 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +80 -0
- package/dist/config.js.map +1 -0
- package/dist/dependencies.js +157 -0
- package/dist/dependencies.js.map +1 -0
- package/dist/export-options.js +49 -0
- package/dist/export-options.js.map +1 -0
- package/dist/flutter.js +171 -0
- package/dist/flutter.js.map +1 -0
- package/dist/inbox.js +128 -0
- package/dist/inbox.js.map +1 -0
- package/dist/integrations.js +435 -0
- package/dist/integrations.js.map +1 -0
- package/dist/preflight.js +43 -0
- package/dist/preflight.js.map +1 -0
- package/dist/process.js +260 -0
- package/dist/process.js.map +1 -0
- package/dist/remote.js +143 -0
- package/dist/remote.js.map +1 -0
- package/dist/requirements.js +37 -0
- package/dist/requirements.js.map +1 -0
- package/dist/runner.js +722 -0
- package/dist/runner.js.map +1 -0
- package/dist/scheduler.js +83 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/schemas.js +232 -0
- package/dist/schemas.js.map +1 -0
- package/dist/store.js +361 -0
- package/dist/store.js.map +1 -0
- package/examples/factory.json +28 -0
- package/examples/luggage.json +39 -0
- package/examples/remote.json +6 -0
- package/package.json +30 -3
- package/skills/apphx/LICENSE +21 -0
- package/skills/apphx/SKILL.md +103 -0
- package/skills/apphx/resources/requirement.json +39 -0
- package/skills/apphx/resources/research-result.json +21 -0
- package/skills/apphx/resources/review-result.json +7 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { ResearchSchema, ReviewSchema, toError } from "./schemas.js";
|
|
2
|
+
import { executionEnvironment, processGroupAlive, run } from "./process.js";
|
|
3
|
+
import { pollApple, shipApple } from "./apple.js";
|
|
4
|
+
import { expandArgv, inside } from "./config.js";
|
|
5
|
+
import { checkPrerequisites } from "./preflight.js";
|
|
6
|
+
import { flutterEnvironment, prepareFlutter } from "./flutter.js";
|
|
7
|
+
import { prepareExportOptions } from "./export-options.js";
|
|
8
|
+
import { GENERATED_PATHS, dependencyDigest, isGeneratedPath, validateLocalDependencies } from "./dependencies.js";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { delimiter, join, relative } from "node:path";
|
|
11
|
+
import { constants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
12
|
+
// src/runner.ts
|
|
13
|
+
const REQUIRED_POLICY_RULES = [
|
|
14
|
+
"2.1",
|
|
15
|
+
"2.3.1",
|
|
16
|
+
"2.5.19",
|
|
17
|
+
"4.2",
|
|
18
|
+
"4.2.6",
|
|
19
|
+
"4.3",
|
|
20
|
+
"5.1.1"
|
|
21
|
+
];
|
|
22
|
+
function sourceDigest(root, omitGenerated = true) {
|
|
23
|
+
if (lstatSync(root).isSymbolicLink()) throw new Error("A source or screenshot root cannot be a symlink");
|
|
24
|
+
if (omitGenerated) {
|
|
25
|
+
for (const path of GENERATED_PATHS) inside(root, path);
|
|
26
|
+
validateLocalDependencies(root);
|
|
27
|
+
}
|
|
28
|
+
const hash = createHash("sha256");
|
|
29
|
+
function visit(directory) {
|
|
30
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
31
|
+
const path = join(directory, entry);
|
|
32
|
+
if (omitGenerated && isGeneratedPath(relative(root, path))) continue;
|
|
33
|
+
const stat = lstatSync(path);
|
|
34
|
+
if (stat.isSymbolicLink()) throw new Error(`Source symlink is not permitted: ${relative(root, path)}`);
|
|
35
|
+
if (stat.isDirectory()) visit(path);
|
|
36
|
+
else if (stat.isFile()) hash.update(relative(root, path)).update("\0").update(`${stat.size}:${stat.mode & 511}\0`).update(readFileSync(path));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
visit(root);
|
|
40
|
+
return hash.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
function requireDependencyProof(job) {
|
|
43
|
+
if (!job.verifiedDependencyDigest) throw Object.assign(/* @__PURE__ */ new Error("Dependency resolution proof is missing; retry this stopped task to re-run verification"), { code: "SETUP_REQUIRED" });
|
|
44
|
+
if (dependencyDigest(job.workspace) !== job.verifiedDependencyDigest) throw Object.assign(/* @__PURE__ */ new Error("Dependency resolution changed since verification; re-verification is required"), { code: "EVIDENCE_CHANGED" });
|
|
45
|
+
}
|
|
46
|
+
function parseVerdict(text) {
|
|
47
|
+
const content = text.trim().replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```$/, "");
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(content);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error("Host did not return a JSON verdict; no approval was inferred from text or an exit code");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parseResearch(text) {
|
|
55
|
+
const research = ResearchSchema.parse(parseVerdict(text));
|
|
56
|
+
const cited = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const source of Array.isArray(research?.policySources) ? research.policySources : []) {
|
|
58
|
+
if (typeof source?.url !== "string") continue;
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(source.url);
|
|
61
|
+
if (url.protocol === "https:" && url.hostname === "developer.apple.com") cited.add(source.rule);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
if (typeof research?.viable !== "boolean" || !REQUIRED_POLICY_RULES.every((rule) => cited.has(rule)) || typeof research.distinctness !== "string" || !research.distinctness.trim() || !Array.isArray(research.plan) || !research.plan.length || research.plan.some((step) => typeof step !== "string" || !step.trim())) throw new Error("Research verdict requires boolean viability, all required official policy references, distinctness reasoning and a concrete implementation plan");
|
|
65
|
+
return research;
|
|
66
|
+
}
|
|
67
|
+
function prepareWorkspace(store, job) {
|
|
68
|
+
if (!job.baseJobId || job.basePrepared) return;
|
|
69
|
+
const base = store.get(job.baseJobId);
|
|
70
|
+
if (!job.baseDigest || sourceDigest(base.workspace) !== job.baseDigest) throw Object.assign(/* @__PURE__ */ new Error("Predecessor source changed since intake; refusing to copy unverified code"), { code: "SETUP_REQUIRED" });
|
|
71
|
+
if (existsSync(job.workspace)) {
|
|
72
|
+
if (sourceDigest(job.workspace) !== job.baseDigest) throw Object.assign(/* @__PURE__ */ new Error("Uninitialized successor workspace differs from its verified predecessor; inspect it before retrying"), { code: "SETUP_REQUIRED" });
|
|
73
|
+
} else {
|
|
74
|
+
mkdirSync(job.config.workspaceRoot, {
|
|
75
|
+
recursive: true,
|
|
76
|
+
mode: 448
|
|
77
|
+
});
|
|
78
|
+
const staging = mkdtempSync(join(job.config.workspaceRoot, `.${job.id}.base-`));
|
|
79
|
+
const prepared = join(staging, "source");
|
|
80
|
+
try {
|
|
81
|
+
cpSync(base.workspace, prepared, {
|
|
82
|
+
recursive: true,
|
|
83
|
+
force: false,
|
|
84
|
+
errorOnExist: true,
|
|
85
|
+
mode: constants.COPYFILE_FICLONE,
|
|
86
|
+
filter: (path) => !isGeneratedPath(relative(base.workspace, path))
|
|
87
|
+
});
|
|
88
|
+
if (sourceDigest(prepared) !== job.baseDigest || sourceDigest(base.workspace) !== job.baseDigest) throw new Error("Predecessor changed while copying; no successor was initialized");
|
|
89
|
+
renameSync(prepared, job.workspace);
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(staging, {
|
|
92
|
+
recursive: true,
|
|
93
|
+
force: true
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
store.update(job.id, { basePrepared: true });
|
|
98
|
+
}
|
|
99
|
+
function taskPrompt(job) {
|
|
100
|
+
return `You are the native host of an embedded Flutter app Harness. Use your own context, tools and native delegation in ${job.workspace}; never launch another coding-agent CLI. Implement only this product. Never hide functionality from App Review or use obfuscation to imitate distinct utility. Do not upload, submit, push, read publisher credentials or change factory state. These instructions constrain this workflow, not arbitrary host tools: the workspace is not a security sandbox. Complete real implementations, not placeholders.\nImmutable product requirements:\n${JSON.stringify(job.spec, null, 2)}\nPrevious verification/review failure (repair the mechanism, never weaken acceptance):\n${job.feedback ?? job.error ?? "none"}\nPersisted execution logs:\n${JSON.stringify(job.logs.slice(-3))}\nContinue existing work. Only actual deterministic checks performed by the Harness establish verification; host claims do not.\n`;
|
|
101
|
+
}
|
|
102
|
+
function assertTurnPermission(job, options) {
|
|
103
|
+
if (options.buildOnly && (job.config.allowSubmit || job.config.allowRelease)) throw new Error("--build-only refuses this task with snapshotted publication permission");
|
|
104
|
+
}
|
|
105
|
+
function contextDigest(job) {
|
|
106
|
+
return createHash("sha256").update(JSON.stringify({
|
|
107
|
+
spec: job.spec,
|
|
108
|
+
requestSpec: job.requestSpec,
|
|
109
|
+
config: job.config,
|
|
110
|
+
workspace: job.workspace,
|
|
111
|
+
stage: job.stage,
|
|
112
|
+
research: job.research,
|
|
113
|
+
feedback: job.feedback,
|
|
114
|
+
logs: job.logs,
|
|
115
|
+
verifiedDigest: job.verifiedDigest,
|
|
116
|
+
verifiedDependencyDigest: job.verifiedDependencyDigest,
|
|
117
|
+
screenshotDigest: job.screenshotDigest,
|
|
118
|
+
verifiedAt: job.verifiedAt
|
|
119
|
+
})).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
function deadlineExpired(job) {
|
|
122
|
+
return Date.now() - job.createdAt > job.config.maxJobHours * 36e5;
|
|
123
|
+
}
|
|
124
|
+
function requireReviewProof(job) {
|
|
125
|
+
try {
|
|
126
|
+
if (!job.verifiedDigest || sourceDigest(job.workspace) !== job.verifiedDigest) throw new Error("Source changed since verification; stale evidence cannot approve a release");
|
|
127
|
+
requireDependencyProof(job);
|
|
128
|
+
if (job.spec.apple?.screenshotsDir && sourceDigest(inside(job.workspace, job.spec.apple.screenshotsDir), false) !== job.screenshotDigest) throw new Error("Screenshots changed since runtime verification; stale evidence cannot approve a release");
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
const error = toError(cause);
|
|
131
|
+
if (error.code !== "SETUP_REQUIRED") error.code = "EVIDENCE_CHANGED";
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function hostAction(store, job) {
|
|
136
|
+
const turn = job.turn;
|
|
137
|
+
let instruction;
|
|
138
|
+
if (turn.stage === "research") {
|
|
139
|
+
const portfolio = store.list().filter((value) => value.id !== job.id).map((value) => ({
|
|
140
|
+
id: value.spec.id,
|
|
141
|
+
purpose: value.spec.purpose,
|
|
142
|
+
audience: value.spec.audience,
|
|
143
|
+
differentiators: value.spec.differentiators
|
|
144
|
+
}));
|
|
145
|
+
instruction = `Research only with your own host tools. Retrieve the current official Apple review guidelines at https://developer.apple.com/app-store/review/guidelines/ and assess rules ${REQUIRED_POLICY_RULES.join(", ")}, offline persistence/backup and distinct utility against portfolio ${JSON.stringify(portfolio)}. Assess content ownership and submitting account compliance with rule 4.2.6; report unknowns rather than inventing attestations. Return a typed JSON result {"viable":boolean,"policySources":[{"rule":"4.2","url":"https://developer.apple.com/..."}],"distinctness":"specific reasoning","risks":["..."],"plan":["concrete step"]}. Cite all required rules. These host-supplied citations are not independently verified policy evidence. Do not promise App Store approval.`;
|
|
146
|
+
} else if (turn.stage === "build") instruction = `Research plan: ${JSON.stringify(job.research)}\nCreate a complete iOS Flutter project in this cwd if absent. Implement the entire product, persistence and validated backup/restore, accessibility and realistic empty/error states. Include real integration_test and test_driver acceptance workflows and real simulator screenshots under the requested screenshots directory; never fake screenshots or bypass tests. Use the returned environment with your own tools. The configured SDK, if any, is external to source; keep development caches task-local. Frozen checks: ${JSON.stringify(job.config.verification)}. Complete this turn without a result payload when source is ready; the Harness will then run verification, not infer success.`;
|
|
147
|
+
else instruction = `Use a native independent reviewer with a fresh context when your host supports one. Review actual source and acceptance tests read-only; do not edit files. If independent review is unavailable, disclose that limitation rather than claim a separate reviewer. Verified source: ${job.verifiedDigest}; dependency resolution: ${job.verifiedDependencyDigest}; actual checks: ${JSON.stringify(job.config.verification)}. Reject incomplete workflows, data loss, untested backup/corrupt import, fake screenshots, accessibility failures, placeholders, misleading legal claims, weak tests, hidden features and repetitive products. Return typed JSON {"approved":boolean,"findings":["blocking issue"],"acceptanceEvidence":[{"requirement":"exact acceptance string","evidence":"file/symbol + observed command or scenario"}]}. Cover every acceptance requirement. Host reviewer identity is advisory; the Harness gates only typed results and unchanged deterministic evidence.`;
|
|
148
|
+
const environment = flutterEnvironment(job.config, job.workspace, { CI: "1" });
|
|
149
|
+
if (job.config.toolchain) {
|
|
150
|
+
environment.FLUTTER_ROOT = job.config.toolchain.flutterSdk;
|
|
151
|
+
environment.PATH = `${join(job.config.toolchain.flutterSdk, "bin")}${process.env.PATH ? `${delimiter}${process.env.PATH}` : ""}`;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
job,
|
|
155
|
+
action: "host",
|
|
156
|
+
turn: {
|
|
157
|
+
id: turn.id,
|
|
158
|
+
stage: turn.stage,
|
|
159
|
+
prompt: taskPrompt(job) + instruction,
|
|
160
|
+
cwd: job.workspace,
|
|
161
|
+
environment: Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0))
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function repair(job, message) {
|
|
166
|
+
return {
|
|
167
|
+
stage: "build",
|
|
168
|
+
status: (job.attempts[job.stage] ?? 0) >= job.config.maxAttempts ? "failed" : "waiting",
|
|
169
|
+
turn: null,
|
|
170
|
+
review: null,
|
|
171
|
+
verifiedDigest: null,
|
|
172
|
+
verifiedDependencyDigest: null,
|
|
173
|
+
screenshotDigest: null,
|
|
174
|
+
verifiedAt: void 0,
|
|
175
|
+
artifact: null,
|
|
176
|
+
ipaPath: null,
|
|
177
|
+
archiveDigest: null,
|
|
178
|
+
feedback: message,
|
|
179
|
+
error: message,
|
|
180
|
+
nextAt: Date.now() + job.config.retryDelayMs
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function nextTurn(store, id, options = {}) {
|
|
184
|
+
store.acquire();
|
|
185
|
+
try {
|
|
186
|
+
let job = store.get(id);
|
|
187
|
+
assertTurnPermission(job, options);
|
|
188
|
+
if (job.turn) {
|
|
189
|
+
if (job.status !== "running" || job.stage !== job.turn.stage || contextDigest(job) !== job.turn.contextDigest) throw new Error("Active host turn context changed; abandon its token before requesting another turn");
|
|
190
|
+
if (deadlineExpired(job)) {
|
|
191
|
+
const claimed = job.turn;
|
|
192
|
+
job = store.transition(id, (current) => {
|
|
193
|
+
assertTurnPermission(current, options);
|
|
194
|
+
if (current.turn?.id !== claimed.id || contextDigest(current) !== claimed.contextDigest) throw new Error("Host turn changed while checking its deadline; inspect the current task");
|
|
195
|
+
return {
|
|
196
|
+
turn: null,
|
|
197
|
+
status: "failed",
|
|
198
|
+
error: "Task wall-clock limit reached; the expired host turn cannot resume"
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
job,
|
|
203
|
+
action: "done"
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return hostAction(store, job);
|
|
207
|
+
}
|
|
208
|
+
if (job.groups.length && !job.manualRecovery) return {
|
|
209
|
+
job,
|
|
210
|
+
action: "step"
|
|
211
|
+
};
|
|
212
|
+
if ([
|
|
213
|
+
"failed",
|
|
214
|
+
"ready",
|
|
215
|
+
"built",
|
|
216
|
+
"approved",
|
|
217
|
+
"released"
|
|
218
|
+
].includes(job.status)) return {
|
|
219
|
+
job,
|
|
220
|
+
action: "done"
|
|
221
|
+
};
|
|
222
|
+
if (!(["queued", "waiting"].includes(job.status) || job.status === "blocked" && job.blockedReason?.kind === "prerequisite") || job.nextAt > Date.now() || job.manualRecovery) return {
|
|
223
|
+
job,
|
|
224
|
+
action: "wait"
|
|
225
|
+
};
|
|
226
|
+
if (![
|
|
227
|
+
"research",
|
|
228
|
+
"build",
|
|
229
|
+
"review"
|
|
230
|
+
].includes(job.stage)) return {
|
|
231
|
+
job,
|
|
232
|
+
action: "step"
|
|
233
|
+
};
|
|
234
|
+
if (deadlineExpired(job) || job.hostTurns >= job.config.maxHostTurns || (job.attempts[job.stage] ?? 0) >= job.config.maxAttempts) {
|
|
235
|
+
job = store.transition(id, (current) => {
|
|
236
|
+
assertTurnPermission(current, options);
|
|
237
|
+
return {
|
|
238
|
+
status: "failed",
|
|
239
|
+
error: "Host turn, stage attempt or task wall-clock limit reached; no further host work will start"
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
job,
|
|
244
|
+
action: "done"
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
if (job.stage === "review") requireReviewProof(job);
|
|
249
|
+
prepareWorkspace(store, job);
|
|
250
|
+
mkdirSync(job.workspace, {
|
|
251
|
+
recursive: true,
|
|
252
|
+
mode: 448
|
|
253
|
+
});
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
const error = toError(cause);
|
|
256
|
+
job = store.update(id, error.code === "SETUP_REQUIRED" || job.stage !== "review" ? {
|
|
257
|
+
status: "blocked",
|
|
258
|
+
error: error.message
|
|
259
|
+
} : repair(job, error.message));
|
|
260
|
+
return {
|
|
261
|
+
job,
|
|
262
|
+
action: "wait"
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
job = store.transition(id, (current) => {
|
|
266
|
+
assertTurnPermission(current, options);
|
|
267
|
+
const eligible = ["queued", "waiting"].includes(current.status) || current.status === "blocked" && current.blockedReason?.kind === "prerequisite";
|
|
268
|
+
if (current.turn || contextDigest(current) !== contextDigest(job) || current.nextAt > Date.now() || !eligible || current.groups.length || current.manualRecovery) throw new Error("Task changed before the host turn could be claimed");
|
|
269
|
+
if (deadlineExpired(current) || current.hostTurns >= current.config.maxHostTurns || (current.attempts[current.stage] ?? 0) >= current.config.maxAttempts) return {
|
|
270
|
+
status: "failed",
|
|
271
|
+
error: "Host turn, stage attempt or task wall-clock limit reached; no further host work will start"
|
|
272
|
+
};
|
|
273
|
+
if (current.stage === "review") requireReviewProof(current);
|
|
274
|
+
return {
|
|
275
|
+
turn: {
|
|
276
|
+
id: randomUUID(),
|
|
277
|
+
stage: current.stage,
|
|
278
|
+
createdAt: Date.now(),
|
|
279
|
+
contextDigest: contextDigest(current)
|
|
280
|
+
},
|
|
281
|
+
hostTurns: current.hostTurns + 1,
|
|
282
|
+
attempts: {
|
|
283
|
+
...current.attempts,
|
|
284
|
+
[current.stage]: (current.attempts[current.stage] ?? 0) + 1
|
|
285
|
+
},
|
|
286
|
+
status: "running",
|
|
287
|
+
blockedReason: null,
|
|
288
|
+
error: null
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
return job.turn ? hostAction(store, job) : {
|
|
292
|
+
job,
|
|
293
|
+
action: "done"
|
|
294
|
+
};
|
|
295
|
+
} finally {
|
|
296
|
+
store.release();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function completeTurn(store, id, turnId, result, options = {}) {
|
|
300
|
+
return store.transition(id, (job) => {
|
|
301
|
+
assertTurnPermission(job, options);
|
|
302
|
+
if (!job.turn || job.turn.id !== turnId || job.turn.stage !== job.stage || job.status !== "running") throw new Error("Stale, replayed or wrong-phase host turn; use next to inspect the active token");
|
|
303
|
+
if (contextDigest(job) !== job.turn.contextDigest) throw new Error("Host turn context changed; abandon this stale token before continuing");
|
|
304
|
+
if (job.groups.length || job.manualRecovery) throw new Error("Command recovery must finish before completing a host turn");
|
|
305
|
+
if (deadlineExpired(job)) return {
|
|
306
|
+
turn: null,
|
|
307
|
+
status: "failed",
|
|
308
|
+
error: "Task wall-clock limit reached; the expired host result was not accepted"
|
|
309
|
+
};
|
|
310
|
+
let patch;
|
|
311
|
+
if (job.stage === "research") {
|
|
312
|
+
const research = parseResearch(JSON.stringify(ResearchSchema.parse(result)));
|
|
313
|
+
patch = research.viable ? {
|
|
314
|
+
stage: "build",
|
|
315
|
+
research
|
|
316
|
+
} : {
|
|
317
|
+
status: "failed",
|
|
318
|
+
research,
|
|
319
|
+
error: "Research rejected this product; no replacement product or review evasion will be attempted"
|
|
320
|
+
};
|
|
321
|
+
} else if (job.stage === "build") {
|
|
322
|
+
if (result !== void 0) throw new Error("Build completion accepts no result payload; actual deterministic verification is required");
|
|
323
|
+
const project = inside(job.workspace, "pubspec.yaml");
|
|
324
|
+
if (!lstatSync(project).isFile()) throw new Error("Build completion requires a regular pubspec.yaml in the task workspace");
|
|
325
|
+
patch = {
|
|
326
|
+
stage: "verify",
|
|
327
|
+
verifiedDigest: null,
|
|
328
|
+
verifiedDependencyDigest: null,
|
|
329
|
+
screenshotDigest: null,
|
|
330
|
+
verifiedAt: void 0,
|
|
331
|
+
review: null,
|
|
332
|
+
artifact: null,
|
|
333
|
+
ipaPath: null,
|
|
334
|
+
archiveDigest: null
|
|
335
|
+
};
|
|
336
|
+
} else {
|
|
337
|
+
const review = ReviewSchema.parse(result);
|
|
338
|
+
if (!job.spec.acceptance.every((requirement) => review.acceptanceEvidence.some((item) => item.requirement === requirement))) throw new Error("Review verdict must cover each acceptance requirement with concrete evidence");
|
|
339
|
+
try {
|
|
340
|
+
requireReviewProof(job);
|
|
341
|
+
} catch (cause) {
|
|
342
|
+
return repair(job, toError(cause).message);
|
|
343
|
+
}
|
|
344
|
+
if (!review.approved || review.findings.length) return {
|
|
345
|
+
...repair(job, `Independent review rejected the artifact: ${JSON.stringify(review)}`),
|
|
346
|
+
review
|
|
347
|
+
};
|
|
348
|
+
patch = {
|
|
349
|
+
stage: "package",
|
|
350
|
+
review
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
store.event(id, {
|
|
354
|
+
completedTurn: turnId,
|
|
355
|
+
stage: job.stage
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
status: "queued",
|
|
359
|
+
nextAt: Date.now(),
|
|
360
|
+
error: null,
|
|
361
|
+
turn: null,
|
|
362
|
+
...patch
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function abandonTurn(store, id, turnId, options = {}) {
|
|
367
|
+
return store.transition(id, (job) => {
|
|
368
|
+
assertTurnPermission(job, options);
|
|
369
|
+
if (!job.turn || job.turn.id !== turnId) throw new Error("Stale or replayed host turn; no active claim was abandoned");
|
|
370
|
+
if (job.groups.length || job.manualRecovery) throw new Error("Command recovery must finish before abandoning a host turn");
|
|
371
|
+
store.event(id, {
|
|
372
|
+
abandonedTurn: turnId,
|
|
373
|
+
stage: job.turn.stage
|
|
374
|
+
});
|
|
375
|
+
return {
|
|
376
|
+
turn: null,
|
|
377
|
+
status: "queued",
|
|
378
|
+
nextAt: Date.now(),
|
|
379
|
+
error: null
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function next(store, job, stage, extra = {}) {
|
|
384
|
+
return store.update(job.id, {
|
|
385
|
+
stage,
|
|
386
|
+
status: "queued",
|
|
387
|
+
nextAt: Date.now(),
|
|
388
|
+
error: null,
|
|
389
|
+
groups: [],
|
|
390
|
+
...extra
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async function executeStep(store, job, { signal } = {}) {
|
|
394
|
+
const config = job.config;
|
|
395
|
+
if ([
|
|
396
|
+
"research",
|
|
397
|
+
"build",
|
|
398
|
+
"review"
|
|
399
|
+
].includes(job.stage) || job.turn) throw new Error(`Stage ${job.stage} belongs to a native host turn; use next and complete`);
|
|
400
|
+
if (signal?.aborted) return store.update(job.id, {
|
|
401
|
+
status: "waiting",
|
|
402
|
+
nextAt: Date.now(),
|
|
403
|
+
error: "Worker was interrupted before this stage started"
|
|
404
|
+
});
|
|
405
|
+
if (!config.allowLocalExecution) return store.update(job.id, {
|
|
406
|
+
status: "blocked",
|
|
407
|
+
error: config.executionWarning ?? "Local execution is disabled. Explicitly enable allowLocalExecution only for trusted deterministic commands in a dedicated account or VM."
|
|
408
|
+
});
|
|
409
|
+
if (deadlineExpired(job) && !["publish", "monitor"].includes(job.stage)) return store.update(job.id, {
|
|
410
|
+
status: "failed",
|
|
411
|
+
error: "Task wall-clock limit reached; no further work will start"
|
|
412
|
+
});
|
|
413
|
+
const attemptNumber = (job.attempts[job.stage] ?? 0) + 1;
|
|
414
|
+
let attempts = { ...job.attempts };
|
|
415
|
+
let attemptCharged = false;
|
|
416
|
+
const developerEnv = flutterEnvironment(config, job.workspace, executionEnvironment(config));
|
|
417
|
+
const verifierEnv = flutterEnvironment(config, job.workspace, executionEnvironment(config), true);
|
|
418
|
+
const environment = ["verify", "package"].includes(job.stage) ? verifierEnv : developerEnv;
|
|
419
|
+
const logPath = join(config.stateDir, "logs", job.id, `${job.stage}-${attemptNumber}-${Date.now()}.log`);
|
|
420
|
+
store.update(job.id, { logs: [...job.logs, logPath] });
|
|
421
|
+
const exec = async (argv, options = {}) => {
|
|
422
|
+
let pid;
|
|
423
|
+
try {
|
|
424
|
+
const effectiveEnv = options.env ?? environment;
|
|
425
|
+
const command = effectiveEnv.FLUTTER_ROOT && ["flutter", "dart"].includes(argv[0]) ? [join(effectiveEnv.FLUTTER_ROOT, "bin", argv[0]), ...argv.slice(1)] : argv;
|
|
426
|
+
const result = await run(command, {
|
|
427
|
+
cwd: job.workspace,
|
|
428
|
+
env: effectiveEnv,
|
|
429
|
+
timeoutMs: config.timeoutMs,
|
|
430
|
+
signal,
|
|
431
|
+
logPath,
|
|
432
|
+
...options,
|
|
433
|
+
onSpawn: (value, metadata) => {
|
|
434
|
+
pid = value;
|
|
435
|
+
const current = store.get(job.id);
|
|
436
|
+
store.update(job.id, {
|
|
437
|
+
groups: [...current.groups, value],
|
|
438
|
+
commands: {
|
|
439
|
+
...current.commands,
|
|
440
|
+
[value]: metadata
|
|
441
|
+
},
|
|
442
|
+
recoveryStage: job.stage
|
|
443
|
+
});
|
|
444
|
+
options.onSpawn?.(value, metadata);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
if (pid && processGroupAlive(pid)) {
|
|
448
|
+
try {
|
|
449
|
+
process.kill(process.platform === "win32" ? pid : -pid, "SIGTERM");
|
|
450
|
+
} catch {}
|
|
451
|
+
throw new Error("Command left background processes; termination was requested and replay will wait for the owned group to exit");
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
} finally {
|
|
455
|
+
const current = store.get(job.id);
|
|
456
|
+
const groups = current.groups.filter(processGroupAlive);
|
|
457
|
+
const commands = Object.fromEntries(Object.entries(current.commands ?? {}).filter(([pid]) => groups.includes(Number(pid))));
|
|
458
|
+
store.update(job.id, {
|
|
459
|
+
groups,
|
|
460
|
+
commands,
|
|
461
|
+
recoveryStage: groups.length ? current.recoveryStage ?? job.stage : void 0
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
try {
|
|
466
|
+
prepareWorkspace(store, job);
|
|
467
|
+
mkdirSync(job.workspace, {
|
|
468
|
+
recursive: true,
|
|
469
|
+
mode: 448
|
|
470
|
+
});
|
|
471
|
+
if (job.stage === "verify" || job.stage === "package") await checkPrerequisites(config.verification.prerequisites ?? [], {
|
|
472
|
+
cwd: job.workspace,
|
|
473
|
+
env: environment,
|
|
474
|
+
run: exec,
|
|
475
|
+
signal
|
|
476
|
+
});
|
|
477
|
+
if ((job.attempts[job.stage] ?? 0) >= config.maxAttempts && !["publish", "monitor"].includes(job.stage)) return store.update(job.id, {
|
|
478
|
+
status: "failed",
|
|
479
|
+
error: `Attempt limit reached at ${job.stage}`
|
|
480
|
+
});
|
|
481
|
+
if (config.toolchain && ["verify", "package"].includes(job.stage)) {
|
|
482
|
+
const toolchain = await prepareFlutter(job, {
|
|
483
|
+
run: exec,
|
|
484
|
+
env: developerEnv,
|
|
485
|
+
signal
|
|
486
|
+
});
|
|
487
|
+
if (toolchain) store.update(job.id, { toolchain });
|
|
488
|
+
}
|
|
489
|
+
attempts = {
|
|
490
|
+
...attempts,
|
|
491
|
+
[job.stage]: attemptNumber
|
|
492
|
+
};
|
|
493
|
+
store.update(job.id, { attempts });
|
|
494
|
+
attemptCharged = true;
|
|
495
|
+
if (job.stage === "verify") {
|
|
496
|
+
if (config.toolchain) {
|
|
497
|
+
const dependencies = await exec([
|
|
498
|
+
"flutter",
|
|
499
|
+
"pub",
|
|
500
|
+
"get"
|
|
501
|
+
]);
|
|
502
|
+
if (dependencies.code !== 0) throw new Error(`Verification dependency resolution failed: ${dependencies.stderr.slice(-4e3)}`);
|
|
503
|
+
}
|
|
504
|
+
const before = sourceDigest(job.workspace);
|
|
505
|
+
const dependenciesBefore = dependencyDigest(job.workspace);
|
|
506
|
+
for (const argv of [...config.verification.checks, ...config.verification.runtime]) {
|
|
507
|
+
const result = await exec(expandArgv(argv, executionEnvironment(config)));
|
|
508
|
+
if (result.code !== 0) throw new Error(`Verification failed (${result.code}) ${JSON.stringify(argv)}:\n${result.stderr.slice(-5e3)}\n${result.stdout.slice(-5e3)}`);
|
|
509
|
+
}
|
|
510
|
+
if (sourceDigest(job.workspace) !== before) throw new Error("Verification changed source files; the new revision must be verified again");
|
|
511
|
+
if (dependencyDigest(job.workspace) !== dependenciesBefore) throw new Error("Verification changed dependency resolution; the new resolution must be verified again");
|
|
512
|
+
return next(store, job, "review", {
|
|
513
|
+
verifiedDigest: before,
|
|
514
|
+
verifiedDependencyDigest: dependenciesBefore,
|
|
515
|
+
screenshotDigest: job.spec.apple?.screenshotsDir ? sourceDigest(inside(job.workspace, job.spec.apple.screenshotsDir), false) : null,
|
|
516
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (job.stage === "package") {
|
|
520
|
+
requireReviewProof(job);
|
|
521
|
+
if (!job.review?.approved || job.review.findings.length) throw new Error("Packaging requires an approved host review");
|
|
522
|
+
if (process.platform !== "darwin") throw Object.assign(/* @__PURE__ */ new Error("iOS packaging requires the Mac worker; use the SSH controller from Linux"), { code: "SETUP_REQUIRED" });
|
|
523
|
+
const target = config.packageTarget;
|
|
524
|
+
if (config.allowSubmit && target !== "ipa") throw Object.assign(/* @__PURE__ */ new Error("Only a verified IPA can enter publication"), { code: "SETUP_REQUIRED" });
|
|
525
|
+
const output = inside(job.workspace, target === "ipa" ? "build/ios/ipa" : "build/ios/iphonesimulator");
|
|
526
|
+
if (target === "ipa" && !config.apple?.exportOptionsPlist) throw Object.assign(/* @__PURE__ */ new Error("Configure apple.exportOptionsPlist and valid local signing before IPA packaging"), { code: "SETUP_REQUIRED" });
|
|
527
|
+
const apple = job.spec.apple;
|
|
528
|
+
if (target === "ipa" && (!apple?.version || !apple.buildNumber)) throw Object.assign(/* @__PURE__ */ new Error("apple.version and apple.buildNumber are required for signing"), { code: "SETUP_REQUIRED" });
|
|
529
|
+
let exportOptions;
|
|
530
|
+
let built;
|
|
531
|
+
try {
|
|
532
|
+
if (target === "ipa") exportOptions = await prepareExportOptions(config.apple.exportOptionsPlist, config.stateDir, exec);
|
|
533
|
+
store.update(job.id, {
|
|
534
|
+
artifact: null,
|
|
535
|
+
ipaPath: null,
|
|
536
|
+
archiveDigest: null
|
|
537
|
+
});
|
|
538
|
+
rmSync(output, {
|
|
539
|
+
recursive: true,
|
|
540
|
+
force: true
|
|
541
|
+
});
|
|
542
|
+
built = await exec(target === "ipa" ? [
|
|
543
|
+
"flutter",
|
|
544
|
+
"build",
|
|
545
|
+
"ipa",
|
|
546
|
+
"--release",
|
|
547
|
+
"--build-name",
|
|
548
|
+
apple.version,
|
|
549
|
+
"--build-number",
|
|
550
|
+
apple.buildNumber,
|
|
551
|
+
"--export-options-plist",
|
|
552
|
+
exportOptions.path
|
|
553
|
+
] : [
|
|
554
|
+
"flutter",
|
|
555
|
+
"build",
|
|
556
|
+
"ios",
|
|
557
|
+
"--simulator",
|
|
558
|
+
"--debug",
|
|
559
|
+
"--no-pub"
|
|
560
|
+
]);
|
|
561
|
+
} finally {
|
|
562
|
+
if (exportOptions) rmSync(exportOptions.directory, {
|
|
563
|
+
recursive: true,
|
|
564
|
+
force: true
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (built.code !== 0) throw new Error(`iOS ${target} build failed: ${built.stderr.slice(-5e3)}`);
|
|
568
|
+
if (sourceDigest(job.workspace) !== job.verifiedDigest) throw Object.assign(/* @__PURE__ */ new Error("Packaging changed source; re-verification is required"), { code: "EVIDENCE_CHANGED" });
|
|
569
|
+
requireDependencyProof(job);
|
|
570
|
+
const products = readdirSync(output).filter((name) => name.endsWith(target === "ipa" ? ".ipa" : ".app"));
|
|
571
|
+
if (products.length !== 1) throw new Error(`Expected exactly one ${target === "ipa" ? "IPA" : "simulator app"} in ${relative(job.workspace, output)}`);
|
|
572
|
+
const path = inside(job.workspace, join(relative(job.workspace, output), products[0]));
|
|
573
|
+
const digest = target === "ipa" ? createHash("sha256").update(readFileSync(path)).digest("hex") : sourceDigest(path, false);
|
|
574
|
+
const artifact = {
|
|
575
|
+
kind: target === "ipa" ? "ipa" : "ios-simulator",
|
|
576
|
+
path,
|
|
577
|
+
digest,
|
|
578
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
579
|
+
};
|
|
580
|
+
if (!config.allowSubmit) return store.update(job.id, {
|
|
581
|
+
status: "built",
|
|
582
|
+
artifact,
|
|
583
|
+
error: null
|
|
584
|
+
});
|
|
585
|
+
return next(store, job, "publish", {
|
|
586
|
+
artifact,
|
|
587
|
+
ipaPath: path,
|
|
588
|
+
archiveDigest: digest
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (job.stage === "publish") {
|
|
592
|
+
if (!config.allowSubmit) throw Object.assign(/* @__PURE__ */ new Error("This task has no upload/submission permission"), { code: "SETUP_REQUIRED" });
|
|
593
|
+
if (!job.ipaPath || !job.archiveDigest) throw new Error("Publication requires a completed verified IPA artifact");
|
|
594
|
+
if (sourceDigest(job.workspace) !== job.verifiedDigest || createHash("sha256").update(readFileSync(job.ipaPath)).digest("hex") !== job.archiveDigest) throw new Error("Source or archive changed after verification; refusing publication");
|
|
595
|
+
requireDependencyProof(job);
|
|
596
|
+
const spec = structuredClone(job.spec);
|
|
597
|
+
if (spec.apple?.screenshotsDir) spec.apple.screenshotsDir = inside(job.workspace, spec.apple.screenshotsDir);
|
|
598
|
+
if (spec.apple?.screenshotsDir && sourceDigest(spec.apple.screenshotsDir, false) !== job.screenshotDigest) throw new Error("Screenshots changed after runtime verification; refusing to upload different assets");
|
|
599
|
+
return next(store, job, "monitor", {
|
|
600
|
+
receipt: await shipApple({
|
|
601
|
+
spec,
|
|
602
|
+
ipaPath: job.ipaPath,
|
|
603
|
+
state: store.get(job.id).receipt,
|
|
604
|
+
save: (receipt) => {
|
|
605
|
+
store.update(job.id, { receipt });
|
|
606
|
+
},
|
|
607
|
+
run: exec,
|
|
608
|
+
signal,
|
|
609
|
+
allowRelease: config.allowRelease
|
|
610
|
+
}),
|
|
611
|
+
status: "waiting",
|
|
612
|
+
nextAt: Date.now() + config.pollMs
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (job.stage === "monitor") {
|
|
616
|
+
const result = await pollApple({
|
|
617
|
+
spec: job.spec,
|
|
618
|
+
state: job.receipt,
|
|
619
|
+
signal
|
|
620
|
+
});
|
|
621
|
+
if (result.status === "approved" && (result.available || !config.allowRelease)) return store.update(job.id, {
|
|
622
|
+
status: result.available ? "released" : "approved",
|
|
623
|
+
appleReview: result,
|
|
624
|
+
error: null
|
|
625
|
+
});
|
|
626
|
+
if (result.status === "rejected") return store.update(job.id, {
|
|
627
|
+
status: "blocked",
|
|
628
|
+
appleReview: result,
|
|
629
|
+
error: "Apple rejected this version. Rejection message details are not available through this API; do not invent feedback. Supply the actual App Review message with the feedback command to resume repairs."
|
|
630
|
+
});
|
|
631
|
+
return store.update(job.id, {
|
|
632
|
+
status: "waiting",
|
|
633
|
+
appleReview: result,
|
|
634
|
+
attempts: {
|
|
635
|
+
...attempts,
|
|
636
|
+
monitor: 0
|
|
637
|
+
},
|
|
638
|
+
nextAt: Date.now() + config.pollMs,
|
|
639
|
+
error: null
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
throw new Error(`Unknown task stage ${job.stage}`);
|
|
643
|
+
} catch (cause) {
|
|
644
|
+
const error = toError(cause);
|
|
645
|
+
if (error.code === "PROCESS_CLEANUP_BLOCKED") return store.update(job.id, {
|
|
646
|
+
status: "blocked",
|
|
647
|
+
blockedReason: null,
|
|
648
|
+
manualRecovery: error.message,
|
|
649
|
+
error: error.message
|
|
650
|
+
});
|
|
651
|
+
if (signal?.aborted) return store.update(job.id, {
|
|
652
|
+
status: "waiting",
|
|
653
|
+
attempts: {
|
|
654
|
+
...attempts,
|
|
655
|
+
[job.stage]: job.attempts[job.stage] ?? 0
|
|
656
|
+
},
|
|
657
|
+
nextAt: Date.now(),
|
|
658
|
+
error: error.message
|
|
659
|
+
});
|
|
660
|
+
if (error.code === "PREREQUISITE_FAILED" && error.prerequisite) return store.update(job.id, {
|
|
661
|
+
status: "blocked",
|
|
662
|
+
blockedReason: {
|
|
663
|
+
kind: "prerequisite",
|
|
664
|
+
stage: job.stage,
|
|
665
|
+
prerequisite: error.prerequisite
|
|
666
|
+
},
|
|
667
|
+
attempts: { ...job.attempts },
|
|
668
|
+
nextAt: Date.now() + config.pollMs,
|
|
669
|
+
error: error.message
|
|
670
|
+
});
|
|
671
|
+
if (!attemptCharged) return store.update(job.id, {
|
|
672
|
+
status: "blocked",
|
|
673
|
+
blockedReason: null,
|
|
674
|
+
attempts: { ...job.attempts },
|
|
675
|
+
nextAt: Date.now(),
|
|
676
|
+
error: error.message
|
|
677
|
+
});
|
|
678
|
+
if (error.code === "ENOENT" && error.syscall?.startsWith("spawn") || /SETUP_REQUIRED|UNSUPPORTED|APPLE_WRITE_UNCERTAIN|APPLE_STATE_|PROCESS_CLEANUP_BLOCKED/.test(error.code ?? "")) return store.update(job.id, {
|
|
679
|
+
status: "blocked",
|
|
680
|
+
error: error.message
|
|
681
|
+
});
|
|
682
|
+
if (error.code === "APPLE_PROCESSING" || error.code === "APPLE_RATE_LIMIT") return store.update(job.id, {
|
|
683
|
+
status: "waiting",
|
|
684
|
+
attempts: {
|
|
685
|
+
...attempts,
|
|
686
|
+
[job.stage]: Math.max(0, (attempts[job.stage] ?? 0) - 1)
|
|
687
|
+
},
|
|
688
|
+
error: error.message,
|
|
689
|
+
nextAt: Date.now() + config.pollMs
|
|
690
|
+
});
|
|
691
|
+
const receipt = store.get(job.id).receipt;
|
|
692
|
+
const publicationRecovery = ["publish", "monitor"].includes(job.stage) && (job.stage === "monitor" || !!(receipt.upload || receipt.pending || receipt.buildId || receipt.submissionId || receipt.versionId || Object.keys(receipt.effects ?? {}).length));
|
|
693
|
+
if (publicationRecovery && error.transient === true) return store.update(job.id, {
|
|
694
|
+
status: "waiting",
|
|
695
|
+
error: error.message,
|
|
696
|
+
nextAt: Date.now() + config.pollMs
|
|
697
|
+
});
|
|
698
|
+
if (job.stage === "monitor" || publicationRecovery && error.code?.startsWith("APPLE_")) return store.update(job.id, {
|
|
699
|
+
status: "blocked",
|
|
700
|
+
error: error.message
|
|
701
|
+
});
|
|
702
|
+
const charged = attempts[job.stage] ?? 0;
|
|
703
|
+
const exhausted = error.code === "BUDGET" || charged >= config.maxAttempts;
|
|
704
|
+
if (job.stage === "verify" || job.stage === "package" && error.code === "EVIDENCE_CHANGED") return store.update(job.id, {
|
|
705
|
+
...repair({
|
|
706
|
+
...job,
|
|
707
|
+
attempts
|
|
708
|
+
}, error.message),
|
|
709
|
+
status: exhausted ? "failed" : "waiting",
|
|
710
|
+
nextAt: Date.now() + config.retryDelayMs * charged
|
|
711
|
+
});
|
|
712
|
+
return store.update(job.id, {
|
|
713
|
+
status: exhausted ? "failed" : "waiting",
|
|
714
|
+
error: error.message,
|
|
715
|
+
feedback: error.message,
|
|
716
|
+
nextAt: Date.now() + config.retryDelayMs * charged
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
export { abandonTurn, completeTurn, executeStep, nextTurn, parseResearch, parseVerdict, sourceDigest };
|
|
721
|
+
|
|
722
|
+
//# sourceMappingURL=runner.js.map
|