@pennixrv/trellis 0.6.27 → 0.6.28
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.
- package/dist/migrations/manifests/0.6.27.json +9 -0
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/ownership_record.py +414 -0
- package/dist/templates/trellis/scripts/task.py +114 -1
- package/package.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.6.27",
|
|
3
|
+
"description": "Hardens the task-bound continuation record for safe cross-session handoff state.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Fixes:**\n- Rechecks task identity while a continuation record operation acquires its lock.\n- Keeps continuation writes bound to the direct current Trellis session.\n\n**Internal:**\n- Keeps continuation runtime state outside version-controlled project files.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "No project migration is required. Existing projects can run the normal `trellis update` flow."
|
|
9
|
+
}
|
|
@@ -37,6 +37,7 @@ export declare const commonTaskContext: string;
|
|
|
37
37
|
export declare const commonTaskStore: string;
|
|
38
38
|
export declare const commonSessionContext: string;
|
|
39
39
|
export declare const commonContinuationRecord: string;
|
|
40
|
+
export declare const commonOwnershipRecord: string;
|
|
40
41
|
export declare const commonPackagesContext: string;
|
|
41
42
|
export declare const commonWorkflowPhase: string;
|
|
42
43
|
export declare const commonTrellisConfig: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAcH,eAAO,MAAM,WAAW,QAAsC,CAAC;AAG/D,eAAO,MAAM,UAAU,QAA6C,CAAC;AACrE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,QAA8C,CAAC;AAC3E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,YAAY,QAA2C,CAAC;AACrE,eAAO,MAAM,QAAQ,QAAuC,CAAC;AAC7D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAChF,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,oBAAoB,QAEhC,CAAC;AACF,eAAO,MAAM,wBAAwB,QAEpC,CAAC;AACF,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAG9E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAC7E,eAAO,MAAM,UAAU,QAAkC,CAAC;AAC1D,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAG7E,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,iBAAiB,QAAgC,CAAC;AAC/D,eAAO,MAAM,qBAAqB,QAAoC,CAAC;AAMvE,eAAO,MAAM,sBAAsB,QAAsC,CAAC;AAC1E,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAAoC,CAAC;AAEtE;;GAEG;AACH,wBAAgB,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAcH,eAAO,MAAM,WAAW,QAAsC,CAAC;AAG/D,eAAO,MAAM,UAAU,QAA6C,CAAC;AACrE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,QAA8C,CAAC;AAC3E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,YAAY,QAA2C,CAAC;AACrE,eAAO,MAAM,QAAQ,QAAuC,CAAC;AAC7D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAChF,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,oBAAoB,QAEhC,CAAC;AACF,eAAO,MAAM,wBAAwB,QAEpC,CAAC;AACF,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAG9E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAC7E,eAAO,MAAM,UAAU,QAAkC,CAAC;AAC1D,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAG7E,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,iBAAiB,QAAgC,CAAC;AAC/D,eAAO,MAAM,qBAAqB,QAAoC,CAAC;AAMvE,eAAO,MAAM,sBAAsB,QAAsC,CAAC;AAC1E,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAAoC,CAAC;AAEtE;;GAEG;AACH,wBAAgB,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAyCnD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAMlD"}
|
|
@@ -47,6 +47,7 @@ export const commonTaskContext = readTemplate("scripts/common/task_context.py");
|
|
|
47
47
|
export const commonTaskStore = readTemplate("scripts/common/task_store.py");
|
|
48
48
|
export const commonSessionContext = readTemplate("scripts/common/session_context.py");
|
|
49
49
|
export const commonContinuationRecord = readTemplate("scripts/common/continuation_record.py");
|
|
50
|
+
export const commonOwnershipRecord = readTemplate("scripts/common/ownership_record.py");
|
|
50
51
|
export const commonPackagesContext = readTemplate("scripts/common/packages_context.py");
|
|
51
52
|
export const commonWorkflowPhase = readTemplate("scripts/common/workflow_phase.py");
|
|
52
53
|
export const commonTrellisConfig = readTemplate("scripts/common/trellis_config.py");
|
|
@@ -97,6 +98,7 @@ export function getAllScripts() {
|
|
|
97
98
|
scripts.set("common/task_store.py", commonTaskStore);
|
|
98
99
|
scripts.set("common/session_context.py", commonSessionContext);
|
|
99
100
|
scripts.set("common/continuation_record.py", commonContinuationRecord);
|
|
101
|
+
scripts.set("common/ownership_record.py", commonOwnershipRecord);
|
|
100
102
|
scripts.set("common/packages_context.py", commonPackagesContext);
|
|
101
103
|
scripts.set("common/workflow_phase.py", commonWorkflowPhase);
|
|
102
104
|
scripts.set("common/trellis_config.py", commonTrellisConfig);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Task-bound ownership transfer for formal cross-session handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import tempfile
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .active_task import clear_active_task, resolve_active_task, resolve_context_key, set_active_task
|
|
14
|
+
from .continuation_record import (
|
|
15
|
+
ContinuationError,
|
|
16
|
+
SAFE_ID,
|
|
17
|
+
SHA256,
|
|
18
|
+
_digest,
|
|
19
|
+
_project_file,
|
|
20
|
+
_record_lock,
|
|
21
|
+
_safe_task_id,
|
|
22
|
+
_text,
|
|
23
|
+
)
|
|
24
|
+
from .io import read_json_checked
|
|
25
|
+
from .paths import FILE_TASK_JSON, get_repo_root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
KIND = "trellis-handoff-ownership"
|
|
29
|
+
SCHEMA_VERSION = 1
|
|
30
|
+
STATES = {"quiescing", "sealed", "retiring", "ready", "claiming", "claimed", "consumed", "archived"}
|
|
31
|
+
ARCHIVE_OBSERVATIONS = {"not_required", "observed"}
|
|
32
|
+
MAX_RECORD_BYTES = 32 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OwnershipError(ContinuationError):
|
|
36
|
+
"""Raised when a handoff ownership transition is unsafe or stale."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _identity(value: Any, label: str) -> str:
|
|
40
|
+
value = _text(value, label, 160)
|
|
41
|
+
if not SAFE_ID.fullmatch(value):
|
|
42
|
+
raise OwnershipError(f"{label} is unsafe")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _record_path(root: Path, task_id: str, handoff_id: str) -> Path:
|
|
47
|
+
task_id = _safe_task_id(task_id)
|
|
48
|
+
handoff_id = _identity(handoff_id, "handoff_id")
|
|
49
|
+
runtime = root / ".trellis" / ".runtime"
|
|
50
|
+
records = runtime / "handoff-ownership"
|
|
51
|
+
task_dir = records / task_id
|
|
52
|
+
if runtime.is_symlink() or records.is_symlink() or task_dir.is_symlink():
|
|
53
|
+
raise OwnershipError("ownership record directory is unsafe")
|
|
54
|
+
return task_dir / f"{handoff_id}.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _now() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _task_snapshot(root: Path, task_path: str) -> dict[str, str]:
|
|
62
|
+
path = _project_file(root, task_path, "task path")
|
|
63
|
+
relative = path.relative_to(root).as_posix()
|
|
64
|
+
if not relative.startswith(".trellis/tasks/"):
|
|
65
|
+
raise OwnershipError("task path is outside .trellis/tasks")
|
|
66
|
+
data, reason = read_json_checked(path / FILE_TASK_JSON)
|
|
67
|
+
if data is None:
|
|
68
|
+
raise OwnershipError(f"task.json is {reason}")
|
|
69
|
+
task_id = _safe_task_id(data.get("id") or data.get("name"))
|
|
70
|
+
return {"id": task_id, "path": relative, "status": _text(data.get("status"), "task.status", 64)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _direct_context(root: Path, require_task: bool = False) -> tuple[str, Any]:
|
|
74
|
+
context_key = resolve_context_key()
|
|
75
|
+
if not context_key:
|
|
76
|
+
raise OwnershipError("no_direct_session_identity")
|
|
77
|
+
active = resolve_active_task(
|
|
78
|
+
root,
|
|
79
|
+
allow_single_session_fallback=False,
|
|
80
|
+
allow_environment_context=True,
|
|
81
|
+
)
|
|
82
|
+
if active.source_type == "session-fallback":
|
|
83
|
+
raise OwnershipError("session_fallback_untrusted")
|
|
84
|
+
if active.context_key != context_key:
|
|
85
|
+
raise OwnershipError("direct_session_identity_unstable")
|
|
86
|
+
if require_task and not active.task_path:
|
|
87
|
+
raise OwnershipError("no_direct_current_task")
|
|
88
|
+
return context_key, active
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _new_record(task: dict[str, str], handoff_id: str, core_digest: str, source: str) -> dict[str, Any]:
|
|
92
|
+
if not SHA256.fullmatch(core_digest):
|
|
93
|
+
raise OwnershipError("core_digest is invalid")
|
|
94
|
+
return {
|
|
95
|
+
"schema_version": SCHEMA_VERSION,
|
|
96
|
+
"kind": KIND,
|
|
97
|
+
"handoff_id": _identity(handoff_id, "handoff_id"),
|
|
98
|
+
"core_digest": core_digest,
|
|
99
|
+
"task": task,
|
|
100
|
+
"source_session_id": source,
|
|
101
|
+
"source_context_key": source,
|
|
102
|
+
"consumer_session_id": None,
|
|
103
|
+
"consumer_context_key": None,
|
|
104
|
+
"state": "quiescing",
|
|
105
|
+
"generation": 0,
|
|
106
|
+
"fencing_token": secrets.token_hex(16),
|
|
107
|
+
"archive_observation": None,
|
|
108
|
+
"event_id": f"event-{secrets.token_hex(12)}",
|
|
109
|
+
"previous_event_digest": None,
|
|
110
|
+
"updated_at": _now(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _record_digest(record: dict[str, Any]) -> str:
|
|
115
|
+
return _digest({key: value for key, value in record.items() if key != "integrity"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode(path: Path) -> dict[str, Any]:
|
|
119
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_RECORD_BYTES:
|
|
120
|
+
raise OwnershipError("ownership record is unsafe or too large")
|
|
121
|
+
data, reason = read_json_checked(path)
|
|
122
|
+
if data is None:
|
|
123
|
+
raise OwnershipError(f"ownership record is {reason}")
|
|
124
|
+
required = {
|
|
125
|
+
"schema_version", "kind", "handoff_id", "core_digest", "task",
|
|
126
|
+
"source_session_id", "source_context_key", "consumer_session_id",
|
|
127
|
+
"consumer_context_key", "state", "generation", "fencing_token",
|
|
128
|
+
"archive_observation", "event_id", "previous_event_digest", "updated_at", "integrity",
|
|
129
|
+
}
|
|
130
|
+
if set(data) != required or data["schema_version"] != SCHEMA_VERSION or data["kind"] != KIND:
|
|
131
|
+
raise OwnershipError("ownership record schema is unsupported")
|
|
132
|
+
_identity(data["handoff_id"], "handoff_id")
|
|
133
|
+
if not SHA256.fullmatch(data["core_digest"]):
|
|
134
|
+
raise OwnershipError("core_digest is invalid")
|
|
135
|
+
task = data["task"]
|
|
136
|
+
if not isinstance(task, dict) or set(task) != {"id", "path", "status"}:
|
|
137
|
+
raise OwnershipError("ownership task is invalid")
|
|
138
|
+
_safe_task_id(task["id"])
|
|
139
|
+
_text(task["path"], "task.path", 1024)
|
|
140
|
+
_text(task["status"], "task.status", 64)
|
|
141
|
+
for key in ("source_session_id", "source_context_key"):
|
|
142
|
+
_identity(data[key], key)
|
|
143
|
+
for key in ("consumer_session_id", "consumer_context_key"):
|
|
144
|
+
if data[key] is not None:
|
|
145
|
+
_identity(data[key], key)
|
|
146
|
+
if data["state"] not in STATES or not isinstance(data["generation"], int) or data["generation"] < 0:
|
|
147
|
+
raise OwnershipError("ownership state is invalid")
|
|
148
|
+
_identity(data["fencing_token"], "fencing_token")
|
|
149
|
+
if data["archive_observation"] is not None and data["archive_observation"] not in ARCHIVE_OBSERVATIONS:
|
|
150
|
+
raise OwnershipError("archive observation is invalid")
|
|
151
|
+
_identity(data["event_id"], "event_id")
|
|
152
|
+
if data["previous_event_digest"] is not None and not SHA256.fullmatch(data["previous_event_digest"]):
|
|
153
|
+
raise OwnershipError("previous event digest is invalid")
|
|
154
|
+
_text(data["updated_at"], "updated_at", 128)
|
|
155
|
+
integrity = data["integrity"]
|
|
156
|
+
if not isinstance(integrity, dict) or set(integrity) != {"record_digest"} or not SHA256.fullmatch(integrity["record_digest"]):
|
|
157
|
+
raise OwnershipError("ownership integrity is invalid")
|
|
158
|
+
if integrity["record_digest"] != _record_digest(data):
|
|
159
|
+
raise OwnershipError("ownership record digest does not match")
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write(path: Path, record: dict[str, Any]) -> str:
|
|
164
|
+
record["event_id"] = f"event-{secrets.token_hex(12)}"
|
|
165
|
+
record["updated_at"] = _now()
|
|
166
|
+
record["integrity"] = {"record_digest": _record_digest(record)}
|
|
167
|
+
encoded = json.dumps(record, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
|
|
168
|
+
if len(encoded.encode("utf-8")) > MAX_RECORD_BYTES:
|
|
169
|
+
raise OwnershipError("ownership record is too large")
|
|
170
|
+
temporary: Path | None = None
|
|
171
|
+
try:
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
174
|
+
temporary = Path(handle.name)
|
|
175
|
+
handle.write(encoded)
|
|
176
|
+
handle.flush()
|
|
177
|
+
os.fsync(handle.fileno())
|
|
178
|
+
os.chmod(temporary, 0o600)
|
|
179
|
+
os.replace(temporary, path)
|
|
180
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
181
|
+
try:
|
|
182
|
+
os.fsync(directory_fd)
|
|
183
|
+
finally:
|
|
184
|
+
os.close(directory_fd)
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
if temporary is not None:
|
|
187
|
+
temporary.unlink(missing_ok=True)
|
|
188
|
+
raise OwnershipError("ownership record atomic write failed") from exc
|
|
189
|
+
return record["integrity"]["record_digest"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load(root: Path, task_id: str, handoff_id: str) -> tuple[Path, dict[str, Any]]:
|
|
193
|
+
path = _record_path(root, task_id, handoff_id)
|
|
194
|
+
if not path.exists():
|
|
195
|
+
raise OwnershipError("ownership record is absent")
|
|
196
|
+
return path, _decode(path)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check(record: dict[str, Any], core_digest: str, expected_generation: int, actor: str) -> None:
|
|
200
|
+
if record["core_digest"] != core_digest:
|
|
201
|
+
raise OwnershipError("core_digest does not match")
|
|
202
|
+
if record["generation"] != expected_generation:
|
|
203
|
+
raise OwnershipError("expected ownership generation does not match")
|
|
204
|
+
if record["source_session_id"] != actor and record.get("consumer_session_id") != actor:
|
|
205
|
+
raise OwnershipError("session is not an ownership actor")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _public(record: dict[str, Any]) -> dict[str, Any]:
|
|
209
|
+
return {
|
|
210
|
+
"status": record["state"],
|
|
211
|
+
"handoff_id": record["handoff_id"],
|
|
212
|
+
"core_digest": record["core_digest"],
|
|
213
|
+
"task": record["task"],
|
|
214
|
+
"source_session_id": record["source_session_id"],
|
|
215
|
+
"consumer_session_id": record["consumer_session_id"],
|
|
216
|
+
"generation": record["generation"],
|
|
217
|
+
"archive_observation": record["archive_observation"],
|
|
218
|
+
"record_digest": record["integrity"]["record_digest"],
|
|
219
|
+
"updated_at": record["updated_at"],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def quiesce(root: Path, task_path: str, handoff_id: str, core_digest: str, source_session_id: str) -> dict[str, Any]:
|
|
224
|
+
actor, active = _direct_context(root, require_task=True)
|
|
225
|
+
task = _task_snapshot(root, task_path)
|
|
226
|
+
if active.source_type != "session" or active.context_key != actor or active.task_path != task["path"]:
|
|
227
|
+
raise OwnershipError("source task is not the direct current task")
|
|
228
|
+
if _identity(source_session_id, "source_session_id") != actor:
|
|
229
|
+
raise OwnershipError("source_session_id does not match direct session")
|
|
230
|
+
path = _record_path(root, task["id"], handoff_id)
|
|
231
|
+
with _record_lock(path):
|
|
232
|
+
if path.exists():
|
|
233
|
+
raise OwnershipError("ownership record already exists")
|
|
234
|
+
record = _new_record(task, handoff_id, core_digest, actor)
|
|
235
|
+
digest = _write(path, record)
|
|
236
|
+
return {"status": "quiescing", "generation": 0, "record_digest": digest}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def seal(root: Path, task_id: str, handoff_id: str, core_digest: str, expected_generation: int) -> dict[str, Any]:
|
|
240
|
+
actor, _ = _direct_context(root)
|
|
241
|
+
path, record = _load(root, task_id, handoff_id)
|
|
242
|
+
with _record_lock(path):
|
|
243
|
+
record = _decode(path)
|
|
244
|
+
_check(record, core_digest, expected_generation, actor)
|
|
245
|
+
if record["state"] != "quiescing" or record["source_session_id"] != actor:
|
|
246
|
+
raise OwnershipError("ownership record is not sealable by source")
|
|
247
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
248
|
+
record["state"] = "sealed"
|
|
249
|
+
record["generation"] += 1
|
|
250
|
+
digest = _write(path, record)
|
|
251
|
+
return _public(record) | {"record_digest": digest}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def retire(root: Path, task_id: str, handoff_id: str, core_digest: str, expected_generation: int, archive_observation: str) -> dict[str, Any]:
|
|
255
|
+
if archive_observation not in ARCHIVE_OBSERVATIONS:
|
|
256
|
+
raise OwnershipError("archive observation is invalid")
|
|
257
|
+
actor, active = _direct_context(root)
|
|
258
|
+
path, record = _load(root, task_id, handoff_id)
|
|
259
|
+
with _record_lock(path):
|
|
260
|
+
record = _decode(path)
|
|
261
|
+
_check(record, core_digest, expected_generation, actor)
|
|
262
|
+
if record["source_session_id"] != actor or record["state"] not in {"sealed", "retiring"}:
|
|
263
|
+
raise OwnershipError("ownership record is not retireable by source")
|
|
264
|
+
if record["state"] == "sealed":
|
|
265
|
+
if active.task_path != record["task"]["path"]:
|
|
266
|
+
raise OwnershipError("source task is not bound for retirement")
|
|
267
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
268
|
+
record["state"] = "retiring"
|
|
269
|
+
record["archive_observation"] = archive_observation
|
|
270
|
+
record["generation"] += 1
|
|
271
|
+
record["fencing_token"] = secrets.token_hex(16)
|
|
272
|
+
_write(path, record)
|
|
273
|
+
elif record["archive_observation"] != archive_observation:
|
|
274
|
+
raise OwnershipError("archive observation does not match retirement")
|
|
275
|
+
|
|
276
|
+
active = resolve_active_task(root, allow_single_session_fallback=False)
|
|
277
|
+
if active.context_key != actor:
|
|
278
|
+
raise OwnershipError("direct_session_identity_unstable")
|
|
279
|
+
if active.task_path == record["task"]["path"]:
|
|
280
|
+
clear_active_task(root)
|
|
281
|
+
elif active.task_path:
|
|
282
|
+
raise OwnershipError("source task pointer changed during retirement")
|
|
283
|
+
active = resolve_active_task(root, allow_single_session_fallback=False)
|
|
284
|
+
if active.context_key != actor or active.task_path:
|
|
285
|
+
raise OwnershipError("source task retirement could not be verified; recovery_required")
|
|
286
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
287
|
+
record["state"] = "ready"
|
|
288
|
+
record["generation"] += 1
|
|
289
|
+
digest = _write(path, record)
|
|
290
|
+
return _public(record) | {"record_digest": digest}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def claim(root: Path, task_id: str, task_path: str, handoff_id: str, core_digest: str, expected_generation: int) -> dict[str, Any]:
|
|
294
|
+
actor, active = _direct_context(root)
|
|
295
|
+
path, record = _load(root, task_id, handoff_id)
|
|
296
|
+
with _record_lock(path):
|
|
297
|
+
record = _decode(path)
|
|
298
|
+
if record["core_digest"] != core_digest:
|
|
299
|
+
raise OwnershipError("expected ownership claim does not match")
|
|
300
|
+
if record["state"] == "claimed" and record.get("consumer_session_id") == actor:
|
|
301
|
+
if active.task_path != record["task"]["path"]:
|
|
302
|
+
raise OwnershipError("claimed consumer task pointer is missing")
|
|
303
|
+
return _public(record)
|
|
304
|
+
if record["state"] == "claiming" and record.get("consumer_session_id") == actor:
|
|
305
|
+
if active.task_path and active.task_path != record["task"]["path"]:
|
|
306
|
+
raise OwnershipError("target task pointer changed during claim; recovery_required")
|
|
307
|
+
if not active.task_path:
|
|
308
|
+
bound = set_active_task(record["task"]["path"], root)
|
|
309
|
+
if not bound:
|
|
310
|
+
raise OwnershipError("target task binding failed; recovery_required")
|
|
311
|
+
active = resolve_active_task(root, allow_single_session_fallback=False)
|
|
312
|
+
if active.context_key != actor or active.task_path != record["task"]["path"]:
|
|
313
|
+
raise OwnershipError("target task binding could not be verified; recovery_required")
|
|
314
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
315
|
+
record["state"] = "claimed"
|
|
316
|
+
record["generation"] += 1
|
|
317
|
+
digest = _write(path, record)
|
|
318
|
+
return _public(record) | {"record_digest": digest}
|
|
319
|
+
if record["generation"] != expected_generation:
|
|
320
|
+
raise OwnershipError("expected ownership claim does not match")
|
|
321
|
+
if record["state"] != "ready":
|
|
322
|
+
raise OwnershipError("ownership record is not claimable")
|
|
323
|
+
if active.task_path:
|
|
324
|
+
raise OwnershipError("target session already has a direct task")
|
|
325
|
+
task = _task_snapshot(root, task_path)
|
|
326
|
+
if task != record["task"]:
|
|
327
|
+
raise OwnershipError("target task does not match ownership record")
|
|
328
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
329
|
+
record["state"] = "claiming"
|
|
330
|
+
record["consumer_session_id"] = actor
|
|
331
|
+
record["consumer_context_key"] = actor
|
|
332
|
+
record["generation"] += 1
|
|
333
|
+
record["fencing_token"] = secrets.token_hex(16)
|
|
334
|
+
_write(path, record)
|
|
335
|
+
bound = set_active_task(task["path"], root)
|
|
336
|
+
if not bound:
|
|
337
|
+
raise OwnershipError("target task binding failed; recovery_required")
|
|
338
|
+
active = resolve_active_task(root, allow_single_session_fallback=False)
|
|
339
|
+
if active.context_key != actor or active.task_path != task["path"]:
|
|
340
|
+
raise OwnershipError("target task binding could not be verified; recovery_required")
|
|
341
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
342
|
+
record["state"] = "claimed"
|
|
343
|
+
record["generation"] += 1
|
|
344
|
+
digest = _write(path, record)
|
|
345
|
+
return _public(record) | {"record_digest": digest}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def consume(root: Path, task_id: str, handoff_id: str, core_digest: str, expected_generation: int) -> dict[str, Any]:
|
|
349
|
+
actor, _ = _direct_context(root, require_task=True)
|
|
350
|
+
path, record = _load(root, task_id, handoff_id)
|
|
351
|
+
with _record_lock(path):
|
|
352
|
+
record = _decode(path)
|
|
353
|
+
if record["state"] == "consumed" and record.get("consumer_session_id") == actor:
|
|
354
|
+
return _public(record)
|
|
355
|
+
_check(record, core_digest, expected_generation, actor)
|
|
356
|
+
if record["state"] != "claimed" or record.get("consumer_session_id") != actor:
|
|
357
|
+
raise OwnershipError("ownership record is not consumable by current consumer")
|
|
358
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
359
|
+
record["state"] = "consumed"
|
|
360
|
+
record["generation"] += 1
|
|
361
|
+
digest = _write(path, record)
|
|
362
|
+
return _public(record) | {"record_digest": digest}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def archive(root: Path, task_id: str, handoff_id: str, core_digest: str, expected_generation: int) -> dict[str, Any]:
|
|
366
|
+
actor, _ = _direct_context(root, require_task=True)
|
|
367
|
+
path, record = _load(root, task_id, handoff_id)
|
|
368
|
+
with _record_lock(path):
|
|
369
|
+
record = _decode(path)
|
|
370
|
+
if record["state"] == "archived" and record.get("consumer_session_id") == actor:
|
|
371
|
+
return _public(record)
|
|
372
|
+
_check(record, core_digest, expected_generation, actor)
|
|
373
|
+
if record["state"] != "consumed" or record.get("consumer_session_id") != actor:
|
|
374
|
+
raise OwnershipError("ownership record is not archiveable by current consumer")
|
|
375
|
+
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
376
|
+
record["state"] = "archived"
|
|
377
|
+
record["generation"] += 1
|
|
378
|
+
digest = _write(path, record)
|
|
379
|
+
return _public(record) | {"record_digest": digest}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def status(root: Path | None, task_id: str, handoff_id: str, core_digest: str) -> dict[str, Any]:
|
|
383
|
+
root = (root or get_repo_root()).resolve()
|
|
384
|
+
_, record = _load(root, task_id, handoff_id)
|
|
385
|
+
if record["core_digest"] != core_digest:
|
|
386
|
+
raise OwnershipError("core_digest does not match")
|
|
387
|
+
return _public(record)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def assert_task_mutation_allowed(root: Path, task_path: Path) -> None:
|
|
391
|
+
"""Reject ordinary task pointer changes that would bypass ownership fencing."""
|
|
392
|
+
actor = resolve_context_key()
|
|
393
|
+
if not actor:
|
|
394
|
+
return
|
|
395
|
+
try:
|
|
396
|
+
task_id = _safe_task_id(task_path.name)
|
|
397
|
+
except ContinuationError:
|
|
398
|
+
return
|
|
399
|
+
relative = task_path.relative_to(root).as_posix()
|
|
400
|
+
records_dir = root / ".trellis" / ".runtime" / "handoff-ownership" / task_id
|
|
401
|
+
if not records_dir.is_dir() or records_dir.is_symlink():
|
|
402
|
+
return
|
|
403
|
+
for record_path in sorted(records_dir.glob("*.json")):
|
|
404
|
+
if record_path.is_symlink():
|
|
405
|
+
raise OwnershipError("ownership record is unsafe")
|
|
406
|
+
record = _decode(record_path)
|
|
407
|
+
if record["state"] not in STATES:
|
|
408
|
+
continue
|
|
409
|
+
if record["task"]["path"] != relative:
|
|
410
|
+
raise OwnershipError("ownership record task path does not match current task")
|
|
411
|
+
if record.get("consumer_session_id") == actor:
|
|
412
|
+
continue
|
|
413
|
+
if record["source_session_id"] == actor or record["state"] in {"retiring", "ready", "claiming", "claimed", "consumed", "archived"}:
|
|
414
|
+
raise OwnershipError("fencing_conflict: task is owned by another handoff session")
|
|
@@ -10,6 +10,7 @@ Usage:
|
|
|
10
10
|
python3 task.py list-context <dir> # List jsonl entries
|
|
11
11
|
python3 task.py start <dir> # Set active task, record current branch
|
|
12
12
|
python3 task.py current [--source] [--json] # Show active task
|
|
13
|
+
python3 task.py ownership <operation> ... # Manage formal handoff task ownership
|
|
13
14
|
python3 task.py finish # Clear active task
|
|
14
15
|
python3 task.py set-branch <dir> <branch> # Set git branch
|
|
15
16
|
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
|
@@ -81,6 +82,17 @@ from common.continuation_record import (
|
|
|
81
82
|
seal as seal_continuity,
|
|
82
83
|
status as continuity_status,
|
|
83
84
|
)
|
|
85
|
+
from common.ownership_record import (
|
|
86
|
+
OwnershipError,
|
|
87
|
+
archive as archive_ownership,
|
|
88
|
+
assert_task_mutation_allowed,
|
|
89
|
+
claim as claim_ownership,
|
|
90
|
+
consume as consume_ownership,
|
|
91
|
+
quiesce as quiesce_ownership,
|
|
92
|
+
retire as retire_ownership,
|
|
93
|
+
seal as seal_ownership,
|
|
94
|
+
status as ownership_status,
|
|
95
|
+
)
|
|
84
96
|
|
|
85
97
|
|
|
86
98
|
# =============================================================================
|
|
@@ -198,6 +210,12 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
198
210
|
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
|
199
211
|
return 1
|
|
200
212
|
|
|
213
|
+
try:
|
|
214
|
+
assert_task_mutation_allowed(repo_root, full_path)
|
|
215
|
+
except (OwnershipError, OSError) as exc:
|
|
216
|
+
print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
|
|
217
|
+
return 2
|
|
218
|
+
|
|
201
219
|
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
|
|
202
220
|
# manifest means every sub-agent dispatched for this task runs with zero
|
|
203
221
|
# spec context, and nothing downstream surfaces that to the main session.
|
|
@@ -282,15 +300,22 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
282
300
|
def cmd_finish(args: argparse.Namespace) -> int:
|
|
283
301
|
"""Clear active task."""
|
|
284
302
|
repo_root = get_repo_root()
|
|
285
|
-
active =
|
|
303
|
+
active = resolve_active_task(repo_root)
|
|
286
304
|
current = active.task_path
|
|
287
305
|
|
|
288
306
|
if not current:
|
|
289
307
|
print(colored("No current task set", Colors.YELLOW))
|
|
290
308
|
return 0
|
|
291
309
|
|
|
310
|
+
try:
|
|
311
|
+
assert_task_mutation_allowed(repo_root, repo_root / current)
|
|
312
|
+
except (OwnershipError, OSError) as exc:
|
|
313
|
+
print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
|
|
314
|
+
return 2
|
|
315
|
+
|
|
292
316
|
# Resolve task.json path before clearing
|
|
293
317
|
task_json_path = repo_root / current / FILE_TASK_JSON
|
|
318
|
+
clear_active_task(repo_root)
|
|
294
319
|
|
|
295
320
|
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
|
296
321
|
print(f"Source: {active.source}")
|
|
@@ -382,6 +407,54 @@ def cmd_continuity(args: argparse.Namespace) -> int:
|
|
|
382
407
|
return 2
|
|
383
408
|
|
|
384
409
|
|
|
410
|
+
def cmd_ownership(args: argparse.Namespace) -> int:
|
|
411
|
+
"""Manage the task-bound formal handoff ownership record."""
|
|
412
|
+
repo_root = get_repo_root()
|
|
413
|
+
if args.ownership_command == "status":
|
|
414
|
+
try:
|
|
415
|
+
result = ownership_status(repo_root, args.task_id, args.handoff_id, args.core_digest)
|
|
416
|
+
except (OwnershipError, OSError) as exc:
|
|
417
|
+
print(json.dumps({"status": "withheld", "reason": str(exc)}, ensure_ascii=False, sort_keys=True))
|
|
418
|
+
return 2
|
|
419
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
420
|
+
return 0
|
|
421
|
+
if not getattr(args, "explicit_user_request", False):
|
|
422
|
+
print(colored("Error: ownership writes require --explicit-user-request", Colors.RED), file=sys.stderr)
|
|
423
|
+
return 2
|
|
424
|
+
try:
|
|
425
|
+
command = args.ownership_command
|
|
426
|
+
if command == "quiesce":
|
|
427
|
+
result = quiesce_ownership(
|
|
428
|
+
repo_root, args.task, args.handoff_id, args.core_digest, args.source_session_id
|
|
429
|
+
)
|
|
430
|
+
elif command == "seal":
|
|
431
|
+
result = seal_ownership(
|
|
432
|
+
repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
|
|
433
|
+
)
|
|
434
|
+
elif command == "retire":
|
|
435
|
+
result = retire_ownership(
|
|
436
|
+
repo_root, args.task_id, args.handoff_id, args.core_digest,
|
|
437
|
+
args.expected_generation, args.archive_observation,
|
|
438
|
+
)
|
|
439
|
+
elif command == "claim":
|
|
440
|
+
result = claim_ownership(
|
|
441
|
+
repo_root, args.task_id, args.task, args.handoff_id, args.core_digest, args.expected_generation
|
|
442
|
+
)
|
|
443
|
+
elif command == "consume":
|
|
444
|
+
result = consume_ownership(
|
|
445
|
+
repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
|
|
446
|
+
)
|
|
447
|
+
else:
|
|
448
|
+
result = archive_ownership(
|
|
449
|
+
repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
|
|
450
|
+
)
|
|
451
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
452
|
+
return 0
|
|
453
|
+
except (OwnershipError, OSError) as exc:
|
|
454
|
+
print(json.dumps({"status": "withheld", "reason": str(exc)}, ensure_ascii=False, sort_keys=True))
|
|
455
|
+
return 2
|
|
456
|
+
|
|
457
|
+
|
|
385
458
|
# =============================================================================
|
|
386
459
|
# Command: list
|
|
387
460
|
# =============================================================================
|
|
@@ -743,6 +816,45 @@ def main() -> int:
|
|
|
743
816
|
continuity_clear.add_argument("--expected", required=True, help="The current record digest or absent")
|
|
744
817
|
continuity_clear.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
|
|
745
818
|
|
|
819
|
+
# ownership
|
|
820
|
+
p_ownership = subparsers.add_parser("ownership", help="Manage formal handoff task ownership")
|
|
821
|
+
ownership_sub = p_ownership.add_subparsers(dest="ownership_command", required=True)
|
|
822
|
+
|
|
823
|
+
def add_ownership_common(parser, *, expected=False):
|
|
824
|
+
parser.add_argument("--task-id", required=True, help="Task id bound to the handoff")
|
|
825
|
+
parser.add_argument("--handoff-id", required=True, help="Immutable handoff id")
|
|
826
|
+
parser.add_argument("--core-digest", required=True, help="Immutable handoff core digest")
|
|
827
|
+
if expected:
|
|
828
|
+
parser.add_argument("--expected-generation", required=True, type=int)
|
|
829
|
+
parser.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
|
|
830
|
+
|
|
831
|
+
ownership_quiesce = ownership_sub.add_parser("quiesce", help="Begin source handoff quiescence")
|
|
832
|
+
ownership_quiesce.add_argument("--task", required=True, help="Current task path")
|
|
833
|
+
ownership_quiesce.add_argument("--source-session-id", required=True)
|
|
834
|
+
add_ownership_common(ownership_quiesce)
|
|
835
|
+
|
|
836
|
+
ownership_seal = ownership_sub.add_parser("seal", help="Seal the source ownership boundary")
|
|
837
|
+
add_ownership_common(ownership_seal, expected=True)
|
|
838
|
+
|
|
839
|
+
ownership_retire = ownership_sub.add_parser("retire", help="Retire source and expose handoff")
|
|
840
|
+
ownership_retire.add_argument("--archive-observation", required=True, choices=("not_required", "observed"))
|
|
841
|
+
add_ownership_common(ownership_retire, expected=True)
|
|
842
|
+
|
|
843
|
+
ownership_claim = ownership_sub.add_parser("claim", help="Claim a ready handoff as this session")
|
|
844
|
+
ownership_claim.add_argument("--task", required=True, help="Task path to bind to this session")
|
|
845
|
+
add_ownership_common(ownership_claim, expected=True)
|
|
846
|
+
|
|
847
|
+
ownership_consume = ownership_sub.add_parser("consume", help="Record target consumption")
|
|
848
|
+
add_ownership_common(ownership_consume, expected=True)
|
|
849
|
+
|
|
850
|
+
ownership_archive = ownership_sub.add_parser("archive", help="Record post-consume retention archive")
|
|
851
|
+
add_ownership_common(ownership_archive, expected=True)
|
|
852
|
+
|
|
853
|
+
ownership_status_parser = ownership_sub.add_parser("status", help="Show ownership status")
|
|
854
|
+
add_ownership_common(ownership_status_parser)
|
|
855
|
+
ownership_status_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
856
|
+
ownership_status_parser.set_defaults(explicit_user_request=True)
|
|
857
|
+
|
|
746
858
|
# finish
|
|
747
859
|
subparsers.add_parser("finish", help="Clear active task")
|
|
748
860
|
|
|
@@ -824,6 +936,7 @@ def main() -> int:
|
|
|
824
936
|
"start": cmd_start,
|
|
825
937
|
"current": cmd_current,
|
|
826
938
|
"continuity": cmd_continuity,
|
|
939
|
+
"ownership": cmd_ownership,
|
|
827
940
|
"finish": cmd_finish,
|
|
828
941
|
"set-branch": cmd_set_branch,
|
|
829
942
|
"set-base-branch": cmd_set_base_branch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pennixrv/trellis",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.28",
|
|
4
4
|
"description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"inquirer": "^9.3.7",
|
|
35
35
|
"undici": "^6.21.0",
|
|
36
36
|
"zod": "^4.4.2",
|
|
37
|
-
"@pennixrv/trellis-core": "0.6.
|
|
37
|
+
"@pennixrv/trellis-core": "0.6.28"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^9.18.0",
|