Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/frontstage-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ on:
- "scripts/render-star-history.py"
- "scripts/fetch-github-stargazers.sh"
- "scripts/install-from-github.sh"
- "loopx/cli_commands/update_artifact.py"
push:
branches:
- main
Expand Down Expand Up @@ -91,6 +92,7 @@ on:
- "scripts/render-star-history.py"
- "scripts/fetch-github-stargazers.sh"
- "scripts/install-from-github.sh"
- "loopx/cli_commands/update_artifact.py"

permissions:
actions: read
Expand Down Expand Up @@ -193,6 +195,14 @@ jobs:
LOOPX_SITE_RESOLVE_DESKTOP_RELEASE: "1"
run: npm run export:frontstage-share -- --base /loopx/ --out-dir ../../../output/frontstage-pages

- name: Require stable runtime before publishing its bootstrap
if: github.event_name != 'pull_request'
run: |
python - <<'PY'
from loopx.cli_commands.update_artifact import resolve_artifact
resolve_artifact("loopx-project/loopx", "stable")
PY

- name: Fetch complete GitHub stargazer history
if: github.event_name != 'pull_request'
env:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ on:
- "scripts/verify_installed_chat.py"
- "README.md"
- "scripts/release_artifacts.py"
- "scripts/runtime_artifact.py"
- "scripts/qualify_runtime_artifact.py"
- "tsconfig.control-plane.json"
release:
types: [published]
Expand Down Expand Up @@ -390,6 +392,11 @@ jobs:
identity.write_text(json.dumps(record, indent=2) + "\n")
PY

- name: Build and qualify the stable snapshot runtime
run: |
python scripts/runtime_artifact.py --channel stable
python scripts/qualify_runtime_artifact.py dist/runtime > dist/runtime/qualification.json

- name: Attest release packages and checksum manifest
if: github.event_name != 'pull_request'
uses: actions/attest@v4
Expand All @@ -398,6 +405,7 @@ jobs:
dist/packages/*
dist/SHA256SUMS
dist/RELEASE-SOURCES.json
dist/runtime/*

- name: Upload validated release bundle
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -443,6 +451,7 @@ jobs:
dist/packages/* \
dist/SHA256SUMS \
dist/RELEASE-SOURCES.json \
dist/runtime/* \
--repo "${GITHUB_REPOSITORY}"

publish-pypi:
Expand Down
119 changes: 119 additions & 0 deletions .github/workflows/runtime-snapshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Runtime snapshots

on:
push:
branches: [main]
pull_request:
paths:
- ".github/workflows/runtime-snapshots.yml"
- "scripts/runtime_artifact.py"
- "scripts/qualify_runtime_artifact.py"
- "scripts/install*.sh"
- "loopx/cli_commands/update*.py"
- "loopx/cli_commands/support_control_update*.py"
- "loopx/self_update*.py"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: runtime-snapshot-${{ github.event_name == 'pull_request' && github.ref || 'main' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
if: github.repository == 'loopx-project/loopx'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Prepare test environment
run: python -m pip install -e '.[test]'
- name: Qualify update transport and ownership
run: python -m pytest -q tests/test_update_artifact.py tests/test_update_operation.py tests/test_self_update_runtime_activation.py tests/test_archive_installer_commit_response.py
- name: Build qualified frontend
run: python scripts/chat_bundle.py ensure
- name: Build immutable runtime and resource inventory
run: python scripts/runtime_artifact.py --channel main
- name: Qualify clean installation and upgrade
run: python scripts/qualify_runtime_artifact.py dist/runtime > dist/runtime/qualification.json
- name: Freeze revision-addressed metadata
shell: bash
run: |
set -euo pipefail
revision="$(python -c 'import json; print(json.load(open("dist/runtime/loopx-runtime.json"))["source_revision"])')"
cp dist/runtime/loopx-runtime.json "dist/runtime/loopx-runtime-${revision}.json"
mv dist/runtime/runtime-inventory.json "dist/runtime/runtime-inventory-${revision}.json"
mv dist/runtime/qualification.json "dist/runtime/qualification-${revision}.json"
- name: Attest exact runtime bytes and inventory
if: github.event_name != 'pull_request'
uses: actions/attest@v4
with:
subject-path: dist/runtime/*
- uses: actions/upload-artifact@v7
with:
name: loopx-runtime
path: dist/runtime
if-no-files-found: error

publish:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@v7
with:
name: loopx-runtime
path: runtime
- name: Publish immutable snapshot then channel pointer
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
revision="$(python -c 'import json; print(json.load(open("runtime/loopx-runtime.json"))["source_revision"])')"
# One dedicated channel release keeps developer snapshots out of
# product release history and bounded stable-predecessor discovery.
tag=runtime-main
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --target "$revision" --prerelease --title "Latest qualified main runtime" --notes "Mutable channel metadata; revision-addressed archives and manifests are immutable. Stable users continue with PyPI or the stable runtime."
fi
# A retry reuses immutable bytes. Never clobber an immutable asset.
mkdir -p existing
for file in runtime/*; do
name="$(basename "$file")"
if [[ "$name" == loopx-runtime.json ]]; then continue; fi
if gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir existing >/dev/null 2>&1; then
cmp "$file" "existing/$name"
else
gh release upload "$tag" "$file" --repo "$GITHUB_REPOSITORY"
fi
done
# Publication is serialized. A qualified ancestor is usable while a
# newer main is still building; never publish a non-main revision.
relation="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${revision}...main" --jq .status)"
if [[ "$relation" != ahead && "$relation" != identical ]]; then exit 1; fi
# Rerunning an older successful workflow may retain its immutable
# assets, but must not move the qualified main channel backwards.
current="$(gh api "repos/${GITHUB_REPOSITORY}/commits/runtime-main" --jq .sha)"
promotion="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${current}...${revision}" --jq .status)"
if [[ "$promotion" == behind ]]; then exit 0; fi
if [[ "$promotion" != ahead && "$promotion" != identical ]]; then exit 1; fi
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/runtime-main" -f sha="$revision" -F force=false >/dev/null
gh release upload runtime-main runtime/loopx-runtime.json --repo "$GITHUB_REPOSITORY" --clobber
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ChevronUp, Download, RefreshCw, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useWorkspaceI18n } from "./i18n";
import { observeTransfer, transferText, type TransferSample } from "./update-progress";

type Phase = "idle" | "runtime_required" | "runtime_pairing_required" | "connecting" | "service_error" | "checking" | "available" | "up_to_date" | "downloading" | "installing_app" | "installing_runtime" | "restart_required" | "ready" | "error";
type UpdateState = { phase: Phase; details?: { version?: string; channel?: string; received?: number; total?: number; code?: string; background?: boolean; prepared?: boolean } };
Expand Down Expand Up @@ -45,6 +46,8 @@ export function DesktopUpdate() {
const [rollbackAvailable, setRollbackAvailable] = useState(false);
const [runtimeSelection, setRuntimeSelection] = useState<RuntimeSelection>();
const busy = useRef(false);
const [transfer, setTransfer] = useState<TransferSample | null>(null);
const [now, setNow] = useState(Date.now);
const statusGeneration = useRef(0);
const selectedChannel = useRef<string | null>(null);
const invoke = (window as DesktopWindow).__TAURI__?.core.invoke;
Expand All @@ -53,6 +56,15 @@ export function DesktopUpdate() {
const repairAvailable = runtimeSelection?.explicit !== true && runtimeSelection?.bundled_repair_available !== false;
const restoreInPlace = state.phase === "error" && state.details?.code === "app_install_incomplete" && rollbackAvailable;

useEffect(() => {
setTransfer((previous) => observeTransfer(previous, state.phase, state.details?.received, Date.now()));
}, [state.phase, state.details?.received]);
useEffect(() => {
if (!observing) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [observing]);

function readStatus(value: UpdateStatus, includeState = true) {
setVersion(value.app_version);
setPlatform(value.platform ?? "");
Expand Down Expand Up @@ -195,6 +207,7 @@ export function DesktopUpdate() {
{state.details?.version ? <p>{zh ? "目标版本:" : "Target: "}{state.details.version}</p> : null}
<p role="status" aria-live="polite">{observing ? <RefreshCw className="is-spinning" size={14} aria-hidden="true" /> : null}{message[state.phase]}</p>
{state.phase === "downloading" && state.details?.total ? <progress aria-label={zh ? "下载进度" : "Download progress"} max={state.details.total} value={state.details.received ?? 0} /> : null}
{state.phase === "downloading" && transfer ? <p className="personal-update-transfer" role="status">{transferText(transfer, state.details?.total, now, zh)}</p> : null}
{invoke ? <div className="personal-update-actions">
{state.phase === "restart_required" ? <button type="button" onClick={() => void run("restart")}>{zh ? "重启完成更新" : "Restart to finish"}</button> : <>
{restoreInPlace ? <button type="button" onClick={() => void run("rollback")}>{zh ? "恢复上个版本" : "Restore previous version"}</button> : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Presentation-only observations. The updater remains the authority for bytes,
// phase and outcome; missing totals never become a fabricated percentage.
export type TransferSample = {
phase: string; received: number | null; startedAt: number; changedAt: number;
sampledAt: number; bytesPerSecond: number | null;
};

export function observeTransfer(previous: TransferSample | null, phase: string, bytes: number | undefined, now: number): TransferSample {
const received = typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
if (!previous || previous.phase !== phase || (received !== null && previous.received !== null && received < previous.received)) {
return { phase, received, startedAt: now, changedAt: now, sampledAt: now, bytesPerSecond: null };
}
if (received === previous.received) return previous;
const elapsed = (now - previous.sampledAt) / 1000;
return { ...previous, received, sampledAt: now, changedAt: now,
bytesPerSecond: received !== null && previous.received !== null && elapsed > 0 ? (received - previous.received) / elapsed : null };
}

export function transferText(sample: TransferSample, total: number | undefined, now: number, zh: boolean): string {
const amount = sample.received === null ? (zh ? "等待下载数据" : "Waiting for download data") : `${(sample.received / 1048576).toFixed(1)} MiB`;
const length = typeof total === "number" && total > 0 ? `${(total / 1048576).toFixed(1)} MiB` : (zh ? "总量未知" : "size unknown");
const rate = sample.bytesPerSecond === null ? "" : ` · ${((now - sample.changedAt >= 5_000 ? 0 : sample.bytesPerSecond) / 1024).toFixed(0)} KiB/s`;
const elapsed = Math.max(0, Math.floor((now - sample.startedAt) / 1000));
const waiting = now - sample.changedAt >= 10_000 ? (zh ? " · 10 秒内无新进展" : " · no new progress for 10s") : "";
return `${amount} / ${length}${rate} · ${elapsed}s${waiting}`;
}
100 changes: 99 additions & 1 deletion docs/guides/installing-loopx.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,110 @@ Choose one installation owner and keep it authoritative:
| Isolated CLI on an externally managed machine | `pipx` | `pipx install loopx` | `pipx upgrade loopx`, then refresh LoopX host material |
| Existing uv tool environment | `uv` | `uv tool install loopx` | `uv tool upgrade loopx`, then refresh LoopX host material |
| Contributor or source qualification | Git checkout | clone/fetch plus `scripts/install-local.sh` | update the checkout explicitly, rerun the installer, validate `loopx-canary` before promotion |
| No-clone recovery fallback | LoopX archive snapshot | published archive installer | `loopx update apply` |
| No-clone CLI or main preview | LoopX runtime snapshot | published installer with a prebuilt runtime | `loopx update apply` |

PyPI is the canonical source for normal releases. A source checkout is a
development and qualification surface, not a second implicit package channel.
The archive snapshot remains a recovery path rather than a competing default.

### Stable, main and source builds

`loopx update apply` and `loopx upgrade` preserve the installation owner. pip
and pipx continue to use their own environment and configured package index;
they do not become archive installs. Stable PyPI wheels already contain Chat's
frontend. Node is needed for the TypeScript runtime described below, but npm
and a local frontend build are not part of a normal package upgrade.

Managed snapshots inherit their recorded repo and ref, including a pinned SHA.
New snapshots default to stable. Stable snapshots use the runtime attached to
the latest stable release; main uses the latest **qualified** main snapshot,
which can lag the branch while CI runs. The plan records its exact revision,
version, size and SHA-256 before downloading. Missing metadata or a missing
artifact stops the update without starting a source build. An older, diverged
or unverified target requires explicit `--allow-downgrade` intent.

```bash
loopx update plan
loopx upgrade --ref main # snapshot installs only
loopx update apply --ref stable --allow-downgrade
loopx upgrade --ref main --source # explicit source download + local build
```

To acquire a main snapshot without a checkout, use the published installer
with `LOOPX_REF=main`. To build another ref, set
`LOOPX_BUILD_FROM_SOURCE=1 LOOPX_REF=<ref>` before running that installer, or
use the existing checkout installer. `LOOPX_ARCHIVE_URL` remains an explicit
developer archive path. These choices never silently replace a pip/pipx or
live-checkout owner. App updates continue through the native signed updater;
a CLI-only installation does not install a Desktop App.

Updates run in an owned background worker even when the caller waits for the
result. A terminal/Agent disconnect does not cancel it. Repeating apply while
that installation's update is running observes the existing operation. To
return immediately and read back later:

```bash
loopx --format json upgrade --background
loopx --format json update status
loopx --format json update status --operation-id <returned-id>
```

Windows console launchers retain their existing self-uninstall protection in
the original process. Follow the printed `python -m loopx.entrypoint` retry
to update safely and use the durable worker; a worker never hides a locked
console executable from that guard.

JSON stdout contains the final result (or background operation descriptor).
Progress is NDJSON on stderr: operation id, phase, downloaded bytes, total
(`null` when unknown), observed rate, elapsed time and last progress time.
The worker refreshes observation every five seconds and marks ten seconds
without progress explicitly; a heartbeat is not a new byte or completed stage.
Native App downloads show bytes, unknown totals, rate and stalled observation
in **LoopX updates**, with existing check/install/recovery actions unchanged.

Runtime downloads cache hash-verified bytes across invocations. Partial data
is appended only with a matching strong ETag, origin, exact Content-Range and
length; ignored/rejected Range restarts within the bounded budget. A complete
cache hit is verified again before use. `LOOPX_UPDATE_CACHE` selects the cache;
`LOOPX_UPDATE_STATE_DIR` selects private operation readback. Failed or killed
workers remain failed/interrupted, and status reads never replay installation.
Normal HTTP(S) proxy environment variables remain authoritative. Optional
`LOOPX_DOWNLOAD_MIRRORS` is a comma-separated list of operator-trusted HTTPS
base URLs serving identical filenames; publisher-listed sources are also
supported. Timeouts or sustained low throughput can switch sources, always
with the same expected revision/hash/size. This is not a claim that regional
mirrors have been deployed or that any country's latency has been measured.

The prebuilt runtime retains CLI/runtime code, skills and references, text
documentation, developer smokes and the verified frontend. Named presentation
videos/screenshots and frontend development sources are in the accompanying
optional resource archive; illustrations remain available in versioned online
docs. The release inventory lists every retained
and optional file. Installers validate a candidate before activation and restore
owned executable links on activation failure. Data-format migrations retain
their independent receipts/backups; restoring an executable does not reverse
migrated data. Existing managed-service ownership and pending-work restart
guards still apply.

中文:普通 stable 用户继续使用 PyPI;pip/pipx 保留原环境和索引。snapshot 沿用
既有 main/stable/固定 SHA,默认取带前端的预构建包。main 指最新通过 CI 的快照;
源码构建须显式 `--source`,不会静默降级到整仓下载。旧版本或无法核实的目标须
明确 `--allow-downgrade`。`--background` 返回 operation id,断线后用
`update status` 回读原升级;JSON 结果在 stdout,持续进度在 stderr。下载缓存、
续传和可选可信镜像都校验同一制品;代理沿用用户配置。恢复旧可执行文件不回滚
已迁移的数据。无 Node 时无需构建前端,但运行控制平面仍需下面要求的 Node。

Release rollout: publish and qualify the stable runtime before deploying the
new public bootstrap. The Pages lane checks this dependency and retains the
previous deployed site if it is unavailable. `runtime-snapshots.yml` builds
main artifacts; `release-artifacts.yml` reuses the release frontend/wheel lane
for stable. Main archives and revision-addressed metadata share one dedicated
`runtime-main` release, keeping product release history and stable-predecessor
discovery intact; only the latest-channel metadata is replaced. Clean
install/upgrade, package contents, byte size, provenance and
phase timings are release evidence. A 20 MiB runtime is an evaluation target,
not a waived dependency check or a global p50/p95 promise.

LoopX's Effect Program core runs in a managed, idle-exiting TypeScript runtime
and requires Node.js 22.22.3 or later; Node.js 24 LTS is the recommended primary
runtime. LoopX starts and reuses that local runtime automatically; users do not
Expand Down
Loading
Loading