Skip to content
Merged
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,20 @@ SPDX-License-Identifier: Apache-2.0
- While the platform is not ready, the outbox waits for readiness instead of
starting a delivery turn every second that is refused and logged as
`Outbox turn failed` at ERROR.
- `weave workflow graph` draws decision table steps as **Apply a decision
table**. Before, it stopped with `KeyError: 'decisionTable'` on any workflow
with a decision table.
- Studio sends a change that the platform refused for capacity again only when
the same person is still signed in to the same workspace. It first waits for
a sign-in check still out. Before, a change refused just as another person
signed in could be sent again as that person.
- `weave platform user` grants `viewer` on the demo project instead of the demo
environment, so a person created with roles such as `operator` or
`deployment_approver` plus `viewer` can read the project's published
workflows.
- When `make check` fails, it prints the failing stage and the node ids of the
failing tests from pytest's short summary. Test output stays in the private
stage logs.

## 0.1.0a15

Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ site build, Ruff lint and format checks, strict mypy, unit and contract tests,
and release package preparation with installed-artifact verification.

Expected: the last line is `All requested checks passed.` A failing stage stops
the run, which then prints `Checks failed; inspect private evidence.`; each
stage's log and a `checks.json` summary are in a new `build/checks-…` directory.
the run, which then prints `Stage <name> failed.`, the node id of each failing
test from pytest's short summary (never its output), and
`Checks failed; inspect private evidence.`; each stage's log and a `checks.json`
summary are in a new `build/checks-…` directory.
For a focused change, first run
its relevant suite; the integrated delivery must still pass the full gate. Do not
update lock or upstream framework provenance casually to resolve an unrelated
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/local-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ read runs, and work on human tasks:
| `developer` | Demo project | Read the catalog, compile, simulate, write, and publish definitions |
| `deployer` | Demo environment | Activate and retire versions, bind connections, manage triggers and subscriptions |
| `operator` | Demo environment | Start, pause, resume, retry, cancel, and signal runs; inspect and resolve incidents |
| `viewer` | Demo environment | Read runs, deliveries, status, and the catalog |
| `viewer` | Demo project | Read the catalog, and runs, deliveries, and status in the project's environments |
| `task_participant` | Demo environment | Read, claim, complete, and release assigned human tasks |

**`user` never changes an existing username**, and `--role` replaces the
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/people-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ weave platform user --username alice

Expected: `Username: alice`, the password, the granted roles, and the next
command, `weave auth setup http://127.0.0.1:API_PORT`. The default roles are
`developer` on the demo project, and `deployer`, `operator`, `viewer`, and
`developer` and `viewer` on the demo project, and `deployer`, `operator`, and
`task_participant` on the demo environment. Repeat `--role` to grant a
different set instead. An existing username is never reset. Never use this
command with a shared or production identity provider.
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/roles-and-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ an author cannot start runs unless you grant that too.
| Operator | Inspect, pause, resume, retry, or cancel runs; resolve incidents; maintain the platform | `operator` and `viewer`; `execution_manager` to permanently delete run content | [Execution management](execution-management.md) and [incident operations](../reference/incident-operations.md) |

On a local platform, `weave platform user` creates a development person with a
practical starting set: `developer` on the demo project, and `deployer`,
`operator`, `viewer`, and `task_participant` on the demo environment. That
practical starting set: `developer` and `viewer` on the demo project, and
`deployer`, `operator`, and `task_participant` on the demo environment. That
person can author, publish, activate, run, inspect runs, and complete tasks, but
cannot create integration connections without `tenant_admin`. See
[Create a person who can sign in](local-platform.md#5-create-a-person-who-can-sign-in)
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,14 @@ people connect with
`_`, or `-`.
- The generated password is printed once and never saved, and an existing
username is never reset.
- Without `--role`, the person gets `developer` on the demo project and
`deployer`, `operator`, `viewer`, and `task_participant` on the demo
- Without `--role`, the person gets `developer` and `viewer` on the demo
project and `deployer`, `operator`, and `task_participant` on the demo
environment.
- Each `--role` replaces those defaults: `tenant_admin` is granted on the
tenant, `developer` on the project, and every other role on the environment.
Creating connections needs `tenant_admin`.
tenant, `developer` and `viewer` on the project, and every other role on the
environment. Published workflows are project resources, so a person without
`developer` needs `viewer` to see them. Creating connections needs
`tenant_admin`.

**`integrations` and `secret` are for development only.**

Expand Down
31 changes: 31 additions & 0 deletions scripts/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import argparse
import json
import os
import re
import runpy
import signal
import sys
Expand Down Expand Up @@ -64,6 +65,33 @@ def worker_stages() -> list[tuple[str, list[str], int]]:
return stages


# Only the outcome and node id of pytest's short summary lines; the " - message" part may carry output.
SUMMARY_LINE = re.compile(r"^(FAILED|ERROR) ([\x21-\x7e](?:[\x20-\x7e]*?[\x21-\x7e])?)(?: - |$)")
SUMMARY_TAIL_BYTES = 256 * 1024
SUMMARY_MAX_LINES = 50
SUMMARY_MAX_LENGTH = 300


def failing_tests(log: Path) -> list[str]:
"""Name the failing tests from the tail of a stage log, never their output."""
try:
with log.open("rb") as stream:
stream.seek(max(0, stream.seek(0, os.SEEK_END) - SUMMARY_TAIL_BYTES))
tail = stream.read().decode("utf-8", "replace")
except OSError:
return []
_, header, summary = tail.rpartition("short test summary info")
names = []
for line in summary.splitlines()[1:] if header else ():
# pytest closes its summary with a "==== 1 failed in 3.00s ====" line; later output is not its summary.
if line.startswith("="):
break
match = SUMMARY_LINE.match(re.sub(r"\x1b\[[0-9;]*m", "", line))
if match and len(match.group(2)) <= SUMMARY_MAX_LENGTH:
names.append(match.group(1) + " " + match.group(2))
return list(dict.fromkeys(names))[:SUMMARY_MAX_LINES]


def release_prerequisites(root: Path, context: str | None) -> None:
if context != "colima-weave-tests" or os.environ.get("WEAVE_TEST_DOCKER_CONTEXT") != context:
raise ValueError("An explicit owned release Docker context is required")
Expand Down Expand Up @@ -215,6 +243,9 @@ def run(root: Path, evidence: Path, *, release: bool, context: str | None, integ
except BaseException:
results[name] = {"status": "failed", "seconds": round(time.monotonic() - started, 3)}
failed = True
print(f"Stage {name} failed.", flush=True)
for line in failing_tests(evidence / (name + ".log")):
print(line, flush=True)
break
except BaseException:
failed = True
Expand Down
6 changes: 4 additions & 2 deletions src/firefly_weave/cli/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,10 @@ def user(context: click.Context, username: str | None, roles: tuple[str, ...], o
never saved; an existing username is never reset.

Default roles are the smallest set to author, publish, activate, run,
inspect runs, and work on human tasks: developer on the demo project, and
deployer, operator, viewer, and task_participant on the demo environment.
inspect runs, and work on human tasks: developer and viewer on the demo
project, and deployer, operator, and task_participant on the demo
environment. Add viewer to any other set so the person can read the
project's published workflows.
Use `user grant` to add roles to an account later.
"""
if context.invoked_subcommand is not None:
Expand Down
6 changes: 4 additions & 2 deletions src/firefly_weave/sdk/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,8 +1548,10 @@ def _person_roles(roles: Sequence[str], default: Sequence[str] = DEFAULT_PERSON_


def _person_grants(roles: tuple[str, ...], scope: dict[str, str]) -> list[dict[str, Any]]:
levels = {"tenant_admin": ("tenant_id",), "developer": ("tenant_id", "project_id")}
# Definitions are project resources; activation, runs, and tasks belong to the demo environment.
project = ("tenant_id", "project_id")
levels = {"tenant_admin": ("tenant_id",), "developer": project, "viewer": project}
# Definitions are project resources, and an environment grant never contains a project-level read: viewer
# reads the catalog only on the project. Activation, runs, and tasks belong to the demo environment.
return [
{
"role": role,
Expand Down
1 change: 1 addition & 0 deletions src/firefly_weave/sdk/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"end": "Return the result",
"action": "Call an action",
"transform": "Shape the data",
"decisionTable": "Apply a decision table",
"wait": "Wait for a duration",
"signal": "Wait for a signal",
"humanTask": "Wait for a human decision",
Expand Down
31 changes: 27 additions & 4 deletions studio/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,20 +127,34 @@ export class StudioApi {
};
/** Called when the local host no longer recognizes this window's pairing. */
onSessionEnded: (() => void) | null = null;
/**
* Who is signed in, and where, for a keyed change sent again after a
* capacity pause: the host sends it for whoever is signed in by then.
* `settled` waits for a sign-in check still out; `scope` names the person
* and workspace right then. The shell sets it.
*/
replayGuard: {
settled: () => Promise<void>;
scope: () => string;
} | null = null;
/** Pauses before replaying a request the host or platform turned away for capacity. */
wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
/** A pause that a cancel ends at once, so a canceled request is never sent again. */
private async pause(milliseconds: number, signal?: AbortSignal) {
if (!signal) return this.wait(milliseconds);
private pause(milliseconds: number, signal?: AbortSignal) {
return this.untilCanceled(() => this.wait(milliseconds), signal);
}
/** Waits for `work`, or rejects at once when `signal` cancels first. */
private async untilCanceled(work: () => Promise<void>, signal?: AbortSignal) {
if (!signal) return work();
signal.throwIfAborted();
let stop = () => {};
const canceled = new Promise<never>((_resolve, reject) => {
stop = () => reject(signal.reason);
signal.addEventListener("abort", stop, { once: true });
});
try {
await Promise.race([this.wait(milliseconds), canceled]);
await Promise.race([work(), canceled]);
} finally {
signal.removeEventListener("abort", stop);
}
Expand All @@ -167,7 +181,10 @@ export class StudioApi {
h["X-Weave-CSRF"] = this.session.csrfToken;
// A capacity or compatibility rejection means the host or platform did not admit the
// request, so a read, or a change that carries an idempotency key, can be sent again.
const safe = method === "GET" || "Idempotency-Key" in h;
const keyed = method !== "GET" && "Idempotency-Key" in h;
const safe = method === "GET" || keyed;
// A change is sent again only for the person and workspace it was first sent for.
const scope = keyed ? this.replayGuard?.scope() : undefined;
for (let attempt = 1; ; attempt++) {
const scoped = signal ? cancellable(signal, timeout) : null;
let response: Response;
Expand Down Expand Up @@ -202,6 +219,12 @@ export class StudioApi {
retryDelay(response.headers.get("Retry-After")),
signal,
);
const guard = keyed ? this.replayGuard : null;
if (guard) {
await this.untilCanceled(() => guard.settled(), signal);
// Nothing ran: the refusal stands, rather than a send as someone else.
if (guard.scope() !== scope) throw error;
}
continue;
}
if (
Expand Down
6 changes: 6 additions & 0 deletions studio/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,12 @@ export class App implements CanvasHost {
);
});
this.api.onSessionEnded = () => this.sessionEnded();
// A keyed change refused for capacity is sent again only once Studio knows
// who is signed in, and only for the person and workspace it was sent for.
this.api.replayGuard = {
settled: () => this.identitySettled(),
scope: () => scopeKeyOf(this),
};
this.loginWatcher.subscribe((event) => this.backgroundLogin(event));
afterEveryRender(() => {
const title = this.pageTitle();
Expand Down
78 changes: 78 additions & 0 deletions studio/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,84 @@ describe("capacity rejections", () => {
});
expect(other).toHaveBeenCalledTimes(1);
});
it("does not replay a keyed change once another person is signed in", async () => {
const fetch = vi.fn(async () => busy());
vi.stubGlobal("fetch", fetch);
const client = api();
let person = "jane";
let settle: () => void = () => undefined;
const checked: string[] = [];
client.replayGuard = {
scope: () => person,
settled: () =>
new Promise<void>((resolve) => {
checked.push(person);
settle = resolve;
}),
};
client.wait = vi.fn(async () => {
// The person changes while Studio pauses before the replay.
person = "john";
});
const pending = client.mutate(
"/studio/api/runs",
"POST",
{},
undefined,
"k",
);
await vi.waitFor(() => expect(checked).toEqual(["john"]));
// Nothing is sent while the sign-in check is still out.
expect(fetch).toHaveBeenCalledTimes(1);
settle();
await expect(pending).rejects.toMatchObject({
status: 429,
code: "WV-OPERATION-CAPACITY",
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it("stops at once when canceled while the sign-in check is still out", async () => {
const fetch = vi.fn(async () => busy());
vi.stubGlobal("fetch", fetch);
const client = api();
let waiting: () => void = () => undefined;
const reached = new Promise<void>((resolve) => (waiting = resolve));
client.replayGuard = {
scope: () => "jane",
settled: () => {
waiting();
return new Promise<void>(() => undefined);
},
};
const controller = new AbortController();
const pending = client.request(
"/studio/api/runs",
"POST",
{},
{ "Idempotency-Key": "k" },
15000,
controller.signal,
);
await reached;
controller.abort(new DOMException("Canceled.", "AbortError"));
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("replays a keyed change after the sign-in check finds the same person", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(busy())
.mockResolvedValueOnce(ok());
vi.stubGlobal("fetch", fetch);
const client = api();
const settled = vi.fn(async () => undefined);
client.replayGuard = { scope: () => "jane", settled };
await expect(
client.mutate("/studio/api/runs", "POST", {}, undefined, "k"),
).resolves.toEqual({ items: [] });
expect(settled).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("does not replay other 429 answers", async () => {
const fetch = vi.fn().mockResolvedValue(busy("WV-PAGE-LIMIT"));
vi.stubGlobal("fetch", fetch);
Expand Down
49 changes: 49 additions & 0 deletions studio/tests/browser/worker-operations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,55 @@ test.describe("another person on the same environment", () => {
).toHaveCount(0);
});

test("a change refused for capacity is not sent again once another person is signed in", async ({
page,
}) => {
const people = await janeThenJohn(page);
const sent: { person: string; path: string }[] = [];
let reached: () => void = () => undefined;
const out = new Promise<void>((resolve) => (reached = resolve));
await page.route("**/workers/*/drain", async (r) => {
sent.push({
person: people.person(),
path: new URL(r.request().url()).pathname
.split("/")
.slice(-2)
.join("/"),
});
if (sent.length === 1) {
reached();
// Studio pauses for Retry-After, then would send the same keyed change again.
return r.fulfill({
status: 429,
headers: { "Retry-After": "5" },
json: { code: "WV-OPERATION-CAPACITY", message: "Busy" },
});
}
return r.fulfill({ json: { revoked: false } });
});
await page.getByLabel("Select every worker on this list, up to 50").check();
await page
.getByRole("region", { name: "Selected workers" })
.getByRole("button", { name: "Drain" })
.click();
await page
.getByRole("dialog", { name: "Drain 3 workers?" })
.getByRole("button", { name: "Drain" })
.click();
// The platform refuses the first drain; John signs in during the pause.
await out;
const refused = Date.now();
await people.switch();
// The switch lands inside the five-second pause, even on a slow runner.
expect(Date.now() - refused).toBeLessThan(4500);
await expect
.poll(() => people.lists.filter((item) => item.person === "john").length)
.toBeGreaterThan(0);
// Past the pause, nothing has gone out for John.
await page.waitForTimeout(Math.max(0, 6500 - (Date.now() - refused)));
expect(sent).toEqual([{ person: "jane", path: `${online}/drain` }]);
});

test("a bulk action waits for the sign-in check before its next send", async ({
page,
}) => {
Expand Down
Loading
Loading