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
35 changes: 33 additions & 2 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ provider contact. A refusal names no option value, path or project detail.
| --- | --- | --- |
| `up` | Whole-project foreground; `--branch`, `--profile` and `--env` (including `--env base`) keep their compiler contracts | `--detach`, `--json`, `--recover`, service subsets, single-service, command, workdir, follow, tail and format options, other unsupported options |
| `down` | Whole-project owner-mediated stop of a ready generation through its live foreground owner or private no-host endpoint | `--json`, `--detach`, `--profile` and `--env` overrides, service subsets including an empty selection, single-service, command, workdir, follow, tail and format options |
| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options |
| `down --recover` | Explicit retirement of a complete stored generation whose owners are dead, or of a pending startup intent that never reserved runtime state; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options |
| `ps` | `ps` and `ps --json` for the exact saved project and explicit `--branch` | `--profile`, `--env`, service subsets or a single service, `--recover`, `--detach`, command, workdir, follow, tail and format options |
| `logs` | `logs SERVICE --no-follow` with `--tail` from 1 through 1000 (default 200), plain or `--json` | Follow mode (the default), omitted, multiple or malformed service selection, non-integer or out-of-range `--tail`, pretty format, time and query filters, `--profile`, `--env`, `--recover`, `--detach`, command and workdir options |
| `exec` | `exec SERVICE -- COMMAND ARG...` with closed stdin, no TTY and an optional absolute `--workdir` | Empty command or empty first argument, NUL bytes, more than 256 arguments, an argument over 16 KiB or 64 KiB in total, relative `--workdir`, interactive or TTY input, `--json`, `--profile`, `--env`, service subsets, follow, tail and format options, `--recover`, `--detach` |
Expand All @@ -90,6 +90,21 @@ inspection also refuses, the captured native startup code takes precedence over
the inspection code. That preserves the failure's owning boundary; it does not
prove cleanup. Retained bindings and recovery requirements stay in force.
These diagnostics do not grant retry or cleanup authority.

A native consumer can refuse after the startup intent is reserved but before it
reserves any runtime journal. For example, a `repository@sha256:` image reaches
execution and refuses `native_graph_admission`. Such a run has no receipt, so the
Removed retirement inspection can never succeed. After the consumer process has
settled, startup asks the native runtime to inspect the exact run. Startup retires
the startup intent and its source envelope only when the runtime refuses
`native_graph_unreserved`: the run's journal directory is exactly absent under
private state directories, and no foreground owner is published for the run. The
failure then reports outcome `unreserved` with the original native code, and `ps`
reports `not_started`. Any other inspection result keeps the attempt retained.
Examples include `native_graph_receipt` for damaged or foreign evidence,
`native_graph_foreground` for a published owner, and a stopped runtime. This
applies only to attempts without lifecycle hooks, host processes or a live-stop
owner. A canceled `unreserved` startup exits 130, like confirmed removal.
Readiness requires authenticated current observations and unchanged input before
publication. The foreground owner remains attached until owned shutdown finishes;
Ctrl-C requests that shutdown. Exact durable Removed evidence retires the tagged
Expand All @@ -107,7 +122,10 @@ Only fresh runtime observations supply state and health. Stored readiness and ph
do not prove that a service is still live.

A project with no saved attempt reports `not_started`; a retained startup intent
without a Ready mapping reports `pending` with no confirmed service rows. When a
without a Ready mapping reports `pending` with no confirmed service rows. That
state covers a startup in progress and a retained incomplete attempt. When no
startup is running, `down --recover` can retire the attempt if it never reserved
runtime state (see below). When a
Ready generation is saved but the candidate runtime is stopped (native code
`runtime_not_running`, or the authenticated `native_graph_foreground` control code
with that exact cause), `ps` exits 0 and reports `runtime_not_running` with the
Expand Down Expand Up @@ -184,6 +202,19 @@ nor acquires managed values or starts a provider.
Omit `--profile` to recover the stored generation; explicit empty or named profile
overrides both refuse.

When the selection has a retained startup intent but no Ready mapping and no
recovery intent, `down --recover` takes a different path. It handles only an
incomplete startup that never reserved runtime state. One example is an attempt
retained by an earlier CLI before this retirement existed. The command first
takes a fresh startup admission. A live or interrupted foreground CLI still holds
that admission, so the command refuses as busy in that case. It also requires that
no Ready mapping, lifecycle-hook owner or live-stop owner exists. It then inspects
the exact run. It retires the start intent and the validated source envelope only
when the native runtime refuses `native_graph_unreserved`. It removes the source
first, so an interrupted retirement keeps the start intent blocking. Any receipt,
any other refusal, or a successful inspection retains every file. This path does
not compile input, acquire values, serve a run or touch guest resources.

The closed version 3 publication and version 1 recovery selector remain supported
with their original calendar boot qualifier; clock drift conservatively refuses
that legacy recovery. New version 4 owners use version 2 UUID selectors. Missing,
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,11 @@ ordinary frontend selection yet and does not implement reactive health or hooks.
The optional feature exposes this bounded consumer through a distinct public CLI:
`graph native plan --source-file FILE --json`, then
`graph native run --source-file FILE --expect-review SHA --json`.
`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. These commands
`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. On macOS,
`inspect` refuses `native_graph_unreserved` before guest access only when the run's journal
directory is exactly absent under private state directories and no foreground owner is
published for the run. It is a point-in-time observation, not proof that no consumer can
still start. A partial, public or present journal keeps the ordinary receipt refusal. These commands
are explicit bounded prerequisites, not full ordinary project parity. Their project
network does not publish ports or grant host-service access. Normal native `hack up`
continues to require the full source/storage/routing/host and foreground-owner contract.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,47 @@ fn abandoned_publication_is_evidence_and_never_implicit_recovery_authority() {
assert!(fixture.owner_root().join("owner.json").exists());
}

#[test]
fn inspect_names_only_an_exactly_absent_unpublished_journal_as_unreserved() {
// HACK-1272: a startup refused before reservation must be distinguishable from
// damaged or foreign receipt evidence, without weakening either refusal.
let fixture = Fixture::new();
let code = |fixture: &Fixture| inspect(&fixture.candidate, RUN).unwrap_err().code;
assert_eq!(code(&fixture), "native_graph_unreserved");
assert!(
!journal::directory(&fixture.candidate, RUN)
.unwrap()
.exists()
);

// A published (live or abandoned) owner is evidence, never "unreserved".
let receipt = fixture.receipt();
let mut publication = owner::Publication::bind(&fixture.candidate, &receipt.review).unwrap();
assert_eq!(code(&fixture), "native_graph_foreground");
publication.finish().unwrap();
assert_eq!(code(&fixture), "native_graph_unreserved");

// A partial journal directory keeps the ordinary receipt refusal.
let root = journal::directory(&fixture.candidate, RUN).unwrap();
fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(&root)
.unwrap();
assert_ne!(code(&fixture), "native_graph_unreserved");
fs::remove_dir(&root).unwrap();

// A public ancestor refuses instead of proving absence.
let parent = root.parent().unwrap();
fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(code(&fixture), "native_graph_receipt");
fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).unwrap();

// A reserved journal is validated as before.
journal::reserve(&fixture.candidate, &receipt).unwrap();
assert_ne!(code(&fixture), "native_graph_unreserved");
}

#[test]
fn direct_operation_guard_refuses_replaced_lock_path() {
let fixture = Fixture::new();
Expand Down
34 changes: 34 additions & 0 deletions packages/runtime-core/src/provider/graph/native/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,40 @@ fn valid_network_name(value: &str) -> bool {
|| matches!(*byte, b'-' | b'_' | b'.')
})
}
/// Stable refusal for a run that never reserved native runtime state.
#[cfg(target_os = "macos")]
pub(super) fn unreserved() -> CandidateError {
error(
"native_graph_unreserved",
"Native runtime state for this run was never reserved: its journal is absent and no foreground owner is published. This point-in-time observation does not prove that no consumer can still start; no state was created, repaired or adopted.",
)
}
/// True only when this run's journal directory is exactly absent beneath private
/// existing ancestors. A present, partial or unreadable path returns false so the
/// ordinary receipt validation still owns it. Read-only; nothing is created.
#[cfg(target_os = "macos")]
pub(super) fn absent(candidate: &Candidate, run: &str) -> Result<bool, CandidateError> {
let root = directory(candidate, run)?;
match fs::symlink_metadata(&root) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Ok(false),
}
for path in root
.ancestors()
.skip(1)
.take_while(|path| path.starts_with(&candidate.state_root))
{
match fs::symlink_metadata(path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Ok(_) => state::check_private_directory(path).map_err(|_| refused())?,
Err(_) => return Err(refused()),
}
}
Ok(
fs::symlink_metadata(&root)
.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound),
)
}
pub(super) fn directory(candidate: &Candidate, run: &str) -> Result<PathBuf, CandidateError> {
if !hex(run, 32) {
return Err(refused());
Expand Down
13 changes: 13 additions & 0 deletions packages/runtime-core/src/provider/graph/native/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,20 @@ fn logs_with<B: Backend>(
}

/// Read-only same-incarnation inspection. A historical ready phase is not current health.
///
/// A run whose journal is exactly absent and whose foreground owner is not
/// published refuses `native_graph_unreserved` before any guest access. Absence
/// is a point-in-time observation: only a caller that separately proved its
/// consumer settled may treat it as "never reserved". Every other journal state
/// keeps the ordinary receipt validation below.
pub fn inspect(candidate: &Candidate, run: &str) -> Result<Snapshot, CandidateError> {
#[cfg(target_os = "macos")]
if journal::absent(candidate, run)? {
super::foreground::require_unpublished(candidate, run)?;
if journal::absent(candidate, run)? {
return Err(journal::unreserved());
}
}
let engine = Engine::connect_cleanup(candidate)?;
let (receipt, _) = journal::load(
candidate,
Expand Down
146 changes: 109 additions & 37 deletions src/backends/native-authored-project-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ import {
requestNativeAuthoredLiveStop,
} from "./native-authored-live-stop.ts";
import type { NativeAuthoredProcessIncarnation } from "./native-authored-process-incarnation.ts";
import {
NATIVE_GRAPH_UNRESERVED,
NativeRuntimeRequestError,
} from "./native-runtime-client.ts";

const LIMIT = 64 * 1024;
const SOURCE_LIMIT = 1024 * 1024;
Expand Down Expand Up @@ -140,6 +144,19 @@ export type NativeAuthoredProjectAdmission = {
readonly expectedRun?: NativeAuthoredProjectRunSelection;
readonly cleaned: NativeAuthoredReceipt;
}) => Promise<void>;
/**
* Retire a start intent whose native consumer never reserved runtime state
* (HACK-1272). `refusal` must be the fresh `native_graph_unreserved` inspection
* refusal for this exact run, obtained while this admission is held and after
* the caller proved no consumer for the run can still start. The ready mapping,
* hook owner and live-stop owner must be absent. The run's retained source is
* removed first (absence is accepted) so an interruption keeps the start
* intent blocking. No receipt, journal or guest state is read or changed here.
*/
readonly retireUnreserved: (opts: {
readonly expectedStart: NativeAuthoredProjectStartSelection;
readonly refusal: unknown;
}) => Promise<void>;
};
function refused(): never {
throw new Error(
Expand Down Expand Up @@ -540,43 +557,7 @@ export async function withNativeAuthoredProjectRecoveryStorage<T>(
);
},
async readSource(run) {
const current = await readPrivate(sourcePath(run), SOURCE_LIMIT);
const value: unknown = JSON.parse(current.text);
if (
!(
isRecord(value) &&
keys(
value,
value.version === 3 || value.version === 5
? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version"
: "branch,env_metadata,kind,overlay,profiles,project,run,version"
) &&
(value.version === 2 ||
((value.version === 3 || value.version === 5) &&
validHookPin(value.hook_permit)))
) ||
value.kind !== "native-graph-source" ||
value.project !== store.identity.projectRoot ||
value.branch !== store.identity.branch ||
value.run !== run ||
!Array.isArray(value.profiles) ||
value.profiles.length > 64 ||
value.profiles.some(
(profile: unknown) =>
typeof profile !== "string" || !validBranch(profile)
) ||
!(
value.overlay === "inherit" ||
value.overlay === "base" ||
(isRecord(value.overlay) &&
keys(value.overlay, "named") &&
typeof value.overlay.named === "string" &&
validBranch(value.overlay.named))
) ||
!parseNativeEnvMetadata(value.env_metadata)
) {
return refused();
}
const current = await readRetainedSource(store, sourcePath(run), run);
await store.check();
return {
dev: current.info.dev,
Expand All @@ -587,6 +568,70 @@ export async function withNativeAuthoredProjectRecoveryStorage<T>(
});
});
}
/** Read one retained private source envelope and require its exact kind, scope and run. */
async function readRetainedSource(store: Store, path: string, run: string) {
const current = await readPrivate(path, SOURCE_LIMIT);
const value: unknown = JSON.parse(current.text);
if (
!(
isRecord(value) &&
keys(
value,
value.version === 3 || value.version === 5
? "branch,env_metadata,hook_permit,kind,overlay,profiles,project,run,version"
: "branch,env_metadata,kind,overlay,profiles,project,run,version"
) &&
(value.version === 2 ||
((value.version === 3 || value.version === 5) &&
validHookPin(value.hook_permit)))
) ||
value.kind !== "native-graph-source" ||
value.project !== store.identity.projectRoot ||
value.branch !== store.identity.branch ||
value.run !== run ||
!Array.isArray(value.profiles) ||
value.profiles.length > 64 ||
value.profiles.some(
(profile: unknown) => typeof profile !== "string" || !validBranch(profile)
) ||
!(
value.overlay === "inherit" ||
value.overlay === "base" ||
(isRecord(value.overlay) &&
keys(value.overlay, "named") &&
typeof value.overlay.named === "string" &&
validBranch(value.overlay.named))
) ||
!parseNativeEnvMetadata(value.env_metadata)
) {
return refused();
}
return current;
}
/** Remove this run's exact validated source envelope; an absent source is accepted. */
async function removeRetainedSource(
store: Store,
path: string,
run: string,
check: () => Promise<void>
): Promise<void> {
let source: Awaited<ReturnType<typeof readPrivate>>;
try {
source = await readRetainedSource(store, path, run);
} catch (error) {
if (hasCode(error, "ENOENT")) {
return;
}
return refused();
}
await check();
const current = await readPrivate(path, SOURCE_LIMIT);
if (!sameFile(source.info, current.info) || current.text !== source.text) {
return refused();
}
await unlink(path);
await synchronizeDirectories(store.held);
}
async function read(store: Store): Promise<NativeAuthoredProjectRunSelection> {
return await readRecord(store, store.file, selected);
}
Expand Down Expand Up @@ -1097,6 +1142,33 @@ export async function withNativeAuthoredProjectAdmission<T>(
await synchronizeDirectories(store.held);
});
},
async retireUnreserved(input) {
const expected = selection(input.expectedStart, started);
const run = expected.record.review.provenance.run;
if (
!(
input.refusal instanceof NativeRuntimeRequestError &&
input.refusal.nativeCode === NATIVE_GRAPH_UNRESERVED &&
HEX32.test(run)
) ||
(await loadNativeAuthoredHookOwner(hookStore)) !== null
) {
return refused();
}
const sourcePath = join(store.root.path, `${run}.source.json`);
await withMutation(async (check) => {
await unchanged(store, store.startFile, expected, started);
await absent(store.file);
await absent(liveStopPath(store));
await removeRetainedSource(store, sourcePath, run, check);
// If retirement stops here, the durable start continues to block a new run.
await unchanged(store, store.startFile, expected, started);
await absent(store.file);
await check();
await unlink(store.startFile);
await synchronizeDirectories(store.held);
});
},
};
try {
return await action(Object.freeze(capability));
Expand Down
Loading
Loading