pointlock-cli 0.1.5

The Pointlock command-line interface: lock, compile, run, resume, inspect, locate, report.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! The provider assembly layer: resolves a `--provider` registration name
//! to a concrete provider assembled under the v0.1 provider name
//! `"devicerail"`.
//!
//! ## Provider registration ruling (M0 + M1)
//!
//! `FlowIR.provider.name` is the const literal `"devicerail"` for all of
//! v0.1 (spine §2 concept 6; 08 §6 version boundary). The assembly layer is
//! therefore the place where a concrete provider is *registered under* that
//! name:
//!
//! - `--provider fake` (M0, kept verbatim) selects the [`FakeProvider`],
//!   whose manifest is re-registered with `name = "devicerail"` before it
//!   reaches the compiler or the lockfile. This mirrors 08 §6.1's demo note
//!   ("装配层以 `--provider fake` 注入 FakeProvider, 同 manifest 形状").
//! - `--provider devicerail` (M1) selects the real
//!   [`DeviceRailProvider`](pointlock_provider_devicerail::DeviceRailProvider)
//!   over a spawned `devicerail-daemon` ([`DeviceRailAssembly`] builds the
//!   spawn endpoint from `--daemon-cmd` / `--daemon-env`).
//!
//! ## Determinism (fake)
//!
//! The mock lockfile is synthesized purely from the manifest with fixed
//! timestamps, so `pointlock lock` today and the run-time re-synthesis
//! tomorrow produce byte-identical content — and therefore the same digest.
//! A FlowIR compiled against a *different* lockfile fails the attestation
//! gate with `capability_drift`, exactly as a real provider would.

use std::collections::{BTreeMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use pointlock_ir::{
    ActionOutcome, ActionResult, AssetRef, EventCursor, FeatureId, Hash, Observation,
    ReconcileResult,
};
use pointlock_provider_devicerail::{DEFAULT_DAEMON_COMMAND, DEFAULT_SHUTDOWN_GRACE_MS, SpawnSpec};
use pointlock_provider_kit::lockfile::{
    CapabilityAttestation, CapabilityLockfile, LockfileDevice, LockfileHello, LockfileProvider,
    PeerInfo, ProtocolVersion,
};
use pointlock_provider_kit::manifest::{PlatformKind, ProviderManifest};
use pointlock_provider_kit::{
    BoundActionCall, CancellationToken, EvidenceStream, FakeHandle, FakeProvider, ObserveRequest,
    Provider, ProviderError, ProviderSession, ScriptedOutcome, SessionHealth, SessionOutcome,
    UiSnapshotOutcome, VerdictWrite,
};

/// The only provider name of v0.1 (`FlowIR.provider.name` const).
pub const PROVIDER_NAME: &str = "devicerail";

/// The mock provider registration of M0 (`--provider fake`).
pub const FAKE_REGISTRATION: &str = "fake";

/// The real provider registration of M1 (`--provider devicerail`): the
/// [`DeviceRailProvider`](pointlock_provider_devicerail::DeviceRailProvider)
/// over a spawned `devicerail-daemon`.
pub const DEVICERAIL_REGISTRATION: &str = "devicerail";

/// The device id the fake assembly binds by default.
pub const DEFAULT_DEVICE_ID: &str = "fake-device-1";

/// The device id the devicerail registration binds by default: the daemon's
/// built-in mock driver device, which is always registered (platform
/// discovery is pinned off by default, see [`DeviceRailAssembly`]).
pub const DEVICERAIL_DEFAULT_DEVICE_ID: &str = "mock-1";

/// A resolved `--provider` registration name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Registration {
    /// The M0 [`FakeProvider`] registration (`--provider fake`).
    Fake,
    /// The M1 real DeviceRail registration (`--provider devicerail`).
    DeviceRail,
}

/// Resolves a `--provider` registration name. Anything outside the two
/// v0.1 registrations is a typed refusal (fail-closed), not a fallback.
pub fn registration(name: &str) -> Result<Registration, String> {
    match name {
        FAKE_REGISTRATION => Ok(Registration::Fake),
        DEVICERAIL_REGISTRATION => Ok(Registration::DeviceRail),
        other => Err(format!(
            "provider registration '{other}' is unknown; v0.1 registers '{FAKE_REGISTRATION}' \
             (the M0 mock) and '{DEVICERAIL_REGISTRATION}' (the real DeviceRail provider), both \
             assembled under the v0.1 provider name '{PROVIDER_NAME}'"
        )),
    }
}

/// Deterministic lock timestamp: the mock provider has no wall clock worth
/// attesting, and a fixed value keeps the lockfile digest reproducible so
/// the run-time re-synthesis matches the `pointlock lock` artifact (M0
/// assembly decision, documented above).
const MOCK_LOCK_ATTESTED_AT: &str = "1970-01-01T00:00:00Z";

/// Deterministic "live attestation" timestamp exposed on open sessions.
const MOCK_SESSION_ATTESTED_AT: &str = "1970-01-01T00:00:01Z";

/// A provider registration assembled for the CLI: the fake provider plus
/// its devicerail-named manifest and the synthesized mock lockfile.
pub struct FakeAssembly {
    provider: FakeProvider,
    manifest: ProviderManifest,
    lockfile: CapabilityLockfile,
}

/// Assembles the M0 fake registration (see the module docs for the
/// registration ruling).
pub fn assemble_fake() -> FakeAssembly {
    // The CLI programs the execute script per call (echo semantics), so the
    // fake starts with an empty queue.
    let provider = FakeProvider::new(VecDeque::new());
    // Provider registration ruling (module docs): the fake's manifest ships
    // as `name: "fake"`, but v0.1 flows bind only against `"devicerail"`.
    let mut manifest = provider.manifest().clone();
    manifest.name = PROVIDER_NAME.to_owned();
    let lockfile = synthesize_lockfile(&manifest);
    FakeAssembly {
        provider,
        manifest,
        lockfile,
    }
}

/// Synthesizes the mock capability lockfile from a manifest: hello freezes
/// protocol 1.5 with `featuresEnabled = manifest.features.guaranteed`; the
/// device freezes `manifest.knownActions` on a mocked platform. Sealed
/// (digest recomputed) before returning.
///
/// Platform note: `PlatformKind` has no `mock` variant (closed enum, spine
/// §4.1); the mock device reports `android`, matching the FakeProvider's
/// own default and keeping `uiTree` the default locating channel.
fn synthesize_lockfile(manifest: &ProviderManifest) -> CapabilityLockfile {
    let mut lockfile = CapabilityLockfile {
        provider: LockfileProvider {
            name: manifest.name.clone(),
            version: manifest.version.clone(),
        },
        attested_at: MOCK_LOCK_ATTESTED_AT.to_owned(),
        hello: LockfileHello {
            protocol_selected: ProtocolVersion { major: 1, minor: 5 },
            features_enabled: manifest.features.guaranteed.clone(),
            server: PeerInfo {
                name: "pointlock-fake-daemon".to_owned(),
                version: manifest.version.clone(),
            },
        },
        device: LockfileDevice {
            platform: PlatformKind::Android,
            // A lockfile attests the DEVICE's driver actions. The
            // provider-synthetic ones (04 §9.4.3) belong to the provider,
            // not the device, so they stay out — bind overlays them, and
            // keeping them out is also what lets a real driver action of
            // the same name shadow them.
            actions: manifest
                .known_actions
                .iter()
                .filter(|action| !action.synthetic)
                .cloned()
                .collect(),
        },
        digest: Hash::new(format!("sha256:{}", "0".repeat(64)))
            .expect("the all-zero placeholder digest is grammatical"),
    };
    lockfile.seal();
    lockfile
}

impl FakeAssembly {
    /// The devicerail-registered manifest (compiler input).
    pub fn manifest(&self) -> &ProviderManifest {
        &self.manifest
    }

    /// The synthesized mock lockfile (what `pointlock lock` writes and what
    /// run-time attestation is checked against).
    pub fn lockfile(&self) -> &CapabilityLockfile {
        &self.lockfile
    }

    /// The `env.platform` string of the mock device.
    pub fn platform(&self) -> String {
        serde_json::to_value(self.lockfile.device.platform)
            .ok()
            .and_then(|value| value.as_str().map(str::to_owned))
            .expect("PlatformKind serializes to a string literal")
    }

    /// Opens a session against the fake and wraps it with the CLI's echo
    /// semantics and (optionally) a stop-after plan.
    ///
    /// The inner fake attests its own private lockfile; the wrapper exposes
    /// the devicerail attestation built from [`Self::lockfile`], so the
    /// runner's `capability_drift` gate compares the FlowIR against the
    /// artifact `pointlock lock` actually wrote.
    pub async fn open_session(
        &self,
        device_id: &str,
        required_features: Vec<FeatureId>,
        stop_after: Option<StopAfterPlan>,
    ) -> Result<Box<dyn ProviderSession>, ProviderError> {
        let mut opts = self.provider.default_open_options();
        opts.device_id = device_id.to_owned();
        opts.required_features = required_features;
        let inner = self.provider.open_session(opts).await?;
        let attestation =
            CapabilityAttestation::from_lockfile(&self.lockfile, MOCK_SESSION_ATTESTED_AT);
        Ok(Box::new(EchoSession {
            inner,
            attestation,
            handle: self.provider.handle(),
            device_id: device_id.to_owned(),
            stop_after,
        }))
    }
}

/// Cancels a stop token after a fixed number of completed dispatches — the
/// deterministic carrier of `pointlock run --stop-after <step-id>`.
///
/// M0 determinism argument: the M0 IR subset admits neither retry policies
/// nor fallback attempt chains (single bound attempt per step), and the
/// echo script makes every dispatch succeed, so the execute sequence is
/// exactly the body order — dispatch N belongs to body step N. The engine
/// honors the token at the next step boundary, i.e. *after* the named step
/// fully completes (judged + stepExited).
pub struct StopAfterPlan {
    /// The run's stop token (shared with `RunOptions::stop`).
    pub token: CancellationToken,
    /// Dispatches remaining before the token is cancelled.
    pub remaining: AtomicUsize,
}

/// Delegating session wrapper adding the CLI's M0 default behavior:
///
/// - **echo semantics**: before each dispatch it programs the fake with a
///   `succeeded` terminal whose `output` echoes the evaluated call
///   arguments verbatim. Scripting *before* delegation (instead of patching
///   the return value) keeps the fake's journal consistent, so a
///   resume-time `reconcile → completed` adopts the same echoed output.
/// - **stop-after**: see [`StopAfterPlan`].
struct EchoSession {
    inner: Box<dyn ProviderSession>,
    attestation: CapabilityAttestation,
    handle: FakeHandle,
    /// The bound device (journaled on synthesized observations).
    device_id: String,
    stop_after: Option<StopAfterPlan>,
}

impl EchoSession {
    /// A `succeeded` terminal echoing `output`; the fake stamps `callId`
    /// and the logical timestamps at execute time. Every echo carries a
    /// synthesized *after* observation with a registered screenshot —
    /// the fake's manifest declares the vision verify channel, so the
    /// echo path must feed it (no UI snapshot: the echo world has no
    /// tree to declare, and inventing one would not be honest).
    fn echo_outcome(&self, output: serde_json::Value) -> ScriptedOutcome {
        ScriptedOutcome::Terminal(ActionOutcome::Succeeded {
            result: Box::new(ActionResult {
                call_id: String::new(),
                started_at_ms: 0,
                finished_at_ms: 0,
                output,
                before: None,
                after: Some(self.handle.make_observation_for(&self.device_id, None)),
                evidence: Vec::new(),
                execution: None,
            }),
        })
    }
}

#[async_trait]
impl ProviderSession for EchoSession {
    fn attestation(&self) -> &CapabilityAttestation {
        &self.attestation
    }

    async fn execute(
        &self,
        call: BoundActionCall,
        cancel: Option<CancellationToken>,
    ) -> Result<ActionOutcome, ProviderError> {
        self.handle
            .push_script(self.echo_outcome(call.arguments.clone()));
        let outcome = self.inner.execute(call, cancel).await;
        if let Some(plan) = &self.stop_after
            && plan.remaining.fetch_sub(1, Ordering::SeqCst) == 1
        {
            plan.token.cancel();
        }
        outcome
    }

    async fn observe(
        &self,
        req: ObserveRequest,
        cancel: Option<CancellationToken>,
    ) -> Result<Observation, ProviderError> {
        self.inner.observe(req, cancel).await
    }

    async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
        self.inner.ui_snapshot(observation_id).await
    }

    async fn reconcile(
        &self,
        call_id: &str,
        issuing: &EventCursor,
    ) -> Result<ReconcileResult, ProviderError> {
        self.inner.reconcile(call_id, issuing).await
    }

    async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError> {
        self.inner.fetch_evidence(asset).await
    }

    async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
        self.inner.record_verdict(verdict).await
    }

    async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
        self.inner.current_cursor().await
    }

    async fn health(&self) -> Result<SessionHealth, ProviderError> {
        self.inner.health().await
    }

    async fn end(
        &self,
        outcome: SessionOutcome,
        reason: Option<String>,
    ) -> Result<(), ProviderError> {
        self.inner.end(outcome, reason).await
    }
}

// ─── The M1 devicerail registration ─────────────────────────────────────────

/// Counter distinguishing evidence tempdirs within one process.
static EVIDENCE_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);

/// The M1 devicerail spawn assembly: the [`SpawnSpec`] that `--daemon-cmd`
/// / `--daemon-env` resolve to, with the smoke-test defaults applied first
/// (a fresh evidence tempdir as `DEVICERAIL_EVIDENCE_DIR`, and
/// `DEVICERAIL_ANDROID=off` so the run does not depend on local adb state —
/// the daemon's built-in mock driver is always registered). User-provided
/// `--daemon-env` entries override the defaults key by key.
pub struct DeviceRailAssembly {
    spawn: SpawnSpec,
}

impl DeviceRailAssembly {
    /// Builds the spawn assembly. `daemon_env` entries are already-split
    /// `KEY=VALUE` pairs; later entries win over the injected defaults.
    /// The effective evidence directory is created (the daemon expects it
    /// to exist and it doubles as the child's working directory, matching
    /// the M1 smoke harness).
    pub fn new(
        daemon_cmd: Option<&Path>,
        daemon_env: Vec<(String, String)>,
    ) -> Result<Self, String> {
        let mut env: BTreeMap<String, String> =
            BTreeMap::from([("DEVICERAIL_ANDROID".to_owned(), "off".to_owned())]);
        for (key, value) in daemon_env {
            env.insert(key, value);
        }
        let evidence_dir = match env.get("DEVICERAIL_EVIDENCE_DIR") {
            Some(pinned) => PathBuf::from(pinned),
            None => {
                let dir = std::env::temp_dir().join(format!(
                    "pointlock-devicerail-evidence-{}-{}",
                    std::process::id(),
                    EVIDENCE_DIR_COUNTER.fetch_add(1, Ordering::Relaxed),
                ));
                env.insert(
                    "DEVICERAIL_EVIDENCE_DIR".to_owned(),
                    dir.to_string_lossy().into_owned(),
                );
                dir
            }
        };
        std::fs::create_dir_all(&evidence_dir).map_err(|err| {
            format!(
                "cannot create the daemon evidence directory {}: {err}",
                evidence_dir.display()
            )
        })?;
        Ok(DeviceRailAssembly {
            spawn: SpawnSpec {
                command: daemon_cmd
                    .map(|path| path.to_string_lossy().into_owned())
                    .unwrap_or_else(|| DEFAULT_DAEMON_COMMAND.to_owned()),
                args: Vec::new(),
                env,
                cwd: Some(evidence_dir.to_string_lossy().into_owned()),
                shutdown_grace_ms: DEFAULT_SHUTDOWN_GRACE_MS,
            },
        })
    }

    /// The spawn spec (the `pointlock lock` freeze path consumes it
    /// directly via `lock_via_spawn_at`).
    pub fn spawn_spec(&self) -> &SpawnSpec {
        &self.spawn
    }

    /// The `OpenSessionOptions.endpoint` value in its wire shape (the
    /// spawn form of the endpoint union, 04 §9.1).
    pub fn endpoint(&self) -> serde_json::Value {
        serde_json::json!({ "spawn": self.spawn })
    }
}

/// Delegating session wrapper carrying the CLI's `--stop-after` plan over
/// an arbitrary provider session (M1: the DeviceRail session; the fake
/// path keeps its own [`EchoSession`] wrapper, which scripts echoes too).
///
/// The determinism argument of [`StopAfterPlan`] applies unchanged: the
/// executable IR subset admits a single bound attempt per step with no
/// retry policy, so as long as every dispatch succeeds (the M1 demo flow
/// over the daemon's mock driver does), dispatch N belongs to body step N.
pub struct StopAfterSession {
    inner: Box<dyn ProviderSession>,
    plan: StopAfterPlan,
}

impl StopAfterSession {
    /// Wraps `inner` with the plan.
    pub fn new(inner: Box<dyn ProviderSession>, plan: StopAfterPlan) -> Self {
        StopAfterSession { inner, plan }
    }
}

#[async_trait]
impl ProviderSession for StopAfterSession {
    fn attestation(&self) -> &CapabilityAttestation {
        self.inner.attestation()
    }

    async fn execute(
        &self,
        call: BoundActionCall,
        cancel: Option<CancellationToken>,
    ) -> Result<ActionOutcome, ProviderError> {
        let outcome = self.inner.execute(call, cancel).await;
        if self.plan.remaining.fetch_sub(1, Ordering::SeqCst) == 1 {
            self.plan.token.cancel();
        }
        outcome
    }

    async fn observe(
        &self,
        req: ObserveRequest,
        cancel: Option<CancellationToken>,
    ) -> Result<Observation, ProviderError> {
        self.inner.observe(req, cancel).await
    }

    async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
        self.inner.ui_snapshot(observation_id).await
    }

    async fn reconcile(
        &self,
        call_id: &str,
        issuing: &EventCursor,
    ) -> Result<ReconcileResult, ProviderError> {
        self.inner.reconcile(call_id, issuing).await
    }

    async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError> {
        self.inner.fetch_evidence(asset).await
    }

    async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
        self.inner.record_verdict(verdict).await
    }

    async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
        self.inner.current_cursor().await
    }

    async fn health(&self) -> Result<SessionHealth, ProviderError> {
        self.inner.health().await
    }

    async fn end(
        &self,
        outcome: SessionOutcome,
        reason: Option<String>,
    ) -> Result<(), ProviderError> {
        self.inner.end(outcome, reason).await
    }
}