prodc 0.3.61__tar.gz → 0.3.63__tar.gz
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.
- {prodc-0.3.61 → prodc-0.3.63}/PKG-INFO +1 -1
- {prodc-0.3.61 → prodc-0.3.63}/pyproject.toml +1 -1
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/agent.md +1 -0
- prodc-0.3.63/src/prodc/board.py +218 -0
- prodc-0.3.63/src/prodc/callbacks.py +195 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/cli.py +106 -97
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/config.py +52 -3
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/model.py +5 -7
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/report.py +10 -18
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/report_html.py +26 -73
- prodc-0.3.63/tests/test_callbacks.py +216 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_command_hook.py +1 -3
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_description.py +2 -6
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_report.py +1 -3
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_report_html.py +9 -7
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_sources.py +12 -3
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_status.py +3 -1
- {prodc-0.3.61 → prodc-0.3.63}/.gitignore +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/CHANGELOG.md +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/LICENSE +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/README.md +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/features/orders.feature +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/needs/jordan.yaml +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/examples/quickstart/prodc.toml +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/__init__.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/adapters.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/dedup.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/loader.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/py.typed +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/status.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/src/prodc/writer.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_adapters.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_cli.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_dedup.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_drafts.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_external_adapter.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_gitlab_source.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_importance.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_loader.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_models.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_roles.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_staleness.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_suggest.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_variants.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_who_variants.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_writer.py +0 -0
- {prodc-0.3.61 → prodc-0.3.63}/tests/test_writer_comments.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prodc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.63
|
|
4
4
|
Summary: Product management as code: roles, flows, and features traced to evidence.
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
|
|
6
6
|
Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
|
|
@@ -76,6 +76,7 @@ Read first, then act, then check.
|
|
|
76
76
|
| record a human/AI review of the warrant | `prodc attest <id> --by <who> --date <ISO> [--ai]` |
|
|
77
77
|
| record a human acceptance pass | `prodc accept <id> --who <person> --date <ISO>` |
|
|
78
78
|
| hand a human a readable status file | `prodc report -o status.md` (markdown; `--format html` for an interactive viewer; `--client ID` to scope) |
|
|
79
|
+
| act on a need going green (notify a tracker, etc.) | `prodc sync` — runs the `[callbacks]` commands for needs whose state changed since the last sync (`--dry-run` to preview) |
|
|
79
80
|
| gate in CI | `prodc lint --strict` |
|
|
80
81
|
|
|
81
82
|
Less often: `prodc argument` (the GSN assurance graph and its defects), `prodc portfolio <toml>…`
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Gather the resolved board once, for every renderer to consume.
|
|
2
|
+
|
|
3
|
+
`prodc status`, `prodc report` (markdown), and `prodc report --format html` each need the
|
|
4
|
+
same thing: resolve every need, roll it up, and compute the per-goal / per-release / per-role
|
|
5
|
+
coverage, the handoffs, the recurrence, and the orphan proofs. They used to do that gather
|
|
6
|
+
three separate times, which is how the three outputs could silently drift. :func:`gather` does
|
|
7
|
+
it once and returns a :class:`ResolvedBoard`; the renderers format that struct. :func:`warrant`
|
|
8
|
+
and :func:`need_payload` are the one user-signal string and the one Need-to-dict serialiser the
|
|
9
|
+
board and the HTML viewer share, so a need looks the same wherever it is rendered.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import date
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .model import (
|
|
20
|
+
Goal,
|
|
21
|
+
Model,
|
|
22
|
+
Need,
|
|
23
|
+
NeedResult,
|
|
24
|
+
Release,
|
|
25
|
+
Role,
|
|
26
|
+
SourceKind,
|
|
27
|
+
ears_shape,
|
|
28
|
+
)
|
|
29
|
+
from .status import (
|
|
30
|
+
Handoff,
|
|
31
|
+
Recurrence,
|
|
32
|
+
Summary,
|
|
33
|
+
evidence_age_days,
|
|
34
|
+
flow_orphan_warnings,
|
|
35
|
+
goal_report,
|
|
36
|
+
handoffs,
|
|
37
|
+
last_observed,
|
|
38
|
+
orphans,
|
|
39
|
+
recurrence,
|
|
40
|
+
release_report,
|
|
41
|
+
resolve,
|
|
42
|
+
role_report,
|
|
43
|
+
shared_proof_warnings,
|
|
44
|
+
summarize,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warrant(need: Need) -> str:
|
|
49
|
+
"""The warrant column: the strongest source kind, or 'opinion' when there is no source at all.
|
|
50
|
+
|
|
51
|
+
A `primary` source names its person (`primary:who`); `secondary`/`assumption` show the kind. A
|
|
52
|
+
need with any source is shown by that kind even if it is an opinion (only an assumption) — the
|
|
53
|
+
opinion roll-up lives in the gap counts, this column shows the literal evidence tier.
|
|
54
|
+
"""
|
|
55
|
+
strongest = need.strongest_kind # the one source-strength ranking lives on the model
|
|
56
|
+
if strongest is None:
|
|
57
|
+
return "opinion"
|
|
58
|
+
if strongest is SourceKind.PRIMARY:
|
|
59
|
+
who = next((s.who for s in need.sources if s.kind is SourceKind.PRIMARY and s.who), None)
|
|
60
|
+
return f"primary:{who}" if who else "primary"
|
|
61
|
+
return strongest.value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def annotate_board_warnings(results: list[NeedResult], needs: list[Need]) -> None:
|
|
65
|
+
"""Append the cross-need hygiene warnings (shared proofs #45, seam coverage #44)."""
|
|
66
|
+
shared = shared_proof_warnings(results)
|
|
67
|
+
flow_orphans = flow_orphan_warnings(needs)
|
|
68
|
+
for r in results:
|
|
69
|
+
if r.need.id in shared:
|
|
70
|
+
r.warnings.append(shared[r.need.id])
|
|
71
|
+
if r.need.id in flow_orphans:
|
|
72
|
+
r.warnings.append("belongs to no flow — seam not covered (#44)")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -> dict[str, object]:
|
|
76
|
+
"""One need serialised to a plain dict — the single Need-to-JSON used by the board and viewer.
|
|
77
|
+
|
|
78
|
+
``flavor="board"`` is the status/`show` payload (the full record); ``flavor="html"`` is the
|
|
79
|
+
report viewer's flatter row. They carry different fields in a different order, so the two
|
|
80
|
+
shapes live here together, byte-for-byte as they were, rather than as copies in two modules.
|
|
81
|
+
"""
|
|
82
|
+
need = r.need
|
|
83
|
+
if flavor == "html":
|
|
84
|
+
strongest = need.strongest_kind
|
|
85
|
+
return {
|
|
86
|
+
"id": need.id,
|
|
87
|
+
"text": need.text,
|
|
88
|
+
"description": need.description,
|
|
89
|
+
"state": r.state.value,
|
|
90
|
+
"must": need.must,
|
|
91
|
+
"who": need.who or "(unassigned)",
|
|
92
|
+
"label": need.label,
|
|
93
|
+
"client": need.client,
|
|
94
|
+
"opinion": need.is_opinion,
|
|
95
|
+
"warrant": strongest.value if strongest is not None else "opinion",
|
|
96
|
+
"importance": need.importance,
|
|
97
|
+
"goal": need.goal,
|
|
98
|
+
"release": need.release,
|
|
99
|
+
"claim": need.claim,
|
|
100
|
+
"proofs": list(need.proofs),
|
|
101
|
+
"detail": list(r.detail),
|
|
102
|
+
"warnings": list(r.warnings),
|
|
103
|
+
"attestation": (
|
|
104
|
+
{
|
|
105
|
+
"who": need.attestation.who,
|
|
106
|
+
"date": need.attestation.date,
|
|
107
|
+
"kind": need.attestation.kind.value,
|
|
108
|
+
"verdict": need.attestation.verdict,
|
|
109
|
+
}
|
|
110
|
+
if need.attestation
|
|
111
|
+
else None
|
|
112
|
+
),
|
|
113
|
+
"sources": [
|
|
114
|
+
{
|
|
115
|
+
"kind": s.kind.value,
|
|
116
|
+
"who": s.who,
|
|
117
|
+
"date": s.date,
|
|
118
|
+
"quote": s.quote,
|
|
119
|
+
"locator": s.locator,
|
|
120
|
+
}
|
|
121
|
+
for s in need.sources
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
"id": need.id,
|
|
126
|
+
"who": need.who,
|
|
127
|
+
"role": need.role,
|
|
128
|
+
"text": need.text,
|
|
129
|
+
"description": need.description,
|
|
130
|
+
"must": need.must,
|
|
131
|
+
"state": r.state.value,
|
|
132
|
+
"grounds": r.state.value,
|
|
133
|
+
"warrant": warrant(need),
|
|
134
|
+
"shape": ears_shape(need.text).value,
|
|
135
|
+
"client": need.client,
|
|
136
|
+
"variant_of": need.variant_of,
|
|
137
|
+
"goal": need.goal,
|
|
138
|
+
"release": need.release,
|
|
139
|
+
"product": need.product,
|
|
140
|
+
"draft": need.draft,
|
|
141
|
+
"importance": need.importance,
|
|
142
|
+
"importance_set_at": need.importance_set_at,
|
|
143
|
+
"claim": need.claim,
|
|
144
|
+
"attestation": (
|
|
145
|
+
{
|
|
146
|
+
"who": need.attestation.who,
|
|
147
|
+
"date": need.attestation.date,
|
|
148
|
+
"kind": need.attestation.kind.value,
|
|
149
|
+
"verdict": need.attestation.verdict,
|
|
150
|
+
"comment": need.attestation.comment,
|
|
151
|
+
}
|
|
152
|
+
if need.attestation
|
|
153
|
+
else None
|
|
154
|
+
),
|
|
155
|
+
"delivered": r.delivered,
|
|
156
|
+
"total": r.total,
|
|
157
|
+
"declared": r.declared,
|
|
158
|
+
"opinion": need.is_opinion,
|
|
159
|
+
"only_assumption": need.only_assumption,
|
|
160
|
+
"evidence_age": evidence_age_days(need, date.today()),
|
|
161
|
+
"last_observed_at": last_observed(need),
|
|
162
|
+
"detail": r.detail,
|
|
163
|
+
"warnings": r.warnings,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class ResolvedBoard:
|
|
169
|
+
"""The board resolved once: the results and every roll-up the renderers read off it."""
|
|
170
|
+
|
|
171
|
+
results: list[NeedResult]
|
|
172
|
+
summary: Summary
|
|
173
|
+
goal_cov: list[tuple[Goal, Summary]]
|
|
174
|
+
ungoaled: list[NeedResult]
|
|
175
|
+
rel_cov: list[tuple[Release, Summary]]
|
|
176
|
+
role_cov: list[tuple[Role, Summary]]
|
|
177
|
+
unroled: list[NeedResult]
|
|
178
|
+
handoffs: list[Handoff]
|
|
179
|
+
recurrence: list[Recurrence]
|
|
180
|
+
orphans: dict[str, list[str]]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def gather(
|
|
184
|
+
cfg: Config,
|
|
185
|
+
model: Model,
|
|
186
|
+
*,
|
|
187
|
+
needs: list[Need] | None = None,
|
|
188
|
+
annotate_warnings: bool = False,
|
|
189
|
+
) -> ResolvedBoard:
|
|
190
|
+
"""Resolve the board once and compute every roll-up a renderer needs.
|
|
191
|
+
|
|
192
|
+
``needs`` overrides which needs are resolved (the caller filters drafts/client/goal/stale
|
|
193
|
+
before calling and passes the result); it defaults to the whole model. ``annotate_warnings``
|
|
194
|
+
appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
|
|
195
|
+
the markdown/HTML reports leave it off, exactly as they did before this was factored out.
|
|
196
|
+
The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
|
|
197
|
+
are computed over the resolved ``needs``.
|
|
198
|
+
"""
|
|
199
|
+
use_needs = list(model.needs) if needs is None else needs
|
|
200
|
+
results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
|
|
201
|
+
if annotate_warnings:
|
|
202
|
+
annotate_board_warnings(results, use_needs)
|
|
203
|
+
summary = summarize(results)
|
|
204
|
+
goal_cov, ungoaled = goal_report(results, list(model.goals))
|
|
205
|
+
rel_cov = release_report(results, list(model.releases))
|
|
206
|
+
role_cov, unroled = role_report(results, list(model.roles))
|
|
207
|
+
return ResolvedBoard(
|
|
208
|
+
results=results,
|
|
209
|
+
summary=summary,
|
|
210
|
+
goal_cov=goal_cov,
|
|
211
|
+
ungoaled=ungoaled,
|
|
212
|
+
rel_cov=rel_cov,
|
|
213
|
+
role_cov=role_cov,
|
|
214
|
+
unroled=unroled,
|
|
215
|
+
handoffs=handoffs(use_needs),
|
|
216
|
+
recurrence=recurrence(use_needs),
|
|
217
|
+
orphans=orphans(use_needs, cfg.adapters),
|
|
218
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Lifecycle callbacks: run a command when a need changes state (#71).
|
|
2
|
+
|
|
3
|
+
The signal that a need is done already lives in prodc — its proof resolving to DELIVERED. This
|
|
4
|
+
lets a team act on that event: when a need's state changes, prodc runs a command from the project
|
|
5
|
+
config with the affected need (and board context) as JSON on **stdin**. Same trust model as the
|
|
6
|
+
command hook (#70): the command comes only from the config, never from need data; the need data is
|
|
7
|
+
piped as data, never executed.
|
|
8
|
+
|
|
9
|
+
A state change needs the *previous* state, which prodc (stateless per run — it re-reads every file)
|
|
10
|
+
does not otherwise keep. So a small ledger (``state_file``) records each need's last-seen state;
|
|
11
|
+
`prodc sync` resolves the board, diffs it against the ledger, fires the matching callbacks, and
|
|
12
|
+
writes the ledger forward. The first run records a baseline and fires nothing (everything would
|
|
13
|
+
look new). A callback that fails is reported, never silent, and its need is *not* advanced in the
|
|
14
|
+
ledger, so the next sync retries it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import cast
|
|
25
|
+
|
|
26
|
+
from .model import Need
|
|
27
|
+
|
|
28
|
+
#: The only event implemented today; others from #71 (capture/report/pre_gate) are follow-ups.
|
|
29
|
+
STATE_CHANGE = "state_change"
|
|
30
|
+
|
|
31
|
+
#: Runs an argv (no shell) with a JSON payload on stdin, returning (ok, short reason). Injected in
|
|
32
|
+
#: tests so no process spawns; :func:`_invoke` is the real subprocess default.
|
|
33
|
+
Invoker = Callable[[tuple[str, ...], str, Path, float], tuple[bool, str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Callback:
|
|
38
|
+
"""One configured lifecycle callback: an event, a command, and optional transition filters."""
|
|
39
|
+
|
|
40
|
+
event: str
|
|
41
|
+
command: tuple[str, ...]
|
|
42
|
+
timeout: float = 30.0
|
|
43
|
+
on_from: frozenset[str] = frozenset() # empty = any source state
|
|
44
|
+
on_to: frozenset[str] = frozenset() # empty = any target state
|
|
45
|
+
|
|
46
|
+
def matches(self, frm: str, to: str) -> bool:
|
|
47
|
+
"""Whether this callback fires for a transition ``frm -> to``."""
|
|
48
|
+
if self.on_from and frm not in self.on_from:
|
|
49
|
+
return False
|
|
50
|
+
return not (self.on_to and to not in self.on_to)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Transition:
|
|
55
|
+
"""A need whose resolved state changed since the last sync."""
|
|
56
|
+
|
|
57
|
+
need_id: str
|
|
58
|
+
frm: str
|
|
59
|
+
to: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Fired:
|
|
64
|
+
"""The outcome of running one callback for one transition."""
|
|
65
|
+
|
|
66
|
+
transition: Transition
|
|
67
|
+
command: tuple[str, ...]
|
|
68
|
+
ok: bool
|
|
69
|
+
detail: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_ledger(path: Path) -> dict[str, str] | None:
|
|
73
|
+
"""The last-seen state per need id, or ``None`` when no ledger exists yet (first run)."""
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
raw: object = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
return None
|
|
82
|
+
return {str(k): str(v) for k, v in cast("dict[object, object]", raw).items()}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_ledger(path: Path, states: dict[str, str]) -> None:
|
|
86
|
+
"""Persist the current state per need id (keys sorted for a stable, reviewable file)."""
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
body = json.dumps({k: states[k] for k in sorted(states)}, indent=2) + "\n"
|
|
89
|
+
path.write_text(body, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def transitions(ledger: dict[str, str], current: dict[str, str]) -> list[Transition]:
|
|
93
|
+
"""Needs whose state differs from the ledger.
|
|
94
|
+
|
|
95
|
+
A need absent from the ledger is *new*, not a transition — it is baselined silently and never
|
|
96
|
+
fired on, so adding a need does not masquerade as a state change.
|
|
97
|
+
"""
|
|
98
|
+
out = [
|
|
99
|
+
Transition(nid, ledger[nid], state)
|
|
100
|
+
for nid, state in current.items()
|
|
101
|
+
if nid in ledger and ledger[nid] != state
|
|
102
|
+
]
|
|
103
|
+
out.sort(key=lambda t: t.need_id)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fire(
|
|
108
|
+
trans: list[Transition],
|
|
109
|
+
callbacks: list[Callback],
|
|
110
|
+
*,
|
|
111
|
+
context: dict[str, Need],
|
|
112
|
+
board: dict[str, object],
|
|
113
|
+
cwd: Path,
|
|
114
|
+
invoke: Invoker | None = None,
|
|
115
|
+
) -> list[Fired]:
|
|
116
|
+
"""Run every callback matching each transition, piping the need + board as JSON on stdin.
|
|
117
|
+
|
|
118
|
+
``context`` maps a need id to its :class:`~prodc.model.Need` (for the payload). Returns one
|
|
119
|
+
:class:`Fired` per (transition, matching callback) actually run, in transition then config
|
|
120
|
+
order.
|
|
121
|
+
"""
|
|
122
|
+
run = invoke or _invoke
|
|
123
|
+
state_cbs = [c for c in callbacks if c.event == STATE_CHANGE]
|
|
124
|
+
out: list[Fired] = []
|
|
125
|
+
for t in trans:
|
|
126
|
+
need = context.get(t.need_id)
|
|
127
|
+
payload = json.dumps(
|
|
128
|
+
{
|
|
129
|
+
"event": STATE_CHANGE,
|
|
130
|
+
"from": t.frm,
|
|
131
|
+
"to": t.to,
|
|
132
|
+
"need": _need_json(need, t.to) if need else {"id": t.need_id, "state": t.to},
|
|
133
|
+
"board": board,
|
|
134
|
+
},
|
|
135
|
+
ensure_ascii=False,
|
|
136
|
+
)
|
|
137
|
+
for cb in state_cbs:
|
|
138
|
+
if cb.matches(t.frm, t.to):
|
|
139
|
+
ok, detail = run(cb.command, payload, cwd, cb.timeout)
|
|
140
|
+
out.append(Fired(t, cb.command, ok, detail))
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def advance_ledger(
|
|
145
|
+
current: dict[str, str], ledger: dict[str, str], fired: list[Fired]
|
|
146
|
+
) -> dict[str, str]:
|
|
147
|
+
"""The ledger to persist: every need at its current state, except a need whose callback failed
|
|
148
|
+
— that one keeps its prior state so the next sync retries it."""
|
|
149
|
+
failed = {f.transition.need_id for f in fired if not f.ok}
|
|
150
|
+
new = dict(current)
|
|
151
|
+
for nid in failed:
|
|
152
|
+
if nid in ledger: # do not advance a need whose callback failed; retry next sync
|
|
153
|
+
new[nid] = ledger[nid]
|
|
154
|
+
return new
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _invoke(command: tuple[str, ...], payload: str, cwd: Path, timeout: float) -> tuple[bool, str]:
|
|
158
|
+
"""Run ``command`` (no shell) with ``payload`` on stdin; report ok and a short reason.
|
|
159
|
+
|
|
160
|
+
Never raises: a spawn failure, a timeout, or a non-zero exit all come back as ``ok=False`` with
|
|
161
|
+
a reason, so a broken callback is reported, never silent and never fatal to the sync itself.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
proc = subprocess.run(
|
|
165
|
+
list(command),
|
|
166
|
+
input=payload,
|
|
167
|
+
cwd=cwd,
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=timeout,
|
|
171
|
+
check=False,
|
|
172
|
+
)
|
|
173
|
+
except subprocess.TimeoutExpired:
|
|
174
|
+
return False, f"timed out after {timeout:g}s"
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
return False, f"could not run ({exc})"
|
|
177
|
+
if proc.returncode == 0:
|
|
178
|
+
return True, "ok"
|
|
179
|
+
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
180
|
+
return False, f"exit {proc.returncode}" + (f": {tail[-1]}" if tail else "")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _need_json(need: Need, state: str) -> dict[str, object]:
|
|
184
|
+
strongest = need.strongest_kind
|
|
185
|
+
return {
|
|
186
|
+
"id": need.id,
|
|
187
|
+
"text": need.text,
|
|
188
|
+
"state": state,
|
|
189
|
+
"who": need.who,
|
|
190
|
+
"client": need.client,
|
|
191
|
+
"must": need.must,
|
|
192
|
+
"goal": need.goal,
|
|
193
|
+
"release": need.release,
|
|
194
|
+
"warrant": strongest.value if strongest is not None else "opinion",
|
|
195
|
+
}
|