@pennixrv/trellis 0.7.0-beta.10 → 0.7.0-beta.11
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/commands/channel/adapters/codex.d.ts +2 -1
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
- package/dist/commands/channel/adapters/codex.js +10 -7
- package/dist/commands/channel/adapters/codex.js.map +1 -1
- package/dist/commands/channel/adapters/index.d.ts +2 -0
- package/dist/commands/channel/adapters/index.d.ts.map +1 -1
- package/dist/commands/channel/adapters/index.js +1 -1
- package/dist/commands/channel/adapters/index.js.map +1 -1
- package/dist/commands/channel/index.d.ts.map +1 -1
- package/dist/commands/channel/index.js +6 -0
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/profiles.d.ts +26 -0
- package/dist/commands/channel/profiles.d.ts.map +1 -0
- package/dist/commands/channel/profiles.js +186 -0
- package/dist/commands/channel/profiles.js.map +1 -0
- package/dist/commands/channel/spawn.d.ts +3 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +22 -3
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/channel/supervisor.d.ts +8 -0
- package/dist/commands/channel/supervisor.d.ts.map +1 -1
- package/dist/commands/channel/supervisor.js +19 -0
- package/dist/commands/channel/supervisor.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
- package/dist/templates/trellis/agents/subnode-profiles.json +14 -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/subnode_artifact.py +263 -0
- package/package.json +2 -2
|
@@ -9,6 +9,7 @@ dispatches workers, makes the acceptance judgment, or mutates task state.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
|
+
import hashlib
|
|
12
13
|
import json
|
|
13
14
|
import re
|
|
14
15
|
import sys
|
|
@@ -26,6 +27,7 @@ SCHEMA_VERSION = 2
|
|
|
26
27
|
MAX_DRAFT_BYTES = 64 * 1024
|
|
27
28
|
MAX_REPORT_BYTES = 128 * 1024
|
|
28
29
|
MAX_WORKLOG_BYTES = 128 * 1024
|
|
30
|
+
MAX_QUEUE_BYTES = 128 * 1024
|
|
29
31
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
30
32
|
CHECKPOINT_RE = re.compile(r"^<!-- trellis-checkpoint: (?P<payload>\{.*\}) -->$", re.MULTILINE)
|
|
31
33
|
SECRET_PATTERNS = (
|
|
@@ -154,6 +156,98 @@ def _relative_to_repo(path: Path, repo_root: Path) -> str:
|
|
|
154
156
|
_fail(f"path is outside repository: {path}")
|
|
155
157
|
|
|
156
158
|
|
|
159
|
+
def _queue_paths(task_dir: Path, work_id: str) -> tuple[Path, Path]:
|
|
160
|
+
work_dir = task_dir / "subnodes" / work_id
|
|
161
|
+
_assert_no_subpath_symlinks(task_dir, work_dir)
|
|
162
|
+
if not work_dir.exists() or not work_dir.is_dir():
|
|
163
|
+
_fail(f"queue work directory does not exist: {work_dir}")
|
|
164
|
+
return work_dir / "queue.json", work_dir / "queue-abandoned.json"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _queue_context(args: argparse.Namespace) -> tuple[Path, str, Path, Path, Path]:
|
|
168
|
+
repo_root = get_repo_root()
|
|
169
|
+
task_dir = resolve_task_dir(args.task, repo_root)
|
|
170
|
+
if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
|
|
171
|
+
_fail("--task must identify a direct active task directory")
|
|
172
|
+
if task_dir.is_symlink():
|
|
173
|
+
_fail(f"refusing symlinked task directory: {task_dir}")
|
|
174
|
+
work_id = _require_id(args.work_id, "--work-id")
|
|
175
|
+
task_id = _task_id(task_dir)
|
|
176
|
+
queue_path, abandoned_path = _queue_paths(task_dir, work_id)
|
|
177
|
+
for path in (queue_path, abandoned_path):
|
|
178
|
+
if path.is_symlink():
|
|
179
|
+
_fail(f"refusing symlinked queue artifact: {path}")
|
|
180
|
+
return repo_root, task_id, task_dir, queue_path, abandoned_path
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _write_json_exclusive(path: Path, value: dict[str, Any], label: str) -> None:
|
|
184
|
+
if path.is_symlink():
|
|
185
|
+
_fail(f"refusing symlinked {label}: {path}")
|
|
186
|
+
try:
|
|
187
|
+
with path.open("x", encoding="utf-8") as stream:
|
|
188
|
+
json.dump(value, stream, indent=2, ensure_ascii=False)
|
|
189
|
+
stream.write("\n")
|
|
190
|
+
except FileExistsError:
|
|
191
|
+
_fail(f"{label} already exists and cannot be replaced: {path}")
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
_fail(f"could not write {label} {path}: {exc}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_queue_file(
|
|
197
|
+
queue_path: Path,
|
|
198
|
+
task_dir: Path,
|
|
199
|
+
task_id: str,
|
|
200
|
+
work_id: str,
|
|
201
|
+
repo_root: Path,
|
|
202
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
203
|
+
queue, _raw = _read_json_object(queue_path, MAX_QUEUE_BYTES, "queue")
|
|
204
|
+
if queue.get("schema_version") != 1:
|
|
205
|
+
_fail("queue.schema_version must be 1")
|
|
206
|
+
if queue.get("task_id") != task_id:
|
|
207
|
+
_fail("queue.task_id does not match the task")
|
|
208
|
+
if queue.get("work_id") != work_id:
|
|
209
|
+
_fail("queue.work_id does not match the requested work")
|
|
210
|
+
channel = queue.get("channel_ref")
|
|
211
|
+
if not isinstance(channel, dict):
|
|
212
|
+
_fail("queue.channel_ref must be an object")
|
|
213
|
+
channel_name = _require_text(channel.get("name"), "queue.channel_ref.name", max_len=128)
|
|
214
|
+
channel_scope = _require_text(channel.get("scope"), "queue.channel_ref.scope", max_len=32)
|
|
215
|
+
if channel_scope not in {"project", "global"}:
|
|
216
|
+
_fail("queue.channel_ref.scope must be project or global")
|
|
217
|
+
items = queue.get("items")
|
|
218
|
+
if not isinstance(items, list) or not items:
|
|
219
|
+
_fail("queue.items must be a non-empty list")
|
|
220
|
+
seen: set[str] = set()
|
|
221
|
+
validated: list[dict[str, Any]] = []
|
|
222
|
+
for index, item in enumerate(items):
|
|
223
|
+
if not isinstance(item, dict):
|
|
224
|
+
_fail(f"queue.items[{index}] must be an object")
|
|
225
|
+
subnode_id = _require_id(item.get("subnode_id"), f"queue.items[{index}].subnode_id")
|
|
226
|
+
if subnode_id in seen:
|
|
227
|
+
_fail("queue.items contains duplicate subnode_id values")
|
|
228
|
+
seen.add(subnode_id)
|
|
229
|
+
brief_path = _require_text(item.get("brief_path"), f"queue.items[{index}].brief_path")
|
|
230
|
+
brief, raw, brief_file, node_dir, brief_task_dir = _validate_brief_file(
|
|
231
|
+
brief_path, repo_root
|
|
232
|
+
)
|
|
233
|
+
if brief_task_dir != task_dir:
|
|
234
|
+
_fail(f"queue item {subnode_id} brief belongs to another task")
|
|
235
|
+
if brief.get("work_id") != work_id or brief.get("subnode_id") != subnode_id:
|
|
236
|
+
_fail(f"queue item {subnode_id} brief identity does not match")
|
|
237
|
+
expected_brief_path = _relative_to_repo(node_dir / "brief.json", repo_root)
|
|
238
|
+
if _relative_to_repo(brief_file, repo_root) != expected_brief_path:
|
|
239
|
+
_fail(f"queue item {subnode_id} brief_path is not canonical")
|
|
240
|
+
if brief.get("channel_ref", {}).get("name") != channel_name:
|
|
241
|
+
_fail(f"queue item {subnode_id} channel name does not match the queue")
|
|
242
|
+
if brief.get("channel_ref", {}).get("scope") != channel_scope:
|
|
243
|
+
_fail(f"queue item {subnode_id} channel scope does not match the queue")
|
|
244
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
245
|
+
if item.get("brief_digest") != digest:
|
|
246
|
+
_fail(f"queue item {subnode_id} brief digest does not match")
|
|
247
|
+
validated.append({"subnode_id": subnode_id, "brief_path": brief_path, "brief_digest": digest})
|
|
248
|
+
return queue, validated
|
|
249
|
+
|
|
250
|
+
|
|
157
251
|
def _validate_snapshot(value: Any) -> None:
|
|
158
252
|
if not isinstance(value, list) or not value:
|
|
159
253
|
_fail("source_snapshot must be a non-empty list")
|
|
@@ -595,6 +689,145 @@ def _init(args: argparse.Namespace) -> None:
|
|
|
595
689
|
}))
|
|
596
690
|
|
|
597
691
|
|
|
692
|
+
def _queue_init(args: argparse.Namespace) -> None:
|
|
693
|
+
repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
|
|
694
|
+
if queue_path.exists() or abandoned_path.exists():
|
|
695
|
+
_fail("queue already exists or has been abandoned")
|
|
696
|
+
if not args.brief:
|
|
697
|
+
_fail("queue init requires at least one --brief")
|
|
698
|
+
work_id = _require_id(args.work_id, "--work-id")
|
|
699
|
+
channel_name = _require_text(args.channel_name, "--channel-name", max_len=128)
|
|
700
|
+
channel_scope = _require_text(args.channel_scope, "--channel-scope", max_len=32)
|
|
701
|
+
if channel_scope not in {"project", "global"}:
|
|
702
|
+
_fail("--channel-scope must be project or global")
|
|
703
|
+
items: list[dict[str, str]] = []
|
|
704
|
+
seen: set[str] = set()
|
|
705
|
+
for brief_arg in args.brief:
|
|
706
|
+
brief, raw, brief_path, node_dir, brief_task_dir = _validate_brief_file(
|
|
707
|
+
brief_arg, repo_root
|
|
708
|
+
)
|
|
709
|
+
subnode_id = _require_id(brief.get("subnode_id"), "brief.subnode_id")
|
|
710
|
+
if brief_task_dir != task_dir:
|
|
711
|
+
_fail(f"queue item {subnode_id} brief belongs to another task")
|
|
712
|
+
if brief.get("work_id") != work_id:
|
|
713
|
+
_fail(f"queue item {subnode_id} brief belongs to another work")
|
|
714
|
+
if subnode_id in seen:
|
|
715
|
+
_fail("queue init contains duplicate subnode_id values")
|
|
716
|
+
if brief.get("channel_ref", {}).get("name") != channel_name:
|
|
717
|
+
_fail(f"queue item {subnode_id} channel name does not match")
|
|
718
|
+
if brief.get("channel_ref", {}).get("scope") != channel_scope:
|
|
719
|
+
_fail(f"queue item {subnode_id} channel scope does not match")
|
|
720
|
+
expected_path = _relative_to_repo(node_dir / "brief.json", repo_root)
|
|
721
|
+
if _relative_to_repo(brief_path, repo_root) != expected_path:
|
|
722
|
+
_fail(f"queue item {subnode_id} brief path is not canonical")
|
|
723
|
+
seen.add(subnode_id)
|
|
724
|
+
items.append({
|
|
725
|
+
"subnode_id": subnode_id,
|
|
726
|
+
"brief_path": expected_path,
|
|
727
|
+
"brief_digest": hashlib.sha256(raw).hexdigest(),
|
|
728
|
+
})
|
|
729
|
+
queue = {
|
|
730
|
+
"schema_version": 1,
|
|
731
|
+
"task_id": task_id,
|
|
732
|
+
"work_id": work_id,
|
|
733
|
+
"channel_ref": {"name": channel_name, "scope": channel_scope},
|
|
734
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
735
|
+
"items": items,
|
|
736
|
+
}
|
|
737
|
+
_write_json_exclusive(queue_path, queue, "queue")
|
|
738
|
+
print(json.dumps({"queue": _relative_to_repo(queue_path, repo_root), "items": items}))
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _queue_validate(args: argparse.Namespace) -> None:
|
|
742
|
+
repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
|
|
743
|
+
_queue, items = _validate_queue_file(
|
|
744
|
+
queue_path, task_dir, task_id, args.work_id, repo_root
|
|
745
|
+
)
|
|
746
|
+
print(json.dumps({
|
|
747
|
+
"status": "abandoned" if abandoned_path.exists() else "valid",
|
|
748
|
+
"queue": _relative_to_repo(queue_path, repo_root),
|
|
749
|
+
"task_id": task_id,
|
|
750
|
+
"work_id": args.work_id,
|
|
751
|
+
"items": items,
|
|
752
|
+
"item_count": len(items),
|
|
753
|
+
}))
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _queue_claim(args: argparse.Namespace) -> None:
|
|
757
|
+
repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
|
|
758
|
+
if abandoned_path.exists():
|
|
759
|
+
_fail("queue has been abandoned; no further dispatch claim is allowed")
|
|
760
|
+
_queue, items = _validate_queue_file(
|
|
761
|
+
queue_path, task_dir, task_id, args.work_id, repo_root
|
|
762
|
+
)
|
|
763
|
+
subnode_id = _require_id(args.subnode_id, "--subnode-id")
|
|
764
|
+
item = next((candidate for candidate in items if candidate["subnode_id"] == subnode_id), None)
|
|
765
|
+
if item is None:
|
|
766
|
+
_fail(f"subnode is not listed in the queue: {subnode_id}")
|
|
767
|
+
claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
|
|
768
|
+
_assert_no_subpath_symlinks(task_dir, claim_path)
|
|
769
|
+
claim = {
|
|
770
|
+
"schema_version": 1,
|
|
771
|
+
"task_id": task_id,
|
|
772
|
+
"work_id": args.work_id,
|
|
773
|
+
"subnode_id": subnode_id,
|
|
774
|
+
"brief_digest": item["brief_digest"],
|
|
775
|
+
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
776
|
+
}
|
|
777
|
+
_write_json_exclusive(claim_path, claim, "dispatch claim")
|
|
778
|
+
print(json.dumps({
|
|
779
|
+
"claim": _relative_to_repo(claim_path, repo_root),
|
|
780
|
+
"subnode_id": subnode_id,
|
|
781
|
+
"brief_digest": item["brief_digest"],
|
|
782
|
+
}))
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _queue_abandon(args: argparse.Namespace) -> None:
|
|
786
|
+
repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
|
|
787
|
+
_queue, items = _validate_queue_file(
|
|
788
|
+
queue_path, task_dir, task_id, args.work_id, repo_root
|
|
789
|
+
)
|
|
790
|
+
item_ids = {item["subnode_id"] for item in items}
|
|
791
|
+
claimed_ids: set[str] = set()
|
|
792
|
+
for item in items:
|
|
793
|
+
subnode_id = item["subnode_id"]
|
|
794
|
+
claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
|
|
795
|
+
_assert_no_subpath_symlinks(task_dir, claim_path)
|
|
796
|
+
if not claim_path.exists():
|
|
797
|
+
continue
|
|
798
|
+
claim, _raw = _read_json_object(claim_path, MAX_DRAFT_BYTES, "dispatch claim")
|
|
799
|
+
if (
|
|
800
|
+
claim.get("schema_version") != 1
|
|
801
|
+
or claim.get("task_id") != task_id
|
|
802
|
+
or claim.get("work_id") != args.work_id
|
|
803
|
+
or claim.get("subnode_id") != subnode_id
|
|
804
|
+
or claim.get("brief_digest") != item["brief_digest"]
|
|
805
|
+
):
|
|
806
|
+
_fail(f"dispatch claim does not match queue item {subnode_id}")
|
|
807
|
+
claimed_ids.add(subnode_id)
|
|
808
|
+
dispatched = [_require_id(value, "--dispatched") for value in (args.dispatched or [])]
|
|
809
|
+
pending = [_require_id(value, "--pending") for value in (args.pending or [])]
|
|
810
|
+
if len(set(dispatched)) != len(dispatched) or len(set(pending)) != len(pending):
|
|
811
|
+
_fail("queue abandonment lists must not contain duplicates")
|
|
812
|
+
if not set(dispatched).issubset(item_ids) or not set(pending).issubset(item_ids):
|
|
813
|
+
_fail("queue abandonment lists must contain only queued subnode IDs")
|
|
814
|
+
if set(dispatched) & set(pending):
|
|
815
|
+
_fail("a subnode cannot be both dispatched and pending")
|
|
816
|
+
if set(dispatched) != claimed_ids or set(pending) != item_ids - claimed_ids:
|
|
817
|
+
_fail("queue abandonment lists must exactly match dispatch claims and remaining queued items")
|
|
818
|
+
abandoned = {
|
|
819
|
+
"schema_version": 1,
|
|
820
|
+
"task_id": task_id,
|
|
821
|
+
"work_id": args.work_id,
|
|
822
|
+
"reason": _require_text(args.reason, "--reason"),
|
|
823
|
+
"dispatched": dispatched,
|
|
824
|
+
"pending": pending,
|
|
825
|
+
"abandoned_at": datetime.now(timezone.utc).isoformat(),
|
|
826
|
+
}
|
|
827
|
+
_write_json_exclusive(abandoned_path, abandoned, "queue abandonment")
|
|
828
|
+
print(json.dumps({"status": "abandoned", "queue": _relative_to_repo(abandoned_path, repo_root)}))
|
|
829
|
+
|
|
830
|
+
|
|
598
831
|
def _validate(args: argparse.Namespace) -> None:
|
|
599
832
|
repo_root = get_repo_root()
|
|
600
833
|
report, _evidence, _brief, _node_dir_value, concerns = _validate_report_file(args.report, repo_root)
|
|
@@ -718,6 +951,36 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
718
951
|
counter.add_argument("--primary", required=True, help="primary subnode artifact directory")
|
|
719
952
|
counter.add_argument("--counter", required=True, help="counter subnode artifact directory")
|
|
720
953
|
counter.set_defaults(handler=_validate_counter)
|
|
954
|
+
|
|
955
|
+
queue = subparsers.add_parser("queue", help="manage one write-once subnode queue")
|
|
956
|
+
queue_parsers = queue.add_subparsers(dest="queue_command", required=True)
|
|
957
|
+
|
|
958
|
+
queue_init = queue_parsers.add_parser("init", help="write one FIFO queue manifest")
|
|
959
|
+
queue_init.add_argument("--task", required=True)
|
|
960
|
+
queue_init.add_argument("--work-id", required=True)
|
|
961
|
+
queue_init.add_argument("--channel-name", required=True)
|
|
962
|
+
queue_init.add_argument("--channel-scope", required=True)
|
|
963
|
+
queue_init.add_argument("--brief", action="append", required=True)
|
|
964
|
+
queue_init.set_defaults(handler=_queue_init)
|
|
965
|
+
|
|
966
|
+
queue_validate = queue_parsers.add_parser("validate", help="validate a queue manifest")
|
|
967
|
+
queue_validate.add_argument("--task", required=True)
|
|
968
|
+
queue_validate.add_argument("--work-id", required=True)
|
|
969
|
+
queue_validate.set_defaults(handler=_queue_validate)
|
|
970
|
+
|
|
971
|
+
queue_claim = queue_parsers.add_parser("claim", help="write one dispatch intent")
|
|
972
|
+
queue_claim.add_argument("--task", required=True)
|
|
973
|
+
queue_claim.add_argument("--work-id", required=True)
|
|
974
|
+
queue_claim.add_argument("--subnode-id", required=True)
|
|
975
|
+
queue_claim.set_defaults(handler=_queue_claim)
|
|
976
|
+
|
|
977
|
+
queue_abandon = queue_parsers.add_parser("abandon", help="stop a queue permanently")
|
|
978
|
+
queue_abandon.add_argument("--task", required=True)
|
|
979
|
+
queue_abandon.add_argument("--work-id", required=True)
|
|
980
|
+
queue_abandon.add_argument("--reason", required=True)
|
|
981
|
+
queue_abandon.add_argument("--dispatched", action="append")
|
|
982
|
+
queue_abandon.add_argument("--pending", action="append")
|
|
983
|
+
queue_abandon.set_defaults(handler=_queue_abandon)
|
|
721
984
|
return parser
|
|
722
985
|
|
|
723
986
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pennixrv/trellis",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.11",
|
|
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.7.0-beta.
|
|
37
|
+
"@pennixrv/trellis-core": "0.7.0-beta.11"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^9.18.0",
|