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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -2
  3. package/dist/apple-types.js +45 -0
  4. package/dist/apple-types.js.map +1 -0
  5. package/dist/apple.js +900 -0
  6. package/dist/apple.js.map +1 -0
  7. package/dist/cli.js +414 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/config.js +80 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/dependencies.js +157 -0
  12. package/dist/dependencies.js.map +1 -0
  13. package/dist/export-options.js +49 -0
  14. package/dist/export-options.js.map +1 -0
  15. package/dist/flutter.js +171 -0
  16. package/dist/flutter.js.map +1 -0
  17. package/dist/inbox.js +128 -0
  18. package/dist/inbox.js.map +1 -0
  19. package/dist/integrations.js +435 -0
  20. package/dist/integrations.js.map +1 -0
  21. package/dist/preflight.js +43 -0
  22. package/dist/preflight.js.map +1 -0
  23. package/dist/process.js +260 -0
  24. package/dist/process.js.map +1 -0
  25. package/dist/remote.js +143 -0
  26. package/dist/remote.js.map +1 -0
  27. package/dist/requirements.js +37 -0
  28. package/dist/requirements.js.map +1 -0
  29. package/dist/runner.js +722 -0
  30. package/dist/runner.js.map +1 -0
  31. package/dist/scheduler.js +83 -0
  32. package/dist/scheduler.js.map +1 -0
  33. package/dist/schemas.js +232 -0
  34. package/dist/schemas.js.map +1 -0
  35. package/dist/store.js +361 -0
  36. package/dist/store.js.map +1 -0
  37. package/examples/factory.json +28 -0
  38. package/examples/luggage.json +39 -0
  39. package/examples/remote.json +6 -0
  40. package/package.json +30 -3
  41. package/skills/apphx/LICENSE +21 -0
  42. package/skills/apphx/SKILL.md +103 -0
  43. package/skills/apphx/resources/requirement.json +39 -0
  44. package/skills/apphx/resources/research-result.json +21 -0
  45. 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