Skip to content

feat(cli): os deploy — the managed-package dev loop in one command; installs pick the only environment, keep it across login, and refuse before any write - #30933

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-30871-deploy-dev-loop
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-30871-deploy-dev-loop

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Refs #30871
Clause-②: no
Metadata-protocol: none

The wait step reads the served-version contract objectstack-ai/cloud#3662 landed for objectstack-ai/cloud#3651 (data.served, live on cloud main). Its shape is confined to packages/cli/src/utils/served-version.ts.

维护者速读

  • 改了什么:新增 os deploy:不带任何参数就是完整开发循环——编译、发布 MANIFEST_VERSION-dev.TIMESTAMP、装进组织唯一的环境、等到环境真正换成新版本。os dev 起来后按 d 就部署(没登录先登录),保存文件不会部署;os build 之后提示下一步,裸跑 os 先列日常命令;脚手架带 npm run deploy。os package publish --install 也改为自动选唯一环境、找不到环境时在写任何东西之前非零退出;os cloud login 不再清掉已记下的环境。
  • 为什么改:维护者要求先把受管软件包的开发循环走顺,并且「开发人员也记不住」参数,所以把五道坎(手动 build、手改版本号、手填环境、--install 失败仍算成功、装完不知道是否生效)收进一个命令的默认行为里。
  • 风险与回滚:os package publish --install 在没有可用环境时从「跳过安装、退出 0」变成「什么都不发布、退出 1」,依赖旧行为的脚本会变红;「等待生效」读取 cloud#3662(已合并)的 data.served;旧版 cloud 不返回该字段时命令会明说无法确认、不会假装已生效。等待期间每轮会向环境自身发一个请求以触发重建(环境按请求才重建)。回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/cli/src/commands/deploy.ts(整体顺序:哪些拒绝发生在写之前)、packages/cli/src/utils/served-version.ts(interpretServedReport:live / failed / pending / 被覆盖 的判定)、packages/cli/src/commands/package/publish.ts(Step 0 前移到第一次写之前)。
  • 两分钟验证:pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/deploy.test.ts test/publish-active-environment-store.test.ts,预期全部通过,其中「several environments … before any write」一项证明拒绝时没有任何 POST。
  • 需要你做的:无,席位落地。

What changes

Command name: os deploy (not os push). It matches the scaffold script the amendment names (npm run deploy), it is the verb developers already know for "put this on the server and make it live" (vercel deploy, fly deploy, wrangler deploy), and push reads as git push — uploading source, not installing and waiting. No deploy id existed; the retired os publish id stays retired.

Usage:

os cloud login          # once per machine
os deploy               # build → publish MANIFEST_VERSION-dev.YYYYMMDDHHMMSS → install into the only environment → wait until served
os deploy --env ID    # an account that reaches several environments
os deploy --no-wait     # return once the install is recorded

Flags: --env ($OS_ENVIRONMENT_ID), --no-wait, --wait-timeout SECONDS (default 600), -s/--server ($OS_CLOUD_URL), -t/--token ($OS_CLOUD_API_KEY).

Card items 1–3, applied to every command that installs into a cloud environment (os package publish --install and os deploy; os package install targets a runtime URL and names no environment, so it has nothing to resolve):

  1. Pick the only environment — utils/install-target.ts, one resolver for both: --env / $OS_ENVIRONMENT_ID, then the active environment cloud.json records for this control plane (the existing gate in active-environment.ts, not duplicated), then GET /api/v1/cloud/environments (the control plane already filters it to the caller's organizations — cloud environment-crud/environments.ts, authz row caller-own-org). Exactly one installable row (not archived, not is_system) → used and printed.
  2. Keep the active environment across os cloud login — mergeCloudLogin keeps activeEnvironmentId when the previous file names the same control plane and the same account (user ids agree, or one side recorded none); otherwise dropped as before.
  3. Refuse before any write — none, several (each listed), or an unreadable list → exit 1 before the package POST, naming --env and os environments switch. publish.ts resolves the target as a new Step 0, ahead of Step 1.

Amendment items:

  1. os deploy (commands/deploy.ts). Credential and environment resolve first, then the build (a child os compile, as every other compile in this CLI; a failed build prints its errors, exits 1, nothing published), then the version (utils/dev-version.ts: UTC timestamp; an existing pre-release gains .dev.TS; build metadata dropped; non-semver refused), then os package publish itself run in-process with that version and --install --env RESOLVED (so manifest-id, namespace and objectstack.manifest.json rules stay in one place; it now records a PublishOutcome), then the wait.
  2. os dev deploy key (utils/dev-deploy-key.ts): attached when the first server listens, only on a TTY. d runs a child os deploy (the terminal is handed back while it runs so a login prompt works; Ctrl-C, which raw mode swallows, is forwarded as SIGINT). Not logged in → the line offers login, and d runs os cloud login first. The child does not inherit OS_ENVIRONMENT_ID: under os dev that variable is the LOCAL environment id, and os deploy reads the same name as its cloud --env. No save hook (cloud ADR-0024).
  3. create-objectstack's blank template ships "deploy": "objectstack deploy"; so does os init's app template (the only one with dev).
  4. os build ends with Next: os deploy … (os compile, which os dev runs on every rebuild, does not); bare os / os --help prints an EVERYDAY COMMANDS block (dev, build, deploy, cloud login, validate) from a help class (src/help.ts, oclif.helpClass).

The wait — the served-version contract (cloud#3651, declared in cloud#3662)

One client function, waitUntilServed, with the shape confined to fetchServedReport (request) and interpretServedReport (reading), as relayed by the seat:

  • Read: GET /api/v1/cloud/environments/{environmentId}/packages/{manifestId} with the publish credential → data.served { state: live|pending|failed, desiredVersionId, versionId, version, observedStatus, observedAt, error }. ⛔ Not GET …/installations/:packageId (old shape, no served) — a test asserts the wait never calls it.
  • live → exit 0 (observedStatus: incomplete adds a plan warning); failed → prints error, exit 1; pending → keep polling.
  • desiredVersionId ≠ the publish answer's id → another install replaced this deploy: said so, exit 1, never "Live".
  • Builds are request-driven: before every poll the wait sends one GET {environment origin}/api/v1/discovery (origin from data.environment.hostname on GET /api/v1/cloud/environments/{id}; /api/v1/discovery is kernel-served, unlike /api/v1/health). The answer (a retryable 503 SERVICE_UNAVAILABLE while it builds) is ignored. No hostname → a warning that the rebuild waits for real traffic, and the wait continues.
  • Failures surface late (three failed rounds in a row, or a build timeout): default --wait-timeout is 600 s, and the timeout message says a failing build can read as still building for minutes.
  • Timeout → names the environment, package, version and the last report, exit 1. A 4xx other than 429 on the read → stop at once, exit 1.
  • An answer with no served (a control plane before flaky: lazy-deps 的两条 dist 探针用例在整仓并发下撞 5s 默认超时(兄弟用例已设 30s) #3662) → says it cannot confirm liveness, exit 0; never spins to the timeout, never prints "Live".
  • The install names the exact version just published (version + install_env_id in the same POST …/versions), so feat(analytics,spec): executeAggregate 桥携带 ExecutionContext —— ADR-0021 D-C 第二层带子 (#3602) #3651's new 409 for a version-less install of a pre-release-only package does not apply.

Docs

content/docs/deployment/cli.mdx: new os deploy section, the d key under os dev, os cloud login keeping the environment, and the publish --env / --install rows and install-target paragraph.

Tests

All local readings are on a shared container; verdict lines quoted from the verify lock.

Unit and package suites
  • Full packages/cli unit tier, vitest run --project unit --maxWorkers=2, at 4d010654: Test Files 1 failed | 392 passed (393), Tests 1 failed | 5477 passed (5478). The one red was test/validate-build-gate-parity.test.ts ("the command sources use no call shape this scan cannot read"): compile.ts imports BUILD_NEXT_STEP as a value it never calls. Fixed in 8df29def74 by classifying it in that file's not-a-gate ledger (a fixed line, reads and judges nothing); the file then ran 33/33.
  • 7664160b merged origin/main (4 commits, none on a packages/cli, create-objectstack or cli.mdx path); rebuilt what they touched in the CLI closure (spec, plugin-auth, service-automation, plugin-approvals) and cli — lock VERDICT command-exit 0. There: pnpm --filter create-objectstack test → Test Files 24 passed (24), Tests 370 passed (370) (typecheck exit 0 at 4d010654).
  • At the final head b7ea5f21 (the served-version contract wiring), lock VERDICT command-exit 0:
    • pnpm --filter @objectstack/cli build → exit 0; pnpm --filter @objectstack/cli typecheck → exit 0 (check:test-typecheck: OK).
    • 13 targeted files (the new ones, every package-publish-* file, publish-active-environment-store, init, validate-build-gate-parity, child-env-source-loader.pin) → Test Files 13 passed (13), Tests 210 passed (210).
  • The full unit tier was not re-run after 4d010654; what changed since is the gate-parity classification, the merge (no overlap) and the wait wiring, each covered by the targeted files above. NOT MEASURED at b7ea5f21: full packages/cli unit tier, reason: shared lock queue (~10–25 min per acquisition); CI's Test Core runs it.
  • The integration tier of packages/cli is declared to CI: the diff touches no integration-tier file, no bin/ entry and no test/helpers/serve-process.ts.
What each acceptance line is pinned by
Acceptance line Test
one-environment pick (publish) publish-active-environment-store.test.ts › "installs into the only environment and says which, skipping archived and system rows"
several / none / unreadable refused, exit 1, before any write same file › three "refuses … before any write" cases (asserts zero non-GET requests)
os cloud login keeps activeEnvironmentId cloud-login-keeps-active-environment.test.ts (same plane + account kept; other plane / other account dropped)
deploy: dev version from a fixed manifest.version deploy.test.ts › "builds, publishes MANIFEST_VERSION-dev.UTC_TIMESTAMP …" and "deploys twice in a row … each time a new version, with no flag"
deploy: only environment picked; waits; success on served version deploy.test.ts (stubbed control plane: reads data.served, wakes the environment, never the installed-state probe) and waitUntilServed › "polls through pending, waking the environment before every poll, until it is live"
deploy: build failure → reason, non-zero deploy.test.ts › "prints the environment's build-failure reason and exits 1"; local build failure case too
deploy: timeout names what it waited for waitUntilServed › "times out naming what it last saw, and reportVerdict names what it waited for and why failures read late"
a deploy replaced by another install is not called live deploy.test.ts › "reports a deploy another install replaced …"; interpretServedReport superseded case
os dev: deploy key runs the deploy path; no login → offers login dev-deploy-key.test.ts
scaffold package.json has deploy create-objectstack/src/template-consistency.test.ts › "bundled templates ship a deploy script"; test/init.test.ts › "app scaffold ships a deploy script"
os build next step; bare os everyday first next-steps.test.ts
Ablations — one per behaviour, through scripts/ablation-replace.mjs (WRAP mode), at b7ea5f2

Each mutation was proven on disk by the tool (anchor 1 → 0, blob changed), the named test then ran RED, and the restore was proven by the blob at HEAD (git diff HEAD and git status --porcelain empty after all twelve). The tests import src/ directly (no dist/ on the resolution path), so no rebuild leg applies.

# Mutation Red
A1 install-target.ts: candidates.length === 1 → === -1 "installs into the only environment" — 1 failed
A2 publish.ts Step 0: refusal branch disabled the three "before any write" cases — 3 failed
A3 login.ts mergeCloudLogin returns next "keeps activeEnvironmentId across a re-login" — 1 failed
A4 dev-version.ts returns the bare core version "deploys twice in a row" — 1 failed
A5 served-version.ts: the state === 'live' branch disabled "… reports it live" — 1 failed
A6 served-version.ts: the state === 'failed' branch disabled "build-failure reason" — 1 failed
A11 served-version.ts: the per-poll wake deleted "polls through pending, waking the environment before every poll …" — 1 failed
A12 served-version.ts: the superseded check disabled "reports a deploy another install replaced …" — 1 failed
A7 dev-deploy-key.ts: runDeploy() not called "runs the deploy path on d" — 1 failed
A8 dev-deploy-key.ts: login branch disabled "d logs in, then deploys" — 1 failed
A9 compile.ts: next-step print disabled "os build ends with the deploy hint" — 1 failed
A10 help.ts: formatRoot returns oclif's alone "root help page carries the everyday block" — 1 failed

Scaffold package.json scripts are configuration, not ablated.

Gates

Derived families, local, at b7ea5f2

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (tree objectstack-ai/objectstack at b7ea5f210d, change set vs merge base): 112 commands, each run with its exit code captured before any pipe → 112 × exit 0. --ran reconciliation: 112 derived famil(ies) accounted for — 112 run, 0 NOT-MEASURED (a DERIVED zero — all 112 recorded an exit code and none of them is 3).

The first run (at 08361b30) caught one real finding of this diff: check:system-context-census read a summary field named isSystem as a system-context elevation read. Renamed to infrastructure (it mirrors the row's is_system); census OK.

Every family CI runs (Lint & Repo Gates, TypeScript Type Check, Test Core, …) is read from this PR's check-runs, not re-run locally.

Acceptance notes

  • The first CI run (b7ea5f2) went red on check:error-code-casing: the deploy test's stub answered a lowercase wire code. It now answers SERVICE_UNAVAILABLE, the framework's envelope for a warming kernel. The family sits in dispatch-gates' artifact-roster residue, outside the --commands total, and is now run locally from --residue.
  • Owed on cloud staging (after cloud#3662 is deployed there): from a fresh create-objectstack scaffold with a fixed manifest.version, npm run dev, press d, edit a file, press d again. Both deploy with no flag, and the environment serves the second build (Live: names VERSION-dev.SECOND_TIMESTAMP). The card body's os package publish --dev --install staging line is superseded by the amendment.
  • Behaviour change on os package publish --install: with no resolvable environment it now exits 1 before publishing anything (it used to print "Skipping auto-install", publish, and exit 0). Declared in the changeset. os package publish otherwise keeps its meaning: a release version, --install optional, no wait.
  • The wake path is GET {origin}/api/v1/discovery, origin from data.environment.hostname (the seat confirmed it). /api/v1/health is the container liveness probe and answers without a kernel.
  • os dev's line says "your ObjectOS Cloud environment" rather than naming it: naming it would cost a control-plane call on every dev boot. os deploy names the environment when the key runs it.
  • test/next-steps.test.ts runs os build in-process once (about 8.8 s of test time on this shared box, the first load of the compile path); it declares no budget. If CI's shard timings put it over 10 s, that is this lane's card.
  • The packages/cli integration tier is declared to CI (no integration-tier file, bin/ entry or serve-process.ts touched).
  • Not this PR: the amendment's item 5 (the skill). The seat files it as a domain:skills card with the name and usage above.

Verification

pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/deploy.test.ts test/dev-deploy-key.test.ts test/publish-active-environment-store.test.ts test/cloud-login-keeps-active-environment.test.ts test/next-steps.test.ts
pnpm --filter create-objectstack test

Generated by Claude Code

@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 83 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

76 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4d56914aaa8c2cf388986309364eef23eea10282.

⛔ 19 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/package.json) — pages documenting those are invisible to this run
  • 2 cross-cutting symbol(s) contributed no route anchor: environmentId (42 routes), packageId (4 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: os build (command, 63 pages), os dev (command, 43 pages)
  • 18 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4d56914aaa8c2cf388986309364eef23eea10282 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4eb56e40abd3037f290a85e9858cc7df19ede4d0 — the merge of head c7aab69d877255d0a9b9aa34b641356e2c09f082 into base 4d56914aaa8c2cf388986309364eef23eea10282, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4eb56e40abd3037f290a85e9858cc7df19ede4d0 && git checkout 4eb56e40abd3037f290a85e9858cc7df19ede4d0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d56914aaa8c2cf388986309364eef23eea10282 c7aab69d877255d0a9b9aa34b641356e2c09f082 && git checkout -B drift-repro 4d56914aaa8c2cf388986309364eef23eea10282 && git merge --no-ff c7aab69d877255d0a9b9aa34b641356e2c09f082

node scripts/docs-audit/affected-docs.mjs --json 4d56914aaa8c2cf388986309364eef23eea10282

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4d56914aaa8c2cf388986309364eef23eea10282 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

席位核对 · ACCEPT · 2026-11-06T14:08Z

Head-sha: c7aab69d
Implemented-by: os-dev (session session_01Q4EjjRhUZjdy8VXs6Kkur2, branch claude/issue-30871-deploy-dev-loop) · Reviewed-by: repo:cloud#3(专题 objectstack-ai/cloud#3652 负责席)

  • 形态: 新增 os deploy。不带参数时依次执行:编译 → 发布 <manifest.version>-dev.<UTC时间戳> → 装进组织唯一的环境 → 轮询 data.served 直到生效。另外:

    • os dev 里按 d 部署,没登录时先引导登录;
    • 脚手架带 npm run deploy;
    • os build 结束后提示下一步,单独运行 os 时先列出日常命令;
    • 卡上第 1–3 项(自动选唯一环境、os cloud login 后保留当前环境、找不到环境就在写入前失败退出)对所有会安装的命令生效。

    Clause-②: no(只是 CLI 命令和参数),不欠契约复核。

  • 裁决: 维护者的「同意」(追加范围 6680520228,替换了原第 4–6 项)。席位对开发两个问题的回答:唤醒请求用 /api/v1/discovery;部署中途被别人的安装覆盖时,报「已被覆盖」并以失败退出。

  • 依赖: cloud#3662 已合并,data.served 已在 cloud main 上线。

  • 返工: 第一版测试桩用了小写错误码,被 check:error-code-casing 拦下。现改为框架对「内核预热中」的标准应答 503 SERVICE_UNAVAILABLE。另外查明,这项检查不在 --commands 的总数里,以后要按 --residue 补跑。

  • 门禁 check-runs(head c7aab69d):41 项 success,13 项 skipped,0 失败。本地跑了 112 项派生门禁加 3 项 residue,全部 exit 0;cli 单测 393 个文件全绿。

  • 落地条款: 非受治理面,CI 绿 ⇒ 席位翻 ready、挂 auto-merge,经合并队列落地,Refs #30871。合并后由席位关卡,并另立 domain:skills 卡,在 skill 里教 os deploy(追加范围第 5 项)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant