Prechádzať zdrojové kódy

Per-saddle workspace roots: register handshake carries workspace_root, assignment paths follow the asking saddle

The web app no longer defaults assignment paths to a /workspace mount.
The saddle REGISTRATION handshake now carries the daemon's workspace
root (it answers workspace_root alongside ssh_public_key and version),
stored on the new nullable saddles.workspace_root column and shown on
the saddles page. build_assignment_payload's project paths are derived
from the ASKING saddle's registered root, falling back to
SADDLE_PROJECTS_ROOT and only then the historical /workspace - so a
fleet mounts one volume per saddle and the paths always address the
saddle that will actually run the task. Paths are advisory: the daemon
owns its filesystem and rewrites them onto its own root.

README: the deployment assumptions now document per-saddle volumes with
git (origin) as the coordination medium - worktrees cut from
origin/<goal branch>, unpulled-changes notices on adoption, merges via
origin - and the web-no-workspace-access rule; SADDLE_PROJECTS_ROOT is
reframed as the compat fallback and dropped from the quick start.

Tests: harness FakeSaddle reports workspace_root on /register; the new
origins bundle (13 checks) asserts the handshake stores it, that two
saddles on different volumes get assignments addressed under their own
roots, and that a daemon reporting no root falls back to
SADDLE_PROJECTS_ROOT. Full suite 1091 passed, 0 failed.
saddle-Clanker-Saddle 1 mesiac pred
rodič
commit
daec26beb1

+ 37 - 21
README.md

@@ -63,7 +63,7 @@ Orchestration behaviour to know:
 | `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above |
 | `SADDLE_LOST_WAKE_S` | Lost-wake guard window (default 15s): no ask after a wake → the saddle goes offline and the next capable one is woken |
 | `SADDLE_STALE_S` | Running-liveness window (default 90s): no chunks or results for this long → one exceptional `GET /status` probe; failure requeues the task and marks the saddle offline |
-| `SADDLE_PROJECTS_ROOT` | Checkouts are addressed as `root/<project-name>` inside the saddle container (default `/workspace`) |
+| `SADDLE_PROJECTS_ROOT` | Fallback for advisory checkout paths when a saddle's daemon did not report a `workspace_root` at registration (itself falling back to `/workspace`); see *Deployment assumptions* |
 
 ## Quick start (local)
 
@@ -131,7 +131,6 @@ podman run -d --name clanker-web --userns=keep-id --network clanker-net \
     -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \
     -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \
     -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \
-    -e SADDLE_PROJECTS_ROOT=/workspace \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     localhost/clanker-saddle:0.1
 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
@@ -147,8 +146,11 @@ saddle container via `podman exec -d clanker-saddle …`, reachable at
 `web-config.json` `saddle_callback_base_url` key) must be the app's
 address **as the daemon dials it** — every registered daemon pushes
 telemetry, task callbacks and notes there. Put the SQLite store on a volume
-(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`)
-and mount git checkouts under `/workspace`. On SELinux hosts the bind
+(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`).
+The saddle daemon clones into its own `/workspace` volume and reports
+that root at registration, so the web container needs NO workspace mount;
+`SADDLE_PROJECTS_ROOT` on the web side is only the fallback for daemons
+that do not report one. On SELinux hosts the bind
 mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the
 mounts to be writable. For durable sessions mount `web-config.json`
 read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
@@ -163,15 +165,17 @@ for the full guide): a two-line Containerfile `FROM` the loaded base
 plus `dnf install` of your tools (C, Vala, GTK, Node, .NET, Android,
 QEMU — anything), then tag the capability names you gave those tools in
 the web UI's saddle page so only tasks requiring them route there.
-Goal task worktrees need every container that may run a goal's tasks to
-share the `/workspace` volume.
+Each saddle mounts its OWN workspace volume: goal task worktrees are cut
+from `origin/<goal branch>` and merges travel through origin, so no two
+saddles ever need to share a filesystem (see *Deployment assumptions*).
 
 ## Creating your first project and task
 
 1. **Project** — `/projects` → name, git URL, default branch. The
-   checkout must exist at `$SADDLE_PROJECTS_ROOT/<name>` on the saddle's
-   filesystem (clone it into the workspace volume; the agent does not
-   clone). Link related projects as *co-developed* (agent may modify) or
+   saddle daemon clones the repo itself into its own workspace; the
+   paths the web app puts in assignments are ADVISORY and are rewritten
+   onto the asking saddle's filesystem (the daemon owns its volumes —
+   the web needs no access to them). Link related projects as *co-developed* (agent may modify) or
    *reference* (read-only) from the project page.
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
    mode (implement / investigate / review / test) and priority (1 is
@@ -440,9 +444,9 @@ truth. The harness-prepared `demo` repo and its bare origin both live
 on branch `main` (the project registration's default branch matches).
 
 The daemon's `SADDLE_WORKSPACE_ROOT` and the app's
-`SADDLE_PROJECTS_ROOT` point at the SAME directory (the goal clone the
-daemon cuts must anchor the worker worktrees — the shared-volume
-deployment assumption). For a container run point `WEB`/`SADDLE` at the
+`SADDLE_PROJECTS_ROOT` may point at the same directory for a local run
+(the daemon reports its root at registration, and the app's value is
+only the fallback for daemons that do not). For a container run point `WEB`/`SADDLE` at the
 published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's
 SQLite file as `DB_PATH`, and start the in-container mock via
 `MOCK_CMD='podman exec -d clanker-saddle python3
@@ -491,15 +495,27 @@ HTTP/2 removes the cap entirely.
 
 ## Deployment assumptions (goal era)
 
-- **Shared workspace volume across saddles.** A goal's clone workspace
-  (`<root>/goal-<id>`, one clone per involved repo on the goal branch)
-  anchors every task worktree (`<root>/task-<task-id>`, ephemeral
-  branch `task/<goal-id>-<task-id>`). Whichever saddle runs a goal task
-  must see the same `SADDLE_WORKSPACE_ROOT` volume as the saddle that
-  runs the goal agent — today the fleet is single-saddle per goal by
-  construction (one goal agent, one worker at a time on the one
-  registered saddle); multi-saddle fleets must mount the same volume
-  everywhere before fanning goal work out.
+- **Per-saddle workspace volumes; git is the coordination medium.** A
+  goal's clone workspace (`<root>/goal-<id>`, one clone per involved
+  repo on the goal branch) lives on whichever saddle runs the goal
+  agent; goal task worktrees (`<root>/task-<task-id>`, ephemeral branch
+  `task/<goal-id>-<task-id>`) are cut from `origin/<goal branch>` — not
+  the local clone — so a worker on ANOTHER saddle (its own volume)
+  starts from the fleet's pushed tip. At every workspace adoption the
+  daemon fetches origin and surfaces a loud *unpulled changes* notice
+  per repo left behind, instructing `git pull --rebase`. The goal agent
+  merges finished task branches via origin (`git fetch origin
+  task/<id>` + `git merge origin/task/<id>`), so a branch that only
+  exists on another saddle merges exactly like a local one. Consequence:
+  every project involved in a goal needs a pushable origin (deploy
+  keys), and saddles mount their own `SADDLE_WORKSPACE_ROOT` — no
+  shared `/workspace` anywhere.
+- **The web app has NO workspace access.** Assignment project paths are
+  advisory: they are built from the ASKING saddle's registered
+  `workspace_root` (its daemon reports `SADDLE_WORKSPACE_ROOT` at
+  `POST /register`; the saddles page shows it), falling back to
+  `SADDLE_PROJECTS_ROOT`, then the historical `/workspace`. The daemon
+  owns its filesystem and rewrites paths onto its own root.
 - **Database tear-down-and-recreate.** The schema is a single flattened
   `M0001_Initial` (goals with links/comments, the flattened tasks, and
   the plan-16 deletions already applied: no `api_tokens`, no

+ 9 - 1
src/Data/Entities.vala

@@ -277,6 +277,13 @@ namespace ClankerSaddle.Data {
         public string? git_email { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
         public string? token { get; set; default = null; }
+        /**
+         * The workspace root this saddle's daemon reported at
+         * /register (its SADDLE_WORKSPACE_ROOT), or null for daemons
+         * that predate the field — assignment paths fall back to
+         * SADDLE_PROJECTS_ROOT, then "/workspace".
+         */
+        public string? workspace_root { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
@@ -287,7 +294,8 @@ namespace ClankerSaddle.Data {
                 .column<string>("git_name", o => o.git_name, (o, v) => o.git_name = v)
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
                 .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
-                .column<string>("token", o => o.token, (o, v) => o.token = v);
+                .column<string>("token", o => o.token, (o, v) => o.token = v)
+                .column<string>("workspace_root", o => o.workspace_root, (o, v) => o.workspace_root = v);
         }
     }
 

+ 4 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -233,6 +233,10 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("git_email");
                 t.column<string>("ssh_public_key");
                 t.column<string>("token");
+                // goal 8 task 6: the workspace root the daemon reported at
+                // /register — assignment paths are addressed on the ASKING
+                // saddle's filesystem, not a shared /workspace default
+                t.column<string>("workspace_root");
             });
 
             b.create_table("sessions", (t) => {

+ 35 - 7
src/Services/SaddleService.vala

@@ -191,8 +191,17 @@ namespace ClankerSaddle.Services {
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                     (owned) payload);
                 string reported_key = EntryJson.string_member(response, "ssh_public_key");
-                if (reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? "")) {
-                    saddle.ssh_public_key = reported_key;
+                // goal 8 task 6: the daemon reports the workspace root it
+                // mounts so assignment paths address the ASKING saddle's
+                // filesystem; absent on older daemons (kept null → the
+                // SADDLE_PROJECTS_ROOT fallback chain)
+                string reported_root = EntryJson.string_member(response, "workspace_root");
+                if ((reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? ""))
+                        || reported_root != (saddle.workspace_root ?? "")) {
+                    if (reported_key.length > 0) {
+                        saddle.ssh_public_key = reported_key;
+                    }
+                    saddle.workspace_root = reported_root.length > 0 ? reported_root : null;
                     yield db.update_async<Saddle>(saddle);
                 }
                 print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
@@ -857,16 +866,16 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("co_developed");
             payload.begin_array();
             if (project != null) {
-                payload.add_value(project_node(((!)project), task.id));
+                payload.add_value(project_node(((!)project), task.id, false, saddle));
             }
             foreach (var linked in co_developed) {
-                payload.add_value(project_node(linked, task.id));
+                payload.add_value(project_node(linked, task.id, false, saddle));
             }
             payload.end_array();
             payload.set_member_name("reference");
             payload.begin_array();
             foreach (var linked in references) {
-                payload.add_value(project_node(linked, task.id, true));
+                payload.add_value(project_node(linked, task.id, true, saddle));
             }
             payload.end_array();
 
@@ -1642,9 +1651,28 @@ namespace ClankerSaddle.Services {
          * saddle's container — one workspace per task, so concurrent
          * tasks never share a checkout.
          */
-        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false) {
+        /**
+         * The projects root assignment paths are addressed against: the
+         * ASKING saddle's registered `workspace_root` (the daemon's own
+         * SADDLE_WORKSPACE_ROOT, reported at /register — goal 8 task 6,
+         * so a fleet no longer assumes one shared /workspace mount),
+         * else the `SADDLE_PROJECTS_ROOT` environment override, else
+         * the historical "/workspace" default. Paths are ADVISORY: the
+         * daemon owns its filesystem and rewrites them onto its own
+         * workspace root.
+         */
+        private static string projects_root_for_saddle(Saddle? saddle) {
+            var registered = saddle != null ? ((!)saddle).workspace_root : null;
+            if (registered != null && ((!)registered).strip().length > 0) {
+                return ((!)registered).strip();
+            }
             var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
-            string projects_root = root_var.length > 0 ? root_var : "/workspace";
+            return root_var.length > 0 ? root_var : "/workspace";
+        }
+
+        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false,
+                Saddle? saddle = null) {
+            string projects_root = projects_root_for_saddle(saddle);
             var builder = new Json.Builder();
             builder.begin_object();
             builder.set_member_name("name");

+ 3 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -41,6 +41,8 @@ namespace ClankerSaddle {
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
+        /** The workspace root the daemon reported at /register (advisory). */
+        public string workspace_root { get; set; default = ""; }
         public Statum.Model.ActionDto? update_identity { get; set; }
         public int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
@@ -181,6 +183,7 @@ namespace ClankerSaddle {
                     git_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
+                    workspace_root = saddle.workspace_root ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
                     current_task_id = work != null ? ((!)work).task_id : 0,
                     allocated_display = work != null

+ 3 - 2
test/bundles/__init__.py

@@ -4,8 +4,8 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, notes, pages, push, replay, resurrect, stacks,
-               subtasks, users)
+               lifecycle, notes, origins, pages, push, replay, resurrect,
+               stacks, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -21,6 +21,7 @@ BUNDLES = {
     "goalui": goalui,
     "users": users,
     "stacks": stacks,
+    "origins": origins,
     "notes": notes,
     "resurrect": resurrect,
 }

+ 142 - 0
test/bundles/origins.py

@@ -0,0 +1,142 @@
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+"""Goal 8 task 6 (web side): the register handshake stores the daemon's
+reported `workspace_root`, and assignment project paths are addressed on
+the ASKING saddle's filesystem — the saddle row's root first, then the
+SADDLE_PROJECTS_ROOT environment override, then the historical
+"/workspace" default. The web app needs NO access to saddle workspaces:
+paths are advisory and multi-saddle goals coordinate through origin."""
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== origin decoupling: the handshake stores the reported root ==")
+    # The primary fake reports /workspace by default; the row carries it
+    # once registration completes (the stacks bundle's demo project is
+    # project 1 in every shard).
+    def saddle_row(name):
+        return next((s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                     if s["name"] == name), None)
+
+    primary = saddle_row("fake-saddle")
+    check(primary is not None, "the primary fake saddle is registered")
+    if primary is not None:
+        check(primary.get("workspace_root") == "/workspace",
+              "the register handshake stores the daemon-reported workspace_root (%r)"
+              % primary.get("workspace_root"))
+
+    print("== origin decoupling: assignment paths follow the ASKING saddle ==")
+    # A second saddle on its own volume: its daemon reports a DIFFERENT
+    # workspace root — the fleet's separate-mount deployment shape.
+    other = FakeSaddle()
+    other.workspace_root = "/volumes/saddle-b"
+    other.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"], {"name": "volume-b",
+                                          "host": "127.0.0.1:%d" % other.port})
+    check(wait_for(lambda: len(other.register_log) > 0 or False,
+                   timeout=15, what="volume-b /register delivery") is not None,
+          "the second saddle received /register")
+    row_b = saddle_row("volume-b")
+    check(row_b is not None and row_b.get("workspace_root") == "/volumes/saddle-b",
+          "the second saddle's reported root lands on its row (%r)"
+          % ((row_b or {}).get("workspace_root")))
+
+    # One task on the demo project, dispatched to EACH saddle in turn:
+    # the assignment's project `path` must sit under the ASKING saddle's
+    # root, not a shared default.
+    def path_of(saddle_fake, title):
+        payloads = [p for p in saddle_fake.start_payloads
+                    if (p.get("task") or {}).get("title") == title]
+        if not payloads:
+            return None
+        co = payloads[-1]["task"].get("co_developed") or []
+        return co[0].get("path") if co else None
+
+    # deterministic routing: a capability each saddle alone carries
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["create_capability"], {"name": "primary-vol"})
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "volume-b-vol"})
+    caps = {c["name"]: int(c["id"])
+            for c in slot_public(auth, "/saddles", "saddles")["capabilities"]}
+    act(auth, saddle_card(auth, "fake-saddle")["attach_capability"],
+        {"capability_id": str(caps["primary-vol"])})
+    act(auth, saddle_card(auth, "volume-b")["attach_capability"],
+        {"capability_id": str(caps["volume-b-vol"])})
+
+    def create_routed(title, cap_name, description):
+        task_id = create_queued(auth, {"project_id": "1", "title": title,
+                                       "description": description, "priority": "2"})
+        act(auth, task_state(auth, task_id)["attach_capability"],
+            {"capability_id": str(caps[cap_name])})
+        wait_for(lambda: [c["name"] for c in
+                          task_state(auth, task_id)["capabilities"]] == [cap_name]
+                 or False, timeout=30, what="%s attached" % cap_name)
+        return task_id
+
+    first = create_routed("Path probe A", "primary-vol",
+                          "Dispatch to the primary volume.")
+    check(wait_for(lambda: path_of(fake, "Path probe A") is not None,
+                   timeout=40, what="primary-saddle assignment") is not None,
+          "the primary saddle receives the task")
+    path_a = path_of(fake, "Path probe A")
+    check(path_a is not None and path_a.startswith("/workspace/task-"),
+          "the primary saddle's paths sit under its reported /workspace (%r)" % path_a)
+
+    second = create_routed("Path probe B", "volume-b-vol",
+                           "Dispatch to the second volume.")
+    check(wait_for(lambda: path_of(other, "Path probe B") is not None,
+                   timeout=40, what="volume-b assignment") is not None,
+          "the volume-b saddle receives the task")
+    path_b = path_of(other, "Path probe B")
+    check(path_b is not None and path_b.startswith("/volumes/saddle-b/task-"),
+          "the volume-b saddle's paths sit under ITS OWN reported root (%r)" % path_b)
+    check(path_a is not None and path_b is not None
+          and path_a.split("/task-")[0] != path_b.split("/task-")[0],
+          "the two saddles' assignments address different volumes")
+
+    print("== origin decoupling: the compat fallback chain ==")
+    # A daemon that does NOT report a workspace_root (pre-task-6 daemon)
+    # keeps a null root; its paths fall back to SADDLE_PROJECTS_ROOT
+    # (the harness runs the app with it pointed at the scratch dir).
+    # The probe task REQUIRES a capability only this saddle carries, so
+    # the dispatcher deterministically targets it (tag routing).
+    legacy = FakeSaddle()
+    legacy.workspace_root = ""
+    legacy.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"],
+        {"name": "legacy-daemon", "host": "127.0.0.1:%d" % legacy.port})
+    check(wait_for(lambda: len(legacy.register_log) > 0 or False,
+                   timeout=15, what="legacy /register delivery") is not None,
+          "the legacy daemon received /register")
+    row_legacy = saddle_row("legacy-daemon")
+    check(row_legacy is not None and not row_legacy.get("workspace_root"),
+          "a daemon reporting no root keeps the column empty (%r)"
+          % ((row_legacy or {}).get("workspace_root")))
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "legacy-only"})
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    legacy_cap = int([c["id"] for c in saddles_state["capabilities"]
+                      if c["name"] == "legacy-only"][0])
+    legacy_card = saddle_card(auth, "legacy-daemon")
+    act(auth, legacy_card["attach_capability"], {"capability_id": str(legacy_cap)})
+    third = create_queued(auth, {"project_id": "1", "title": "Path probe C",
+                                 "description": "Dispatch to the legacy daemon.",
+                                 "priority": "4"})
+    detail = task_state(auth, third)
+    act(auth, detail["attach_capability"], {"capability_id": str(legacy_cap)})
+    check(wait_for(lambda: [c["name"] for c in
+                            task_state(auth, third)["capabilities"]] == ["legacy-only"]
+                   or False, timeout=30, what="capability attached") is not None,
+          "the probe task requires the legacy-only capability")
+    check(wait_for(lambda: path_of(legacy, "Path probe C") is not None,
+                   timeout=60, what="legacy-saddle assignment") is not None,
+          "the legacy saddle receives the task")
+    path_c = path_of(legacy, "Path probe C")
+    harness_root = os.path.join(WORK, "workspace")
+    check(path_c is not None and path_c.startswith(harness_root + "/task-"),
+          "a null root falls back to SADDLE_PROJECTS_ROOT, not /workspace (%r)" % path_c)

+ 4 - 2
test/harness.py

@@ -518,6 +518,7 @@ class FakeSaddle:
         self.session_id = None           # the web-side run row of the running task
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.ssh_public_key = ""         # reported on /status + /register
+        self.workspace_root = "/workspace"  # goal 8 task 6: reported on /register
         self.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
         self.silent = False              # suppress pushes (staleness tests)
@@ -811,7 +812,8 @@ class FakeSaddle:
                     self.daemon.token = payload["saddle_token"]
                 self._send(200, {"ok": True,
                                  "ssh_public_key": self.daemon.ssh_public_key,
-                                 "version": "0.1-fake"})
+                                 "version": "0.1-fake",
+                                 "workspace_root": self.daemon.workspace_root})
                 if self.daemon.ask_on_register:
                     self.daemon.maybe_trigger_ask("register")
                 return
@@ -1315,7 +1317,7 @@ def start_bundle(name):
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
-                 "resurrect")
+                 "resurrect", "origins")
 
 
 def run_bundle(name):