426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
/**
|
|
* Deploy ledger for physical-iPhone sideloads: records which renderer buildId +
|
|
* git commit was last installed on which device, so `devices:status` can answer
|
|
* "is this phone running develop HEAD?" without reading the app's sandboxed
|
|
* container (a physical iOS device does not expose it).
|
|
*
|
|
* `ios-device-deploy.mjs` stages `App.app` locally before install, so the ledger
|
|
* is written deploy-side: it reads the staged renderer stamp
|
|
* (`public/eliza-renderer-build.json`, produced by the vite
|
|
* `renderer-build-manifest` plugin) and appends one JSONL record per successful
|
|
* install. `devices:status` (issue #14338) is the sole reader; when a device has
|
|
* no ledger row it reports "unknown — no ledger entry" honestly rather than
|
|
* guessing (a phone flashed by other means was never recorded here). The
|
|
* boot-trace pull remains the ground-truth cross-check.
|
|
*
|
|
* This module is the single owner of the ledger's filename, its state-dir
|
|
* resolution, its schema, and its read/write path — `devices-status.mjs` (the
|
|
* reader) and `ios-device-deploy.mjs` (the writer) both go through it, so there
|
|
* is exactly one canonical file for the two ends to agree on (a prior split —
|
|
* two filenames + two resolvers — made every deployed phone read back as
|
|
* "unknown, no ledger entry"; issues #14337/#14335, PR #14509 reconciliation).
|
|
*
|
|
* Everything here is pure or takes the filesystem edge as the JSONL path, so the
|
|
* append/read/latest-per-device logic is unit-tested against fixtures without a
|
|
* device. The renderer stamp itself — path resolution and typed read — lives in
|
|
* `lib/ios-renderer-stamp.mjs` (the single source of truth the simulator lanes
|
|
* also use); this module owns only the deploy-side, non-throwing freshness
|
|
* *verdict* ({@link evaluateStagedRendererFreshness}) the device deploy renders
|
|
* before install to refuse a staged bundle whose buildId does not match the
|
|
* freshly built dist (a stale UI would otherwise ship silently — issue #9309's
|
|
* failure mode, on the device lane).
|
|
*/
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Resolve the per-user state dir for the ledger. Precedence, highest first:
|
|
* 1. `ELIZA_DEVICES_STATUS_DIR` — the device-status lane's explicit override
|
|
* (the reader honored this before consolidation; kept so a caller can pin
|
|
* both ends of the ledger at one dir for a test or a scoped run).
|
|
* 2. `MILADY_STATE_DIR` — the repo-wide branded state prefix
|
|
* (`packages/core/src/utils/state-dir.ts` and every alias-aware reader
|
|
* honor it first; the device deploy lane inherits it).
|
|
* 3. `ELIZA_STATE_DIR` — the unbranded state override.
|
|
* 4. `$XDG_STATE_HOME/<namespace>` — XDG base-dir, absolute or home-relative.
|
|
* 5. `~/.local/state/<namespace>` — the default.
|
|
* This is the superset of the two resolvers that previously disagreed; the
|
|
* canonical TypeScript resolver cannot be imported from a plain build script, so
|
|
* the branded/XDG precedence is reproduced here and kept in lockstep with it.
|
|
*
|
|
* @param {{ env?: Record<string, string | undefined>, homedir?: () => string }} [options]
|
|
* @returns {string}
|
|
*/
|
|
export function resolveLedgerStateDir({
|
|
env = process.env,
|
|
homedir = os.homedir,
|
|
} = {}) {
|
|
const explicit = (
|
|
env.ELIZA_DEVICES_STATUS_DIR ??
|
|
env.MILADY_STATE_DIR ??
|
|
env.ELIZA_STATE_DIR
|
|
)?.trim();
|
|
if (explicit) return path.resolve(explicit);
|
|
const namespace = env.ELIZA_NAMESPACE?.trim() || "eliza";
|
|
const xdg = env.XDG_STATE_HOME?.trim();
|
|
if (xdg) {
|
|
return path.isAbsolute(xdg)
|
|
? path.join(xdg, namespace)
|
|
: path.join(homedir(), xdg, namespace);
|
|
}
|
|
return path.join(homedir(), ".local", "state", namespace);
|
|
}
|
|
|
|
/**
|
|
* Basename of the deploy-ledger JSONL. `devices:status` reads this exact file, so
|
|
* the writer must produce it under the same name — one canonical file, no
|
|
* per-name split (issues #14337/#14335).
|
|
*/
|
|
export const DEPLOY_LEDGER_FILENAME = "ios-device-deploy-ledger.jsonl";
|
|
|
|
/**
|
|
* Absolute path to the deploy-ledger JSONL. One file for all devices; each line
|
|
* is a self-contained deploy record (see {@link appendDeployRecord}).
|
|
*
|
|
* @param {{ env?: Record<string, string | undefined>, homedir?: () => string }} [options]
|
|
* @returns {string}
|
|
*/
|
|
export function resolveDeployLedgerPath(options = {}) {
|
|
return path.join(resolveLedgerStateDir(options), DEPLOY_LEDGER_FILENAME);
|
|
}
|
|
|
|
export const DEPLOY_LEDGER_SCHEMA = "elizaos.device.deploy-ledger/v1";
|
|
|
|
/**
|
|
* Build a validated deploy record. `udid` + `buildId` are required — a record
|
|
* that cannot identify the device or the renderer it received is useless to the
|
|
* status reader, so this throws rather than writing a hole. `deployedAt`
|
|
* defaults to now (ISO 8601). `commit` is nullable only because the renderer
|
|
* stamp itself carries a nullable commit (a build outside a git checkout).
|
|
*
|
|
* @param {{
|
|
* udid: string,
|
|
* buildId: string,
|
|
* name?: string | null,
|
|
* identifier?: string | null,
|
|
* commit?: string | null,
|
|
* variant?: string | null,
|
|
* runtimeMode?: string | null,
|
|
* skippedAppexes?: boolean,
|
|
* deployedAt?: string,
|
|
* }} input
|
|
* @returns {{
|
|
* schema: string, udid: string, buildId: string, name: string | null,
|
|
* identifier: string | null, commit: string | null, variant: string | null,
|
|
* runtimeMode: string | null, skippedAppexes: boolean, deployedAt: string,
|
|
* }}
|
|
*/
|
|
export function buildDeployRecord(input) {
|
|
const udid = input?.udid?.trim();
|
|
const buildId = input?.buildId?.trim();
|
|
if (!udid) throw new Error("buildDeployRecord: udid is required");
|
|
if (!buildId) throw new Error("buildDeployRecord: buildId is required");
|
|
return {
|
|
schema: DEPLOY_LEDGER_SCHEMA,
|
|
udid,
|
|
buildId,
|
|
name: input.name?.trim() || null,
|
|
identifier: input.identifier?.trim() || null,
|
|
commit: input.commit?.trim() || null,
|
|
variant: input.variant?.trim() || null,
|
|
runtimeMode: input.runtimeMode?.trim() || null,
|
|
skippedAppexes: input.skippedAppexes === true,
|
|
deployedAt: input.deployedAt?.trim() || new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a ledger JSONL string into records, skipping only blank lines. A line
|
|
* that fails to parse throws with its line number — a corrupt ledger is a real
|
|
* problem the operator must see, not silently dropped (that would let a status
|
|
* check read a truncated history as authoritative).
|
|
*
|
|
* @param {string} text
|
|
* @returns {Array<ReturnType<typeof buildDeployRecord>>}
|
|
*/
|
|
export function parseDeployLedger(text) {
|
|
const records = [];
|
|
const lines = String(text ?? "").split("\n");
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
try {
|
|
records.push(JSON.parse(line));
|
|
} catch (error) {
|
|
// error-policy:J3 untrusted-input — a corrupt ledger line is an explicit
|
|
// failure (not a fabricated-empty history); name the line for the operator.
|
|
throw new Error(
|
|
`parseDeployLedger: malformed JSONL at line ${i + 1}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
/**
|
|
* Read the ledger from disk. A missing file is an empty history (no deploys
|
|
* recorded yet) — that is a legitimate empty result, not a masked failure, so
|
|
* it returns `[]`. Any other read error propagates.
|
|
*
|
|
* @param {string} ledgerPath
|
|
* @returns {Array<ReturnType<typeof buildDeployRecord>>}
|
|
*/
|
|
export function readDeployLedger(ledgerPath) {
|
|
if (!fs.existsSync(ledgerPath)) return [];
|
|
return parseDeployLedger(fs.readFileSync(ledgerPath, "utf8"));
|
|
}
|
|
|
|
/**
|
|
* Append one record to the ledger, creating the state dir + file if absent.
|
|
* JSONL is append-only by construction — concurrent deploys from separate
|
|
* sessions each add their own line without a read-modify-write race.
|
|
*
|
|
* @param {string} ledgerPath
|
|
* @param {ReturnType<typeof buildDeployRecord>} record
|
|
* @returns {ReturnType<typeof buildDeployRecord>}
|
|
*/
|
|
export function appendDeployRecord(ledgerPath, record) {
|
|
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
fs.appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`);
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* Latest record per device UDID from a record list. JSONL is append-ordered, so
|
|
* the last row for a UDID is its current install; this collapses the history to
|
|
* one row per phone (what `devices:status` renders). Records missing a `udid`
|
|
* are dropped — they cannot be attributed to a device.
|
|
*
|
|
* @param {Array<ReturnType<typeof buildDeployRecord>>} records
|
|
* @returns {Map<string, ReturnType<typeof buildDeployRecord>>}
|
|
*/
|
|
export function latestDeployByUdid(records) {
|
|
const latest = new Map();
|
|
for (const record of records ?? []) {
|
|
const udid = record?.udid;
|
|
if (typeof udid !== "string" || !udid) continue;
|
|
latest.set(udid, record);
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
/**
|
|
* The honest status of one device against the deploy ledger. `known: false` is
|
|
* the "unknown — no ledger entry" path the design doc (Q7) requires: a device
|
|
* we never recorded a deploy for is reported as unknown, never guessed.
|
|
*
|
|
* @param {Array<ReturnType<typeof buildDeployRecord>>} records
|
|
* @param {string} udid
|
|
* @returns {{ known: false, udid: string, reason: string }
|
|
* | { known: true, udid: string, record: ReturnType<typeof buildDeployRecord> }}
|
|
*/
|
|
export function deployStatusForDevice(records, udid) {
|
|
const wanted = udid?.trim();
|
|
if (!wanted) throw new Error("deployStatusForDevice: udid is required");
|
|
const record = latestDeployByUdid(records).get(wanted);
|
|
if (!record) {
|
|
return {
|
|
known: false,
|
|
udid: wanted,
|
|
reason:
|
|
"unknown — no ledger entry (device was flashed by other means; " +
|
|
"cross-check with `ios:device:logs --pull-boot-trace`)",
|
|
};
|
|
}
|
|
return { known: true, udid: wanted, record };
|
|
}
|
|
|
|
/**
|
|
* Renderer-freshness verdict for a deploy: does the buildId the staged bundle
|
|
* carries match the freshly built dist? A device install of a stale renderer is
|
|
* the #9309 footgun (a cached dist copied over a fresh one); the deploy asserts
|
|
* on this before install so the phone never boots yesterday's UI.
|
|
*
|
|
* Both manifests are required — a missing/parse-less stamp is a broken pipeline,
|
|
* not a pass, so the caller throws when it cannot read one. This function is the
|
|
* pure comparison over the two already-read manifests.
|
|
*
|
|
* @param {{ buildId?: string | null, commit?: string | null } | null} staged
|
|
* @param {{ buildId?: string | null, commit?: string | null } | null} fresh
|
|
* @returns {{ fresh: boolean, stagedBuildId: string | null,
|
|
* freshBuildId: string | null, reason: string }}
|
|
*/
|
|
export function evaluateStagedRendererFreshness(staged, fresh) {
|
|
const stagedBuildId = staged?.buildId?.trim() || null;
|
|
const freshBuildId = fresh?.buildId?.trim() || null;
|
|
if (!stagedBuildId) {
|
|
return {
|
|
fresh: false,
|
|
stagedBuildId,
|
|
freshBuildId,
|
|
reason:
|
|
"staged App.app has no renderer buildId (public/eliza-renderer-build.json " +
|
|
"missing or unstamped) — rebuild the device lane before deploying",
|
|
};
|
|
}
|
|
if (!freshBuildId) {
|
|
return {
|
|
fresh: false,
|
|
stagedBuildId,
|
|
freshBuildId,
|
|
reason:
|
|
"freshly built dist has no renderer buildId (packages/app/dist/" +
|
|
"eliza-renderer-build.json missing) — run the ios-local build before deploying",
|
|
};
|
|
}
|
|
if (stagedBuildId !== freshBuildId) {
|
|
return {
|
|
fresh: false,
|
|
stagedBuildId,
|
|
freshBuildId,
|
|
reason:
|
|
`staged renderer buildId ${stagedBuildId.slice(0, 12)} != freshly built ` +
|
|
`${freshBuildId.slice(0, 12)} — the device would boot a STALE UI (issue #9309). ` +
|
|
"Rebuild without --skip-build, or re-stage from a matching dist.",
|
|
};
|
|
}
|
|
return {
|
|
fresh: true,
|
|
stagedBuildId,
|
|
freshBuildId,
|
|
reason: `renderer buildId ${stagedBuildId.slice(0, 12)} matches freshly built dist`,
|
|
};
|
|
}
|