Skip to content

Commit 9172543

Browse files
cailmdaleyclaude
andcommitted
Daemon: /felt-edit + /lifecycle set-outcome for cross-host kanban writes
The owner daemon gains the two endpoints Category-B kanban mutations need so a viewer routes every cross-host write through the tunnel→owner spine instead of a WebSocket-connected agent — single-writer at the document holds (only the owner writes a fiber it owns). - /lifecycle set-outcome delegates to `shuttle-ctl set-outcome` (block-required + ensure_owned_here, so a misroute is a loud owner-mismatch). The outcome rides as one argv element, so multi-line block scalars survive without stdin. - /felt-edit shells `felt edit --untag/--tag` against the owned store — the same felt-native tag writer the local path uses. Empty diff is a 200 no-op. install already had a /lifecycle verb; no new daemon code for it. felt-horizon's only writer is TS-resident (rewriteHorizonFrontmatter), so it stays on the WS path until a generic `felt edit --set/--unset` lets one writer serve both planes. Deploy-gated: these 404 against the currently-deployed remote daemons until the W5 git-pull + make-all on candide/cineca/local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a32a791 commit 9172543

5 files changed

Lines changed: 267 additions & 3 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
defmodule ShuttleWeb.FeltEditController do
2+
@moduledoc """
3+
Daemon-local felt-document surface edits for host-routed kanban cards.
4+
5+
The owner-only kanban feed serves a fiber only when its `shuttle.host`
6+
resolves to this daemon, so a viewer's tag edit on a remote-owned card routes
7+
here over the SSH tunnel instead of editing the viewer's own loom mirror.
8+
Single-writer at the document holds: the owner daemon is the lone writer of a
9+
fiber it owns, and `felt edit` is the single felt-native tag writer (the same
10+
CLI Portolan shells for local cards).
11+
12+
`POST /api/v1/felt-edit` body: `{ "fiber_id": "...", "add": [...],
13+
"remove": [...] }`. An empty diff is a 200 no-op.
14+
"""
15+
16+
use Phoenix.Controller, formats: [:json]
17+
18+
alias Shuttle.FeltStores
19+
20+
def create(conn, %{"fiber_id" => fiber_id} = params) when is_binary(fiber_id) do
21+
add = string_list(params["add"])
22+
remove = string_list(params["remove"])
23+
24+
with {:ok, host, address} <- host_for_fiber(fiber_id),
25+
{:ok, output} <- run(host, address, add, remove) do
26+
conn
27+
|> put_resp_content_type("text/plain")
28+
|> send_resp(200, output)
29+
else
30+
{:error, reason} when is_binary(reason) ->
31+
conn
32+
|> put_resp_content_type("text/plain")
33+
|> send_resp(400, reason)
34+
35+
{:command_error, status, output} ->
36+
conn
37+
|> put_resp_content_type("text/plain")
38+
|> send_resp(422, "felt exited #{status}: #{output}")
39+
end
40+
end
41+
42+
def create(conn, _params) do
43+
conn
44+
|> put_resp_content_type("text/plain")
45+
|> send_resp(400, "fiber_id is required")
46+
end
47+
48+
defp host_for_fiber(fiber_id) do
49+
case FeltStores.resolve_fiber(fiber_id) do
50+
{:ok, %{host: host, fiber_id: address}} -> {:ok, host, address}
51+
{:error, :not_found} -> {:error, "fiber not found: #{fiber_id}"}
52+
end
53+
end
54+
55+
# An empty diff is a no-op, mirroring Portolan's local `runFeltTagEdit`.
56+
defp run(_host, _fiber_id, [], []), do: {:ok, ""}
57+
58+
defp run(host, fiber_id, add, remove) do
59+
args = ["-C", host, "edit", fiber_id]
60+
args = Enum.reduce(remove, args, fn tag, acc -> acc ++ ["--untag", tag] end)
61+
args = Enum.reduce(add, args, fn tag, acc -> acc ++ ["--tag", tag] end)
62+
63+
case System.cmd("felt", args, stderr_to_stdout: true) do
64+
{output, 0} -> {:ok, output}
65+
{output, status} -> {:command_error, status, output}
66+
end
67+
rescue
68+
e in ErlangError -> {:error, Exception.message(e)}
69+
end
70+
71+
defp string_list(values) when is_list(values) do
72+
values
73+
|> Enum.filter(&is_binary/1)
74+
|> Enum.map(&String.trim/1)
75+
|> Enum.reject(&(&1 == ""))
76+
end
77+
78+
defp string_list(_), do: []
79+
end

lib/shuttle_web/controllers/lifecycle_controller.ex

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ defmodule ShuttleWeb.LifecycleController do
55
Cross-host lifecycle requests from Portolan land here on the selected
66
daemon. The endpoint delegates to the existing shuttle-ctl Go CLI, so the
77
validated offline frontmatter writer remains the single implementation of
8-
install/pause/resume/repeat/accept/set-model/set-interactive/uninstall.
8+
install/pause/resume/repeat/accept/set-model/set-interactive/set-outcome/uninstall.
99
"""
1010

1111
use Phoenix.Controller, formats: [:json]
1212

1313
alias Shuttle.{FeltStores, LifecycleService}
1414

15-
@allowed ~w(install pause resume repeat accept set-model set-interactive uninstall)
15+
@allowed ~w(install pause resume repeat accept set-model set-interactive set-outcome uninstall)
1616

1717
def create(conn, params) do
1818
with {:ok, action} <- action(params),
@@ -53,7 +53,7 @@ defmodule ShuttleWeb.LifecycleController do
5353
end
5454

5555
defp execute(action, %{"fiber" => fiber} = params)
56-
when action in ~w(pause set-model set-interactive uninstall) do
56+
when action in ~w(pause set-model set-interactive set-outcome uninstall) do
5757
with {:ok, fiber_id} <- fiber_address(fiber) do
5858
action
5959
|> args_for(%{params | "fiber" => fiber_id})
@@ -106,6 +106,14 @@ defmodule ShuttleWeb.LifecycleController do
106106
defp args_for("set-model", %{"fiber" => fiber, "agent" => agent}),
107107
do: {:ok, ["set-model", fiber, agent]}
108108

109+
# The outcome string round-trips as a single argv element, so multi-line
110+
# values (block scalars) survive without stdin piping. set-outcome refuses a
111+
# block-less fiber and runs `ensure_owned_here`, so a misrouted edit surfaces
112+
# a loud owner-mismatch rather than writing the wrong host's document.
113+
defp args_for("set-outcome", %{"fiber" => fiber, "outcome" => outcome})
114+
when is_binary(outcome),
115+
do: {:ok, ["set-outcome", fiber, "--outcome", outcome]}
116+
109117
defp args_for("set-interactive", %{"fiber" => fiber, "interactive" => interactive})
110118
when is_boolean(interactive) do
111119
with {:ok, host} <- host_for_fiber(fiber) do

lib/shuttle_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ defmodule ShuttleWeb.Router do
2828
get("/origins", OriginsController, :show)
2929
post("/lifecycle", LifecycleController, :create)
3030
post("/felt-history", FeltHistoryController, :create)
31+
post("/felt-edit", FeltEditController, :create)
3132
get("/agents", AgentsController, :show)
3233
get("/version", VersionController, :show)
3334
post("/fiber/create", FiberController, :create)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
defmodule ShuttleWeb.FeltEditControllerTest do
2+
use ExUnit.Case
3+
import Plug.Conn
4+
import Phoenix.ConnTest
5+
6+
@endpoint ShuttleWeb.Endpoint
7+
8+
test "applies a tag diff against the configured felt store that owns the fiber" do
9+
root =
10+
System.tmp_dir!()
11+
|> Path.join("shuttle-felt-edit-controller-#{System.unique_integer([:positive])}")
12+
13+
store = Path.join(root, "loom")
14+
fiber_dir = Path.join([store, ".felt", "tests", "remote-tags"])
15+
File.mkdir_p!(fiber_dir)
16+
17+
File.write!(
18+
Path.join(fiber_dir, "remote-tags.md"),
19+
"---\nname: Remote tags\nstatus: active\n---\n\nbody\n"
20+
)
21+
22+
args_file = install_fake_felt!(root)
23+
old_loom_homes = System.get_env("LOOM_HOMES")
24+
System.put_env("LOOM_HOMES", store)
25+
26+
on_exit(fn ->
27+
restore_env("LOOM_HOMES", old_loom_homes)
28+
File.rm_rf(root)
29+
end)
30+
31+
conn =
32+
post(
33+
api_conn(),
34+
"/api/v1/felt-edit",
35+
Jason.encode!(%{
36+
"fiber_id" => "tests/remote-tags",
37+
"remove" => ["old"],
38+
"add" => ["constitution", "new"]
39+
})
40+
)
41+
42+
assert conn.status == 200
43+
44+
# Removes first, then adds — the same order Portolan's local `runFeltTagEdit`
45+
# shells, so `felt edit` sees one coherent diff.
46+
assert File.read!(args_file) ==
47+
"-C\n#{store}\nedit\ntests/remote-tags\n--untag\nold\n--tag\nconstitution\n--tag\nnew\n"
48+
end
49+
50+
test "an empty diff is a 200 no-op that never shells felt edit" do
51+
root =
52+
System.tmp_dir!()
53+
|> Path.join("shuttle-felt-edit-noop-#{System.unique_integer([:positive])}")
54+
55+
store = Path.join(root, "loom")
56+
fiber_dir = Path.join([store, ".felt", "tests", "remote-tags"])
57+
File.mkdir_p!(fiber_dir)
58+
59+
File.write!(
60+
Path.join(fiber_dir, "remote-tags.md"),
61+
"---\nname: Remote tags\nstatus: active\n---\n\nbody\n"
62+
)
63+
64+
args_file = install_fake_felt!(root)
65+
old_loom_homes = System.get_env("LOOM_HOMES")
66+
System.put_env("LOOM_HOMES", store)
67+
68+
on_exit(fn ->
69+
restore_env("LOOM_HOMES", old_loom_homes)
70+
File.rm_rf(root)
71+
end)
72+
73+
conn =
74+
post(
75+
api_conn(),
76+
"/api/v1/felt-edit",
77+
Jason.encode!(%{"fiber_id" => "tests/remote-tags", "add" => [], "remove" => []})
78+
)
79+
80+
assert conn.status == 200
81+
refute File.exists?(args_file)
82+
end
83+
84+
defp api_conn do
85+
build_conn()
86+
|> put_req_header("content-type", "application/json")
87+
|> put_req_header("accept", "application/json")
88+
end
89+
90+
defp install_fake_felt!(root) do
91+
bin_dir = Path.join(root, "bin")
92+
File.mkdir_p!(bin_dir)
93+
94+
bin = Path.join(bin_dir, "felt")
95+
args_file = Path.join(root, "felt-args")
96+
97+
# `FeltStores.resolve_fiber` asks felt for the fiber's carried path
98+
# (`felt show -j`), so the fake answers that with felt-shaped JSON (id +
99+
# absolute path). The `edit` invocation under test records its args and
100+
# prints `ok`.
101+
File.write!(bin, """
102+
#!/bin/sh
103+
case " $* " in
104+
*" show "*" -j "*|*" show "*" -j")
105+
store=""
106+
next=0
107+
for a in "$@"; do
108+
if [ "$next" = 1 ]; then store="$a"; next=0; fi
109+
if [ "$a" = "-C" ]; then next=1; fi
110+
done
111+
printf '{"id":"tests/remote-tags","path":"%s/.felt/tests/remote-tags/remote-tags.md"}\\n' "$store"
112+
;;
113+
*)
114+
printf '%s\\n' "$@" > "$FELT_ARGS_FILE"
115+
printf 'ok\\n'
116+
;;
117+
esac
118+
""")
119+
120+
File.chmod!(bin, 0o755)
121+
122+
old_path = System.get_env("PATH")
123+
old_args_file = System.get_env("FELT_ARGS_FILE")
124+
125+
System.put_env("PATH", bin_dir <> ":" <> (old_path || ""))
126+
System.put_env("FELT_ARGS_FILE", args_file)
127+
128+
on_exit(fn ->
129+
restore_env("PATH", old_path)
130+
restore_env("FELT_ARGS_FILE", old_args_file)
131+
end)
132+
133+
args_file
134+
end
135+
136+
defp restore_env(key, nil), do: System.delete_env(key)
137+
defp restore_env(key, value), do: System.put_env(key, value)
138+
end

test/shuttle_web/controllers/lifecycle_controller_test.exs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,44 @@ defmodule ShuttleWeb.LifecycleControllerTest do
103103
"--felt-store\n#{store}\nset-interactive\ntests/interactive-uid\nfalse\n"
104104
end
105105

106+
test "set-outcome delegates to shuttle-ctl, preserving a multi-line value as one arg" do
107+
root =
108+
System.tmp_dir!()
109+
|> Path.join("shuttle-lifecycle-outcome-#{System.unique_integer([:positive])}")
110+
111+
store = Path.join(root, "loom")
112+
fiber_dir = Path.join([store, ".felt", "tests", "outcome-edit"])
113+
File.mkdir_p!(fiber_dir)
114+
File.write!(Path.join(fiber_dir, "outcome-edit.md"), "---\nname: Outcome edit\n---\n\n")
115+
116+
args_file = install_fake_shuttle_ctl!()
117+
old_loom_homes = System.get_env("LOOM_HOMES")
118+
System.put_env("LOOM_HOMES", store)
119+
120+
on_exit(fn ->
121+
restore_env("LOOM_HOMES", old_loom_homes)
122+
File.rm_rf(root)
123+
end)
124+
125+
conn =
126+
post(
127+
api_conn(),
128+
"/api/v1/lifecycle",
129+
Jason.encode!(%{
130+
"action" => "set-outcome",
131+
"fiber" => "tests/outcome-edit",
132+
"outcome" => "Blocked: waiting on ADS token\nsecond line"
133+
})
134+
)
135+
136+
assert conn.status == 200
137+
138+
# The multi-line outcome rides as a single argv element (one `--outcome`
139+
# value), so the block scalar survives without stdin piping.
140+
assert File.read!(args_file) ==
141+
"set-outcome\ntests/outcome-edit\n--outcome\nBlocked: waiting on ADS token\nsecond line\n"
142+
end
143+
106144
test "accept for standing roles re-arms from the doc and evicts runtime frontmatter" do
107145
root =
108146
System.tmp_dir!()

0 commit comments

Comments
 (0)