cairn-pack-hello 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """hello-cairn — the example domain pack (#2423, ADR-0127 D2). Data plus glue; this
2
+ shim builds the Pack from the ``pack.toml`` beside it. The glue (``hooks.py``,
3
+ ``tools/calc_server.py``) is reached through an entry point and a profile, never
4
+ imported here."""
5
+ from __future__ import annotations
6
+
7
+ from cairn.core.pack import Pack
8
+
9
+ pack = Pack.from_manifest(__file__)
10
+
11
+ __all__ = ["pack"]
@@ -0,0 +1,20 @@
1
+ {"id": 1, "text": "The soup arrived hot and the bread was fresh.", "label": "positive"}
2
+ {"id": 2, "text": "Delivery took two hours longer than promised.", "label": "negative"}
3
+ {"id": 3, "text": "The packaging was fine, nothing special.", "label": "neutral"}
4
+ {"id": 4, "text": "Best noodles I have had in months.", "label": "positive"}
5
+ {"id": 5, "text": "The order was missing the side salad.", "label": "negative"}
6
+ {"id": 6, "text": "Portion size was about what I expected.", "label": "neutral"}
7
+ {"id": 7, "text": "The courier was polite and quick.", "label": "positive"}
8
+ {"id": 8, "text": "My drink had spilled inside the bag.", "label": "negative"}
9
+ {"id": 9, "text": "I ordered the same dish as last week.", "label": "neutral"}
10
+ {"id": 10, "text": "The dessert was a lovely surprise.", "label": "positive"}
11
+ {"id": 11, "text": "The curry was far too salty to finish.", "label": "negative"}
12
+ {"id": 12, "text": "Paid with the app, it worked as usual.", "label": "neutral"}
13
+ {"id": 13, "text": "Everything was still warm when it came.", "label": "positive"}
14
+ {"id": 14, "text": "The tracking map never updated.", "label": "negative"}
15
+ {"id": 15, "text": "The menu has not changed since spring.", "label": "neutral"}
16
+ {"id": 16, "text": "Great value for a weeknight dinner.", "label": "positive"}
17
+ {"id": 17, "text": "The rice was undercooked again.", "label": "negative"}
18
+ {"id": 18, "text": "Arrived at the time the app estimated.", "label": "neutral"}
19
+ {"id": 19, "text": "The new vegetarian option is excellent.", "label": "positive"}
20
+ {"id": 20, "text": "Support never answered my refund request.", "label": "negative"}
@@ -0,0 +1,48 @@
1
+ {"text": "The tea works well on cold mornings."}
2
+ {"text": "A paper map needed a second try."}
3
+ {"text": "The bicycle bell fits in a coat pocket."}
4
+ {"text": "This notebook looks better in person."}
5
+ {"text": "The garden hose is lighter than it looks."}
6
+ {"text": "A wool scarf smells faintly of cedar."}
7
+ {"text": "The desk lamp made less noise than expected."}
8
+ {"text": "That umbrella arrived a day early."}
9
+ {"text": "The bread knife lasted the whole season."}
10
+ {"text": "A clay mug was easy to set up."}
11
+ {"text": "The tea works well on cold mornings."}
12
+ {"text": "A paper map needed a second try."}
13
+ {"text": "The bicycle bell fits in a coat pocket."}
14
+ {"text": "This notebook looks better in person."}
15
+ {"text": "The garden hose is lighter than it looks."}
16
+ {"text": "A wool scarf smells faintly of cedar."}
17
+ {"text": "The desk lamp made less noise than expected."}
18
+ {"text": "That umbrella arrived a day early."}
19
+ {"text": "The bread knife lasted the whole season."}
20
+ {"text": "A clay mug was easy to set up."}
21
+ {"text": "The tea works well on cold mornings."}
22
+ {"text": "A paper map needed a second try."}
23
+ {"text": "The bicycle bell fits in a coat pocket."}
24
+ {"text": "This notebook looks better in person."}
25
+ {"text": "The garden hose is lighter than it looks."}
26
+ {"text": "A wool scarf smells faintly of cedar."}
27
+ {"text": "The desk lamp made less noise than expected."}
28
+ {"text": "That umbrella arrived a day early."}
29
+ {"text": "The bread knife lasted the whole season."}
30
+ {"text": "A clay mug was easy to set up."}
31
+ {"text": "The tea works well on cold mornings."}
32
+ {"text": "A paper map needed a second try."}
33
+ {"text": "The bicycle bell fits in a coat pocket."}
34
+ {"text": "This notebook looks better in person."}
35
+ {"text": "The garden hose is lighter than it looks."}
36
+ {"text": "A wool scarf smells faintly of cedar."}
37
+ {"text": "The desk lamp made less noise than expected."}
38
+ {"text": "That umbrella arrived a day early."}
39
+ {"text": "The bread knife lasted the whole season."}
40
+ {"text": "A clay mug was easy to set up."}
41
+ {"text": "The tea works well on cold mornings."}
42
+ {"text": "A paper map needed a second try."}
43
+ {"text": "The bicycle bell fits in a coat pocket."}
44
+ {"text": "This notebook looks better in person."}
45
+ {"text": "The garden hose is lighter than it looks."}
46
+ {"text": "A wool scarf smells faintly of cedar."}
47
+ {"text": "The desk lamp made less noise than expected."}
48
+ {"text": "That umbrella arrived a day early."}
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: assistant
3
+ version: 0.1.0
4
+ module: ask
5
+ tags:
6
+ - flow:state_machine
7
+ - domain:example
8
+ description: |
9
+ Answer a question from the pack's own docs: retrieve from the `docs` corpus,
10
+ re-rank, ask a model, measure the answer with a tool on the example's MCP server,
11
+ and remember the exchange. The agentic leg of `ask-the-docs`.
12
+
13
+ flow:
14
+ type: state_machine
15
+ source: behavior_tree
16
+ root:
17
+ type: sequence
18
+ children:
19
+ # Retrieval: the builtin rag/search over the corpus pack.toml declares as
20
+ # [requires.rag.docs] (sqlite-vec by default; pgvector when RAG_VECTOR_STORE
21
+ # says so). Read-only.
22
+ - type: operation
23
+ operator: rag/search
24
+ id: search
25
+ input:
26
+ corpus: docs
27
+ query: $input.question
28
+ top_k: 5
29
+ output:
30
+ hits: $state.findings.hits
31
+ ok: $state.findings.search_ok
32
+ error: $state.findings.search_error
33
+ # Re-rank the hits (identity unless a cross-encoder or Cohere is installed).
34
+ - type: operation
35
+ operator: rag/rerank
36
+ id: rerank
37
+ input:
38
+ query: $input.question
39
+ hits: $default($state.findings.hits, [])
40
+ top_k: 3
41
+ output:
42
+ hits: $state.findings.context
43
+ reranker_used: $state.findings.reranker
44
+ # The model call. The compiler builds it through core's build_chat_model, so
45
+ # the call is redacted (when a redact.text binding is active) and costed.
46
+ - type: action
47
+ ref: answer
48
+ # A tool call on the example's own stdio MCP server (tools/calc_server.py),
49
+ # through the builtin mcp/call operator: governed and audited like any tool call.
50
+ - type: operation
51
+ operator: mcp/call
52
+ id: measure
53
+ input:
54
+ server: hello-calc
55
+ tool: word_count
56
+ args:
57
+ text: $state.findings.answer
58
+ output:
59
+ result: $state.findings.answer_stats
60
+ ok: $state.findings.measure_ok
61
+ error: $state.findings.measure_error
62
+ # Remember the exchange in a local memory store (memory/write, sqlite-vec). A
63
+ # store name is a collection name: letters, digits and underscores only.
64
+ - type: operation
65
+ operator: memory/write
66
+ id: remember
67
+ input:
68
+ store: hello_answers
69
+ record:
70
+ question: $input.question
71
+ answer: $state.findings.answer
72
+ embed_text: $input.question
73
+ output:
74
+ ok: $state.findings.remembered
75
+ id: $state.findings.memory_id
76
+ error: $state.findings.memory_error
77
+
78
+ nodes:
79
+ answer:
80
+ type: prompt
81
+ model: gpt-4o-mini
82
+ capabilities: []
83
+ prompt_section: answer
84
+
85
+ tools:
86
+ required: []
87
+ ---
88
+
89
+ # Ask the docs
90
+
91
+ ## answer
92
+
93
+ You answer a question about the hello-cairn example pack. The retrieved passages
94
+ are in `state.findings.context` (each hit's `payload.content` is the text, and
95
+ `payload.source` names the file). Answer in at most three sentences, using only
96
+ what the passages say, and name the file you used. When the passages do not cover
97
+ the question, say that the docs do not cover it. Do not invent features.
@@ -0,0 +1,23 @@
1
+ # flows/engine-check.yaml — a data flow on the builtin engine (#2423).
2
+ #
3
+ # A flow is a list of stages the dataflow engine runs as ONE governed leg. The
4
+ # public core installs exactly one stage provider, cairn-operator-dataflow, and it
5
+ # ships only the conformance stages `noop` and `noop.slow` (the real kernels:
6
+ # dedup, PII redaction, export, live in official operator packs). So this flow
7
+ # is honest about what it is: it proves the flow-leg path end to end (engine
8
+ # selection, the flow gate on aggregate stage risk, a leg Run in the record)
9
+ # and transforms nothing. Swap the stage for a real kernel once one is installed.
10
+ name: engine-check
11
+ kind: data
12
+ description: >
13
+ One `noop` stage on the builtin dataflow engine: shows a flow leg in the
14
+ record without touching data, and registers the checked file's content as a
15
+ version of the dataset the leg names (#2632).
16
+ stages:
17
+ - stage: noop
18
+ config:
19
+ # Bound by the pipeline leg's `with:`; unbound keys are dropped. With both set,
20
+ # the control plane registers `register` at `source`'s content hash: a changed
21
+ # file is a new version, which is what a `dataset` watch sees.
22
+ register: $config.register
23
+ source: $config.source
@@ -0,0 +1,25 @@
1
+ # flows/lora.yaml — a LoRA fine-tune as an ml flow (hello-cairn WP6, ADR-0127 D4).
2
+ #
3
+ # One stage, `train.lora`, from the builtin train-local operator pack
4
+ # (cairn-operator-train-local, contract train.finetune/1.0.0). The stage takes the
5
+ # contract's input keys as its config; every `$config.<key>` the leg does not bind is
6
+ # dropped, so the stage's own defaults apply (Qwen/Qwen2.5-0.5B, 8 steps, 64 rows).
7
+ # `mock: true` runs the torch-free stand-in: no training stack, deterministic numbers.
8
+ #
9
+ # The real fine-tune needs the `[train]` extra of cairn-operator-train-local (torch,
10
+ # transformers, peft, datasets) on the box the ml engine runs on; CPU is enough. The
11
+ # stage writes the adapter under the leg's workdir and reports `kind: model`, so the
12
+ # leg registers a model artifact the pipeline can promote.
13
+ name: lora
14
+ kind: ml
15
+ description: >
16
+ LoRA fine-tune a small causal LM on a JSONL of {"text": ...} rows (CPU).
17
+ stages:
18
+ - stage: train.lora
19
+ config:
20
+ dataset: $config.dataset
21
+ base_model: $config.base_model
22
+ lora_rank: $config.lora_rank
23
+ steps: $config.steps
24
+ max_samples: $config.max_samples
25
+ mock: $config.mock
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: nightly-job
3
+ version: 0.1.0
4
+ module: ops
5
+ tags:
6
+ - flow:state_machine
7
+ - flow:operation-driven
8
+ - domain:example
9
+ description: |
10
+ The nightly job: check that the job's secret is present, then run a report
11
+ command on an SSH box and fetch what it wrote. The first leg of `nightly-ops`.
12
+
13
+ flow:
14
+ type: state_machine
15
+ source: behavior_tree
16
+ root:
17
+ type: sequence
18
+ children:
19
+ # A capability node, not an operator: the active profile's
20
+ # [profile.bindings] "secrets.store" picks the implementation
21
+ # (secrets/env in profiles/hello.toml, secrets/infisical in
22
+ # profiles/hello-infisical.toml). A presence check: the value never
23
+ # enters the run's state.
24
+ - type: operation
25
+ capability: contract://secrets.store/1.0.0
26
+ id: secret
27
+ input:
28
+ name: $input.secret_name
29
+ output:
30
+ present: $state.findings.secret_present
31
+ # The SSH job (builtin remote/exec). The box comes from a `compute.ssh`
32
+ # connection named by the run input, never from the template.
33
+ - type: operation
34
+ operator: remote/exec
35
+ id: report
36
+ execution:
37
+ compute_target: $input.compute_target
38
+ input:
39
+ command: $input.command
40
+ env:
41
+ HELLO_SECRET_PRESENT: $state.findings.secret_present
42
+ output:
43
+ status: $state.findings.job_status
44
+ exit_code: $state.findings.job_exit_code
45
+ host: $state.findings.job_host
46
+ metrics: $state.findings.report
47
+ error: $state.findings.job_error
48
+ nodes: {}
49
+
50
+ tools:
51
+ required: []
52
+ ---
53
+
54
+ # Nightly job
55
+
56
+ Two operation nodes and no model: the secret check runs through whichever
57
+ `secrets.store` implementation the profile binds, and the report runs over SSH on
58
+ the box the `compute_target` input names. The command writes `metrics.json` in its
59
+ working directory; `remote/exec` reads it back as `report`.
@@ -0,0 +1,18 @@
1
+ # flows/summarise.yaml — the nightly report's data flow (hello-cairn WP6).
2
+ #
3
+ # Two builtin stages on the builtin dataflow engine. The public core ships only the
4
+ # conformance stages (`noop`, `noop.slow`), so this flow carries the report through
5
+ # the engine without transforming it: what it proves is the flow leg itself (engine
6
+ # selection, the flow gate on aggregate stage risk, a leg Run and a registered
7
+ # artifact in the record). `noop.slow` with a short `seconds` shows a leg that takes
8
+ # time and reports progress. Swap in a real kernel once an operator pack provides one.
9
+ name: summarise
10
+ kind: data
11
+ description: >
12
+ Carry the nightly report through the builtin dataflow engine: two conformance
13
+ stages, nothing transformed.
14
+ stages:
15
+ - stage: noop
16
+ - stage: noop.slow
17
+ config:
18
+ seconds: 1
@@ -0,0 +1,51 @@
1
+ """hello-cairn's run-hook listener: a receipt line per settled leg and finished run.
2
+
3
+ Glue, not an operator (ADR-0127 D2). The engine fires run hooks through the
4
+ `cairn.run_hooks` entry-point group (cairn/core/pack/run_hooks.py); this pack's
5
+ pyproject declares
6
+
7
+ [project.entry-points."cairn.run_hooks"]
8
+ hello-receipts = "cairn_pack_hello.hooks:receipts"
9
+
10
+ so installing the wheel is what turns it on. A dev pack loaded through
11
+ `CAIRN_PACK_PATH` declares no entry points, so nothing fires there.
12
+
13
+ Every `leg.finished`, `run.finished` and `run.failed` appends one JSON line to
14
+ `$CAIRN_HOME/hello-cairn/receipts.jsonl` (`CAIRN_HOME` defaults to `~/.cairn`). The
15
+ hook sees every run on the engine, not only this pack's, which is what a receipt log
16
+ is for. Only JSON scalars from the payload are written: `run.finished` also carries
17
+ the dispatcher's run store, which is an object, not data.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import os
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ #: The events a receipt is written for; the others pass through untouched.
29
+ EVENTS = ("leg.finished", "run.finished", "run.failed")
30
+
31
+
32
+ def receipts_path() -> Path:
33
+ home = os.environ.get("CAIRN_HOME") or str(Path.home() / ".cairn")
34
+ return Path(home).expanduser() / "hello-cairn" / "receipts.jsonl"
35
+
36
+
37
+ async def receipts(event: str, payload: dict[str, Any]) -> None:
38
+ """``(event, payload) -> None``, the run-hook signature."""
39
+ if event not in EVENTS:
40
+ return
41
+ line = {k: v for k, v in payload.items() if isinstance(v, (str, int, float, bool)) or v is None}
42
+ line["at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
43
+ # Hooks run on the engine's event loop; the file write goes to a worker thread so a
44
+ # slow disk never stalls the dispatcher.
45
+ await asyncio.to_thread(_append, receipts_path(), json.dumps(line, sort_keys=True))
46
+
47
+
48
+ def _append(path: Path, text: str) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ with path.open("a", encoding="utf-8") as fh:
51
+ fh.write(text + "\n")
@@ -0,0 +1,50 @@
1
+ # hello-cairn — the example domain pack (#2423, ADR-0120 D1; the feature proof,
2
+ # ADR-0127 D1/D2).
3
+ #
4
+ # The one pack the public core ships so a fresh install has something governed
5
+ # to run. It needs the engine and the builtin operator packs, and nothing from the
6
+ # platform: four pipelines, one per kind of work (data, agentic, ml, operations).
7
+ #
8
+ # A domain pack is DATA plus glue, never operators (ADR-0127 D2): this manifest,
9
+ # `pipelines/*.yaml` (the runnable surface), `flows/` (the components the
10
+ # pipelines' flow and template legs name), `rag/docs` (a corpus), `data/`,
11
+ # `policies/`, `profiles/`, the one-line `__init__.py`, a run-hook listener
12
+ # (`hooks.py`, declared through the `cairn.run_hooks` entry point in pyproject.toml)
13
+ # and a stdio MCP tool server (`tools/calc_server.py`). Neither piece of glue
14
+ # registers an operation. Assets are found by convention (`pipelines/`, `flows/`,
15
+ # `rag/`, `data/`, `policies/`), so there is no `[assets]` block.
16
+
17
+ [pack]
18
+ name = "hello-cairn"
19
+ version = "0.2.0"
20
+ description = "Example pack: check a dataset, ask its docs, fine-tune a small model and run a nightly job, each behind gates a person or the data answers."
21
+ tags = [
22
+ "domain:example",
23
+ "data:jsonl",
24
+ ]
25
+ kind = "domain"
26
+ publisher = "cairn"
27
+ trust_level = "builtin"
28
+
29
+ [compat]
30
+ cairn_engine = ">=0.1.0"
31
+
32
+ # No `[requires] capabilities`: that table is satisfied by other DOMAIN packs'
33
+ # `[provides]`, not by operator packs, so naming `data.profile` / `storage.put`
34
+ # here would be a composition conflict on every deployment. The pipeline's
35
+ # task legs are checked against the operator registry at plan time instead
36
+ # (`cairn pipeline explain` names the operator class each leg resolves to).
37
+
38
+ # The corpus ask-the-docs retrieves from. Indexed by the engine, not by a leg:
39
+ # with CAIRN_RAG_AUTOINDEX=1 the server indexes every declared corpus at boot into
40
+ # the collection rag/search reads (cairn/services/rag/lifecycle.py).
41
+ [requires.rag.docs]
42
+ corpus = "rag/docs"
43
+ access = "vector_search"
44
+
45
+ # The MCP server ask-the-docs' assistant template calls (tools/calc_server.py).
46
+ # The profile names it under [profile.mcp_servers]; `cairn doctor` reports a
47
+ # profile that does not.
48
+ [requires.mcp]
49
+ servers = ["hello-calc"]
50
+ tools = ["hello-calc:word_count", "hello-calc:add"]
@@ -0,0 +1,85 @@
1
+ # pipelines/ask-the-docs.yaml — the agentic pipeline (hello-cairn WP6, ADR-0127 D1).
2
+ #
3
+ # assistant (template: search → rerank → model → MCP tool → memory)
4
+ # → length (monitor/threshold) → review ⏸
5
+ #
6
+ # The work happens in ONE template leg, flows/assistant.md: retrieval over the
7
+ # `docs` corpus this pack ships (pack.toml [requires.rag.docs] → rag/docs/*.txt), a
8
+ # re-rank, one model call built by core's build_chat_model, a tool call on the
9
+ # example's own stdio MCP server (tools/calc_server.py, through the builtin mcp/call)
10
+ # and a memory write. Then a threshold check on how long the answer is, and a person
11
+ # reads it before anyone relies on it.
12
+ #
13
+ # What this needs from the engine (README "ask-the-docs"):
14
+ # * a model: OPENAI_API_KEY (or LLM_GATEWAY=proxy + LLM_GATEWAY_URL for any
15
+ # OpenAI-compatible gateway). The engine has no built-in mock model.
16
+ # * an embedder for retrieval and memory: RAG_EMBED_PROVIDER=openai (or gemini).
17
+ # * the corpus indexed: CAIRN_RAG_AUTOINDEX=1 on the server indexes every declared
18
+ # corpus at boot (needs the `rag` extra). There is no indexing operator; an
19
+ # un-indexed corpus answers with zero hits, and the model says the docs do not
20
+ # cover the question.
21
+ # * the MCP server named in the engine's environment, CAIRN_MCP_SERVERS (README).
22
+ #
23
+ # Starts on an event (ADR-0131 D4): when `profile-and-publish` publishes, its
24
+ # `publish` leg emits `dataset.ready`; the `kind: event` trigger below starts this
25
+ # pipeline once per message, paused until someone turns it on (README "Events").
26
+ name: ask-the-docs
27
+ kind: pipeline
28
+ visibility: public
29
+ description: >
30
+ Answer a question from the example's own docs with retrieval, a model call and
31
+ an MCP tool, check the answer's length, and have a person read it.
32
+ outcome: Ask a question of a small document set
33
+ suite: operate
34
+
35
+ inputs:
36
+ question: {type: str, default: "What is the difference between a data gate and a person gate?", description: "The question to answer from rag/docs."}
37
+ max_words: {type: int, default: 120, description: "Answers longer than this many words are flagged by the length check."}
38
+
39
+ triggers:
40
+ - kind: event
41
+ topic: dataset.ready
42
+ enabled: false
43
+ description: Ask the docs again whenever the sample dataset is published.
44
+
45
+ governance:
46
+ # One small model call; the reservation is the ceiling, not the expected spend.
47
+ budget: {usd: 0.5}
48
+ series: hello
49
+ approvers: data-owners
50
+
51
+ steps:
52
+ # ── the agentic leg ───────────────────────────────────────────────────────
53
+ - id: assistant
54
+ label: Answer from the docs
55
+ template: assistant
56
+ with:
57
+ question: $input.question
58
+
59
+ # ── a threshold on the answer (read-only) ─────────────────────────────────
60
+ # `crossed` is true when the answer runs longer than max_words. The word count
61
+ # comes from the MCP tool the template called.
62
+ - id: length
63
+ label: Check the answer's length
64
+ task: monitor/threshold
65
+ depends_on: [assistant]
66
+ with:
67
+ metric: answer words
68
+ value: $steps.assistant.answer_stats.words
69
+ threshold: $input.max_words
70
+ comparator: gt
71
+
72
+ # ── the PERSON gate ───────────────────────────────────────────────────────
73
+ - gate: review
74
+ depends_on: [length]
75
+ approvers: data-owners
76
+ show:
77
+ - assistant.answer
78
+ - assistant.reranker
79
+ - length.value
80
+ - length.crossed
81
+
82
+ outputs:
83
+ answer: assistant.answer
84
+ words: length.value
85
+ too_long: length.crossed
@@ -0,0 +1,108 @@
1
+ # pipelines/nightly-ops.yaml — the operations pipeline (hello-cairn WP6, ADR-0127 D1).
2
+ #
3
+ # job (template: secret check → SSH report) → summarise (flow)
4
+ # → propose ⏸ → audit
5
+ #
6
+ # A job that runs every night without a person starting it, and still asks one
7
+ # before anything follows from it:
8
+ #
9
+ # * `job` (flows/nightly-job.md) checks the job's secret through whatever the
10
+ # profile binds to `secrets.store` (secrets/env by default, Infisical with
11
+ # profiles/hello-infisical.toml), then runs `command` on an SSH box through the
12
+ # builtin remote/exec. The box is a `compute.ssh` connection (`cairn connect add
13
+ # ssh`) the `compute_target` input names; a template node is where remote/exec
14
+ # gets one (a `task:` leg has no `execution.compute_target`).
15
+ # * `summarise` carries the report through the builtin dataflow engine.
16
+ # * `propose` files the follow-up for a person: monitor/propose declares
17
+ # `requires_approval`, so this leg waits on the approval queue.
18
+ # * `audit` writes a hash-chained audit row.
19
+ #
20
+ # Every leg that settles fires the `leg.finished` run hook; this pack's
21
+ # `hello-receipts` listener (hooks.py) appends a receipt line for it.
22
+ name: nightly-ops
23
+ kind: pipeline
24
+ visibility: public
25
+ description: >
26
+ Check a secret, run a report on an SSH box, carry it through a flow, propose
27
+ the follow-up to a person, and audit it. Runs nightly once enabled.
28
+ outcome: Run a nightly job on a box you own
29
+ suite: operate
30
+
31
+ inputs:
32
+ compute_target: {type: str, default: "hello-ssh", description: "The compute.ssh connection to run the report on (cairn connect add ssh --name hello-ssh …)."}
33
+ secret_name: {type: str, default: "HELLO_REPORT_TOKEN", description: "The secret the job needs; checked for presence, never read into the run."}
34
+ command:
35
+ type: str
36
+ default: "printf '{\"lines\": %s, \"host\": \"%s\"}' \"$(ls / | wc -l | tr -d ' ')\" \"$(hostname)\" > metrics.json && cat metrics.json"
37
+ description: "The report command. It writes metrics.json in its working directory; remote/exec reads it back."
38
+ max_lines: {type: int, default: 100, description: "The proposal's threshold, shown on the approval card."}
39
+
40
+ governance:
41
+ budget: {usd: 0.1}
42
+ series: hello
43
+ approvers: ops
44
+
45
+ triggers:
46
+ # The nightly run. `enabled: true` arms it when the pipeline is enabled in a
47
+ # workspace; until then it is a declaration.
48
+ - kind: schedule
49
+ cron: "30 2 * * *"
50
+ misfire: skip
51
+ enabled: true
52
+ description: "Every night at 02:30 (engine time)."
53
+ # Rows past the watermark in a Postgres table start a proposal. A `table` source
54
+ # needs a database the engine can reach: here the DSN is read from the env var
55
+ # HELLO_EVENTS_DSN at fire time, never written in this file. Declared, NOT armed on a
56
+ # pipeline: a `table` watch needs the platform's governed import, so core names it
57
+ # unarmed (ADR-0131 D3) — it shows in the console as declared, not armed here.
58
+ - kind: watch
59
+ source: {kind: table, dsn_env: HELLO_EVENTS_DSN, table: hello_events, cursor: id}
60
+ every: "*/10 * * * *"
61
+ register_as: hello-events
62
+ on_change: propose
63
+ enabled: false
64
+ description: "Propose a run when hello_events gains rows (needs Postgres)."
65
+
66
+ steps:
67
+ # ── the job: secret check + SSH report (template leg) ─────────────────────
68
+ - id: job
69
+ label: Run the nightly report
70
+ template: nightly-job
71
+ with:
72
+ compute_target: $input.compute_target
73
+ secret_name: $input.secret_name
74
+ command: $input.command
75
+
76
+ # ── carry the report through a flow ───────────────────────────────────────
77
+ - id: summarise
78
+ label: Summarise the report
79
+ flow: summarise
80
+ depends_on: [job]
81
+
82
+ # ── propose the follow-up (waits for a person) ────────────────────────────
83
+ - id: propose
84
+ label: Propose the follow-up
85
+ task: monitor/propose
86
+ depends_on: [summarise]
87
+ with:
88
+ metric: report lines
89
+ value: $default($steps.job.report.lines, 0)
90
+ threshold: $input.max_lines
91
+ proposed_workflow: "pipeline:hello-cairn/profile-and-publish"
92
+
93
+ # ── the audit record ──────────────────────────────────────────────────────
94
+ - id: audit
95
+ label: Record the nightly run
96
+ task: audit/store
97
+ depends_on: [propose]
98
+ with:
99
+ action: hello.nightly.reported
100
+ outcome: ok
101
+ actor: ops
102
+ target: $steps.job.job_host
103
+
104
+ outputs:
105
+ host: job.job_host
106
+ report: job.report
107
+ approved: propose.approved
108
+ audit_hash: audit.hash