Compare commits
No commits in common. "main" and "fix/test-a2a-sanitization-main" have entirely different histories.
main
...
fix/test-a
@ -1 +0,0 @@
|
|||||||
refire:1778784369
|
|
||||||
@ -203,17 +203,12 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
|
|||||||
|
|
||||||
def ci_job_names(ci_doc: dict) -> set[str]:
|
def ci_job_names(ci_doc: dict) -> set[str]:
|
||||||
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
|
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
|
||||||
whose `if:` gates on `github.event_name` or `github.ref` (those are
|
whose `if:` gates on `github.event_name` (those are event-scoped
|
||||||
event-scoped and can legitimately be `skipped` for a given trigger;
|
and can legitimately be `skipped` for a given trigger; if we
|
||||||
if we required them under the sentinel `needs:`, every PR-only job
|
required them under the sentinel `needs:`, every PR-only job
|
||||||
would be `skipped` on push and the sentinel would interpret
|
would be `skipped` on push and the sentinel would interpret
|
||||||
`skipped != success` as failure). RFC §4 spec.
|
`skipped != success` as failure). RFC §4 spec.
|
||||||
|
|
||||||
`github.ref` is the companion gate for jobs that run only on direct
|
|
||||||
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
|
|
||||||
These never execute in a PR context, so flagging them as missing
|
|
||||||
from `all-required.needs:` is a false positive (mc#958 / mc#959).
|
|
||||||
|
|
||||||
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
|
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
|
||||||
(typos in needs) — see `ci_jobs_all` for that."""
|
(typos in needs) — see `ci_jobs_all` for that."""
|
||||||
jobs = ci_doc.get("jobs")
|
jobs = ci_doc.get("jobs")
|
||||||
@ -226,9 +221,7 @@ def ci_job_names(ci_doc: dict) -> set[str]:
|
|||||||
continue
|
continue
|
||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
gate = v.get("if")
|
gate = v.get("if")
|
||||||
if isinstance(gate, str) and (
|
if isinstance(gate, str) and "github.event_name" in gate:
|
||||||
"github.event_name" in gate or "github.ref" in gate
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
names.add(k)
|
names.add(k)
|
||||||
return names
|
return names
|
||||||
|
|||||||
@ -47,15 +47,6 @@ REQUIRED_CONTEXTS_RAW = _env(
|
|||||||
"sop-checklist / all-items-acked (pull_request)"
|
"sop-checklist / all-items-acked (pull_request)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Required contexts for push (main/staging) runs. The push CI uses the same
|
|
||||||
# aggregator names with " (push)" suffix. Checking these explicitly instead of
|
|
||||||
# the combined state avoids false-pause when non-blocking jobs (e.g. Platform
|
|
||||||
# Go with continue-on-error: true due to mc#774) have failed — their failures
|
|
||||||
# pollute the combined state but do not block merges.
|
|
||||||
PUSH_REQUIRED_CONTEXTS_RAW = _env(
|
|
||||||
"PUSH_REQUIRED_CONTEXTS",
|
|
||||||
default="CI / all-required (push)",
|
|
||||||
)
|
|
||||||
|
|
||||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||||
@ -65,11 +56,6 @@ class ApiError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MergePermissionError(ApiError):
|
|
||||||
"""Merge failed with a permanent permission error (403/404/405).
|
|
||||||
The queue should skip this PR and move to the next one."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class MergeDecision:
|
class MergeDecision:
|
||||||
ready: bool
|
ready: bool
|
||||||
@ -132,59 +118,28 @@ def required_contexts(raw: str) -> list[str]:
|
|||||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
def push_required_contexts() -> list[str]:
|
|
||||||
"""Required contexts for push (branch) CI runs. See PUSH_REQUIRED_CONTEXTS_RAW."""
|
|
||||||
return required_contexts(PUSH_REQUIRED_CONTEXTS_RAW)
|
|
||||||
|
|
||||||
|
|
||||||
def status_state(status: dict) -> str:
|
def status_state(status: dict) -> str:
|
||||||
return str(status.get("status") or status.get("state") or "").lower()
|
return str(status.get("status") or status.get("state") or "").lower()
|
||||||
|
|
||||||
|
|
||||||
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||||
# Gitea /statuses endpoint returns entries in ascending id order (oldest
|
|
||||||
# first). We need the LAST occurrence of each context, so iterate in
|
|
||||||
# reverse to prefer newer entries.
|
|
||||||
latest: dict[str, dict] = {}
|
latest: dict[str, dict] = {}
|
||||||
for status in reversed(statuses):
|
for status in statuses:
|
||||||
context = status.get("context")
|
context = status.get("context")
|
||||||
if isinstance(context, str):
|
if isinstance(context, str) and context not in latest:
|
||||||
latest[context] = status # overwrite: reverse order → newest wins
|
latest[context] = status
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
|
|
||||||
def _is_tier_low_pending_ok(
|
|
||||||
latest_statuses: dict[str, dict],
|
|
||||||
context: str,
|
|
||||||
pr_labels: set[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if tier:low PR can tolerate sop-checklist pending state.
|
|
||||||
|
|
||||||
Per sop-checklist-config.yaml tier_failure_mode, tier:low uses soft-fail:
|
|
||||||
sop-checklist posts state=pending when acks are satisfied (missing
|
|
||||||
manager/ceo acks are informational only). The queue should accept
|
|
||||||
pending instead of waiting for success.
|
|
||||||
"""
|
|
||||||
if "tier:low" not in pr_labels:
|
|
||||||
return False
|
|
||||||
if "sop-checklist" not in context:
|
|
||||||
return False
|
|
||||||
status = latest_statuses.get(context) or {}
|
|
||||||
return status_state(status) == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
def required_contexts_green(
|
def required_contexts_green(
|
||||||
latest_statuses: dict[str, dict],
|
latest_statuses: dict[str, dict],
|
||||||
contexts: list[str],
|
contexts: list[str],
|
||||||
pr_labels: set[str] | None = None,
|
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
missing_or_bad: list[str] = []
|
missing_or_bad: list[str] = []
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
status = latest_statuses.get(context)
|
status = latest_statuses.get(context)
|
||||||
state = status_state(status or {})
|
state = status_state(status or {})
|
||||||
if state != "success":
|
if state != "success":
|
||||||
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
|
|
||||||
continue # tier:low soft-fail: accept pending sop-checklist
|
|
||||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||||
return not missing_or_bad, missing_or_bad
|
return not missing_or_bad, missing_or_bad
|
||||||
|
|
||||||
@ -237,27 +192,19 @@ def evaluate_merge_readiness(
|
|||||||
pr_status: dict,
|
pr_status: dict,
|
||||||
required_contexts: list[str],
|
required_contexts: list[str],
|
||||||
pr_has_current_base: bool,
|
pr_has_current_base: bool,
|
||||||
pr_labels: set[str] | None = None,
|
|
||||||
) -> MergeDecision:
|
) -> MergeDecision:
|
||||||
# Check push-required contexts explicitly instead of combined state.
|
main_state = str(main_status.get("state") or "").lower()
|
||||||
# Combined state can be "failure" due to non-blocking jobs
|
if main_state != "success":
|
||||||
# (continue-on-error: true) that don't actually gate merges.
|
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
|
||||||
# CI / all-required (push) is the authoritative gate — it respects
|
|
||||||
# continue-on-error and correctly aggregates all blocking failures.
|
|
||||||
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
|
|
||||||
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
|
|
||||||
if not main_ok:
|
|
||||||
return MergeDecision(False, "pause", "main required contexts not green: " + ", ".join(main_bad))
|
|
||||||
if not pr_has_current_base:
|
if not pr_has_current_base:
|
||||||
return MergeDecision(False, "update", "PR head does not contain current main")
|
return MergeDecision(False, "update", "PR head does not contain current main")
|
||||||
|
|
||||||
# Check explicit required contexts instead of combined state. Combined state
|
pr_state = str(pr_status.get("state") or "").lower()
|
||||||
# can be "failure" due to non-blocking jobs with continue-on-error: true
|
if pr_state != "success":
|
||||||
# (e.g. publish-runtime-autobump/pr-validate, qa-review on stale tokens).
|
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
|
||||||
# The required_contexts list is the authoritative gate — it includes only
|
|
||||||
# the checks that actually block merges.
|
|
||||||
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
||||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts, pr_labels)
|
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
|
||||||
if not ok:
|
if not ok:
|
||||||
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
||||||
return MergeDecision(True, "merge", "ready")
|
return MergeDecision(True, "merge", "ready")
|
||||||
@ -273,42 +220,10 @@ def get_branch_head(branch: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_combined_status(sha: str) -> dict:
|
def get_combined_status(sha: str) -> dict:
|
||||||
"""Combined status + all individual statuses for `sha`.
|
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||||
|
if not isinstance(body, dict):
|
||||||
The /status endpoint caps the `statuses` array at 30 entries (Gitea
|
|
||||||
default page size), so we fetch the full list via /statuses with a
|
|
||||||
higher limit. The combined `state` still comes from /status.
|
|
||||||
"""
|
|
||||||
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
|
||||||
if not isinstance(combined, dict):
|
|
||||||
raise ApiError(f"status for {sha} response not object")
|
raise ApiError(f"status for {sha} response not object")
|
||||||
combined_statuses: list[dict] = combined.get("statuses") or []
|
return body
|
||||||
try:
|
|
||||||
_, all_statuses_raw = api(
|
|
||||||
"GET",
|
|
||||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
|
||||||
query={"limit": "50"},
|
|
||||||
)
|
|
||||||
if isinstance(all_statuses_raw, list):
|
|
||||||
all_statuses: list[dict] = list(all_statuses_raw)
|
|
||||||
else:
|
|
||||||
all_statuses = []
|
|
||||||
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
||||||
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
|
|
||||||
all_statuses = []
|
|
||||||
# Build latest per context: process combined (ascending→reverse=newest
|
|
||||||
# first), then fill gaps from all_statuses (already newest-first).
|
|
||||||
latest: dict[str, dict] = {}
|
|
||||||
for status in reversed(sorted(combined_statuses, key=lambda s: s.get("id") or 0)):
|
|
||||||
ctx = status.get("context")
|
|
||||||
if isinstance(ctx, str) and ctx not in latest:
|
|
||||||
latest[ctx] = status
|
|
||||||
for status in all_statuses:
|
|
||||||
ctx = status.get("context")
|
|
||||||
if isinstance(ctx, str) and ctx not in latest:
|
|
||||||
latest[ctx] = status
|
|
||||||
combined["statuses"] = list(latest.values())
|
|
||||||
return combined
|
|
||||||
|
|
||||||
|
|
||||||
def list_queued_issues() -> list[dict]:
|
def list_queued_issues() -> list[dict]:
|
||||||
@ -372,28 +287,15 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
|
|||||||
print(f"::notice::merging PR #{pr_number}")
|
print(f"::notice::merging PR #{pr_number}")
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return
|
return
|
||||||
try:
|
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
|
||||||
except ApiError as exc:
|
|
||||||
# Re-raise permission-like errors so process_once can skip this PR.
|
|
||||||
# 403 = no push access, 404 = repo/pr not found, 405 = not allowed.
|
|
||||||
msg = str(exc)
|
|
||||||
for code in ("403", "404", "405"):
|
|
||||||
if code in msg:
|
|
||||||
raise MergePermissionError(msg) from exc
|
|
||||||
raise # re-raise other ApiErrors unchanged
|
|
||||||
|
|
||||||
|
|
||||||
def process_once(*, dry_run: bool = False) -> int:
|
def process_once(*, dry_run: bool = False) -> int:
|
||||||
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
|
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
|
||||||
main_sha = get_branch_head(WATCH_BRANCH)
|
main_sha = get_branch_head(WATCH_BRANCH)
|
||||||
main_status = get_combined_status(main_sha)
|
main_status = get_combined_status(main_sha)
|
||||||
# Check push-required contexts explicitly instead of combined state.
|
if str(main_status.get("state") or "").lower() != "success":
|
||||||
# See evaluate_merge_readiness for rationale.
|
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
|
||||||
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
|
|
||||||
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
|
|
||||||
if not main_ok:
|
|
||||||
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
issue = choose_next_queued_issue(
|
issue = choose_next_queued_issue(
|
||||||
@ -423,13 +325,11 @@ def process_once(*, dry_run: bool = False) -> int:
|
|||||||
commits = get_pull_commits(pr_number)
|
commits = get_pull_commits(pr_number)
|
||||||
current_base = pr_has_current_base(pr, commits, main_sha)
|
current_base = pr_has_current_base(pr, commits, main_sha)
|
||||||
pr_status = get_combined_status(head_sha)
|
pr_status = get_combined_status(head_sha)
|
||||||
pr_labels = label_names(pr)
|
|
||||||
decision = evaluate_merge_readiness(
|
decision = evaluate_merge_readiness(
|
||||||
main_status=main_status,
|
main_status=main_status,
|
||||||
pr_status=pr_status,
|
pr_status=pr_status,
|
||||||
required_contexts=contexts,
|
required_contexts=contexts,
|
||||||
pr_has_current_base=current_base,
|
pr_has_current_base=current_base,
|
||||||
pr_labels=pr_labels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
|
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
|
||||||
@ -452,25 +352,7 @@ def process_once(*, dry_run: bool = False) -> int:
|
|||||||
"deferring to next tick"
|
"deferring to next tick"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
try:
|
merge_pull(pr_number, dry_run=dry_run)
|
||||||
merge_pull(pr_number, dry_run=dry_run)
|
|
||||||
except MergePermissionError as exc:
|
|
||||||
# Permanent merge failure (HTTP 403/404/405). Post a comment so
|
|
||||||
# maintainers know why, then return 0 so this tick is done.
|
|
||||||
# The PR stays in the queue; future ticks can retry after the
|
|
||||||
# permission issue is resolved.
|
|
||||||
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
|
|
||||||
post_comment(
|
|
||||||
pr_number,
|
|
||||||
(
|
|
||||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
|
||||||
"No available token has Can-merge permission on this repo. "
|
|
||||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
|
||||||
"Skipping to next queued PR on next tick."
|
|
||||||
),
|
|
||||||
dry_run=dry_run,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
return 0
|
return 0
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@ -480,21 +362,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
_require_runtime_env()
|
_require_runtime_env()
|
||||||
try:
|
return process_once(dry_run=args.dry_run)
|
||||||
return process_once(dry_run=args.dry_run)
|
|
||||||
except ApiError as exc:
|
|
||||||
# API errors (401/403/404/500) are transient for a queue tick —
|
|
||||||
# log and exit 0 so the workflow is not marked failed and the next
|
|
||||||
# tick can retry. Returning non-zero would permanently fail the
|
|
||||||
# workflow run, blocking future ticks.
|
|
||||||
sys.stderr.write(f"::error::queue API error: {exc}\n")
|
|
||||||
return 0
|
|
||||||
except urllib.error.URLError as exc:
|
|
||||||
sys.stderr.write(f"::error::queue network error: {exc}\n")
|
|
||||||
return 0
|
|
||||||
except TimeoutError as exc:
|
|
||||||
sys.stderr.write(f"::error::queue timeout: {exc}\n")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -36,9 +36,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
|
|||||||
raw `.error` fields into CI logs/summaries.
|
raw `.error` fields into CI logs/summaries.
|
||||||
9. Production deploy/redeploy workflows must expose an operational control:
|
9. Production deploy/redeploy workflows must expose an operational control:
|
||||||
kill switch for auto deploys or rollback tag for manual deploys.
|
kill switch for auto deploys or rollback tag for manual deploys.
|
||||||
10. Docker health checks must not run `docker info | head` under pipefail.
|
|
||||||
`head` closes the pipe early, `docker info` can exit nonzero from
|
|
||||||
SIGPIPE, and the step can falsely report Docker daemon failure.
|
|
||||||
|
|
||||||
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
|
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
|
||||||
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
|
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
|
||||||
@ -228,24 +225,6 @@ def _iter_uses(doc: Any) -> Iterable[str]:
|
|||||||
yield step["uses"]
|
yield step["uses"]
|
||||||
|
|
||||||
|
|
||||||
def _iter_run_blocks(doc: Any) -> Iterable[str]:
|
|
||||||
"""Yield every shell `run:` block from job steps in a workflow document."""
|
|
||||||
if not isinstance(doc, dict):
|
|
||||||
return
|
|
||||||
jobs = doc.get("jobs")
|
|
||||||
if not isinstance(jobs, dict):
|
|
||||||
return
|
|
||||||
for job in jobs.values():
|
|
||||||
if not isinstance(job, dict):
|
|
||||||
continue
|
|
||||||
steps = job.get("steps")
|
|
||||||
if not isinstance(steps, list):
|
|
||||||
continue
|
|
||||||
for step in steps:
|
|
||||||
if isinstance(step, dict) and isinstance(step.get("run"), str):
|
|
||||||
yield step["run"]
|
|
||||||
|
|
||||||
|
|
||||||
def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
|
def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
|
||||||
"""Return per-violation error lines for cross-repo `uses:` references."""
|
"""Return per-violation error lines for cross-repo `uses:` references."""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
@ -285,10 +264,6 @@ GITHUB_API_REF_RE = re.compile(
|
|||||||
|
|
||||||
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
|
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
|
||||||
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
|
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
|
||||||
RUN_SETS_PIPEFAIL_RE = re.compile(r"(?m)^\s*set\s+-[^\n]*o\s+pipefail\b")
|
|
||||||
DOCKER_INFO_HEAD_PIPE_RE = re.compile(
|
|
||||||
r"(?m)^\s*docker\s+info\b[^\n|]*\|\s*head\b"
|
|
||||||
)
|
|
||||||
RAW_CP_RESPONSE_RE = re.compile(
|
RAW_CP_RESPONSE_RE = re.compile(
|
||||||
r"""(?x)
|
r"""(?x)
|
||||||
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
|
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
|
||||||
@ -408,30 +383,6 @@ def check_production_operational_control(filename: str, raw: str) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rule 10 — docker info piped to head under pipefail
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def check_docker_info_head_pipefail(filename: str, doc: Any) -> list[str]:
|
|
||||||
errors: list[str] = []
|
|
||||||
for run_block in _iter_run_blocks(doc):
|
|
||||||
if not (
|
|
||||||
RUN_SETS_PIPEFAIL_RE.search(run_block)
|
|
||||||
and DOCKER_INFO_HEAD_PIPE_RE.search(run_block)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
errors.append(
|
|
||||||
f"::error file={filename}::Rule 10 (FATAL): workflow runs "
|
|
||||||
f"`docker info | head` after enabling `pipefail`. `head` can "
|
|
||||||
f"close the pipe early, making `docker info` exit nonzero and "
|
|
||||||
f"falsely fail the Docker daemon health check. Capture "
|
|
||||||
f"`docker_info=\"$(docker info 2>&1)\"` first, then print a "
|
|
||||||
f"bounded preview with `printf ... | sed -n '1,5p'`."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Driver
|
# Driver
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -485,7 +436,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
|
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
|
||||||
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
|
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
|
||||||
fatal_errors.extend(check_production_operational_control(rel, raw))
|
fatal_errors.extend(check_production_operational_control(rel, raw))
|
||||||
fatal_errors.extend(check_docker_info_head_pipefail(rel, doc))
|
|
||||||
warnings.extend(check_github_server_url_missing(rel, doc, raw))
|
warnings.extend(check_github_server_url_missing(rel, doc, raw))
|
||||||
|
|
||||||
# Cross-file checks
|
# Cross-file checks
|
||||||
|
|||||||
@ -145,7 +145,7 @@ if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
|
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
|
||||||
# sop-checklist.py posts `sop-checklist / na-declarations (pull_request)`
|
# sop-checklist-gate.py posts `sop-checklist / na-declarations (pull_request)`
|
||||||
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
|
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
|
||||||
# the requirement for a Gitea APPROVE review is waived.
|
# the requirement for a Gitea APPROVE review is waived.
|
||||||
NA_STATUSES_TMP=$(mktemp)
|
NA_STATUSES_TMP=$(mktemp)
|
||||||
|
|||||||
362
.gitea/scripts/sop-checklist.py → .gitea/scripts/sop-checklist-gate.py
Normal file → Executable file
362
.gitea/scripts/sop-checklist.py → .gitea/scripts/sop-checklist-gate.py
Normal file → Executable file
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# sop-checklist — evaluate whether a PR has peer-acked each
|
# sop-checklist-gate — evaluate whether a PR has peer-acked each
|
||||||
# SOP-checklist item. Posts a commit-status that branch protection
|
# SOP-checklist item. Posts a commit-status that branch protection
|
||||||
# can require.
|
# can require.
|
||||||
#
|
#
|
||||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||||
#
|
#
|
||||||
# Invoked by .gitea/workflows/sop-checklist.yml on:
|
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
|
||||||
# - pull_request_target: [opened, edited, synchronize, reopened]
|
# - pull_request_target: [opened, edited, synchronize, reopened]
|
||||||
# - issue_comment: [created, edited, deleted]
|
# - issue_comment: [created, edited, deleted]
|
||||||
#
|
#
|
||||||
@ -68,7 +68,7 @@ import sys
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Any, Callable
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -109,8 +109,17 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
|
|||||||
# Optional trailing note after the slug for /sop-ack and required reason
|
# Optional trailing note after the slug for /sop-ack and required reason
|
||||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||||
# yet validated; future iteration may require a min-length).
|
# yet validated; future iteration may require a min-length).
|
||||||
|
#
|
||||||
|
# /sop-n/a <gate> [reason] — declares a gate as not-applicable.
|
||||||
|
# <gate> is a canonical gate name (qa-review, security-review).
|
||||||
|
# The declaring user must be in one of the gate's required_teams.
|
||||||
|
# Most-recent per-user declaration wins (revoke semantics mirror ack).
|
||||||
_DIRECTIVE_RE = re.compile(
|
_DIRECTIVE_RE = re.compile(
|
||||||
r"^[ \t]*/(sop-ack|sop-revoke|sop-n/a)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
_NA_DIRECTIVE_RE = re.compile(
|
||||||
|
r"^[ \t]*/sop-n/?a[ \t]+([A-Za-z0-9_\-]+)(?:[ \t]+(.*))?[ \t]*$",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -121,53 +130,37 @@ def parse_directives(
|
|||||||
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
||||||
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
|
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
|
||||||
|
|
||||||
Returns (directives, na_directives) where each is a list of
|
Returns a tuple of two lists:
|
||||||
(kind, canonical_slug, note) tuples:
|
0. list of (kind, canonical_slug, note) for sop-ack/sop-revoke
|
||||||
kind is "sop-ack", "sop-revoke", or "sop-n/a"
|
1. list of (kind, gate_name, reason) for sop-n/a
|
||||||
canonical_slug is the normalized form (or "" if unparseable)
|
|
||||||
note is the trailing free-text (may be "")
|
canonical_slug is the normalized form (or "" if unparseable).
|
||||||
The two lists are kept separate so call sites can unpack them
|
note/reason is the trailing free-text (may be "").
|
||||||
directly (e.g. directives, na_directives = parse_directives(...)).
|
|
||||||
"""
|
"""
|
||||||
directives: list[tuple[str, str, str]] = []
|
out: list[tuple[str, str, str]] = []
|
||||||
na_directives: list[tuple[str, str, str]] = []
|
na_out: list[tuple[str, str, str]] = []
|
||||||
if not comment_body:
|
if not comment_body:
|
||||||
return directives, na_directives
|
return out, na_out
|
||||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||||
kind = m.group(1)
|
kind = m.group(1)
|
||||||
raw_slug = (m.group(2) or "").strip()
|
raw_slug = (m.group(2) or "").strip()
|
||||||
# If the raw match included trailing words, the regex non-greedy
|
|
||||||
# captured only the first token; strip again for safety.
|
|
||||||
# We split on whitespace to keep the FIRST word as the slug, and
|
|
||||||
# everything after as the note.
|
|
||||||
parts = raw_slug.split()
|
parts = raw_slug.split()
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
first = parts[0]
|
first = parts[0]
|
||||||
# If the slug-capture greedily matched multiple words (e.g.
|
|
||||||
# "comprehensive testing"), preserve normalize behavior: join
|
|
||||||
# the WHOLE first-word-token only; trailing words get appended to
|
|
||||||
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
|
|
||||||
# may have multi-word forms here — normalize handles them.
|
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
# User wrote "/sop-ack comprehensive testing extra-note"
|
|
||||||
# → treat "comprehensive testing" as the slug source if it
|
|
||||||
# normalizes to a known item; otherwise treat "comprehensive"
|
|
||||||
# as slug and "testing extra-note" as note. We defer the
|
|
||||||
# disambiguation to the caller via the returned canonical
|
|
||||||
# slug. For simplicity: try the WHOLE captured string first.
|
|
||||||
canonical = normalize_slug(raw_slug, numeric_aliases)
|
canonical = normalize_slug(raw_slug, numeric_aliases)
|
||||||
else:
|
else:
|
||||||
canonical = normalize_slug(first, numeric_aliases)
|
canonical = normalize_slug(first, numeric_aliases)
|
||||||
note_from_group = (m.group(3) or "").strip()
|
note_from_group = (m.group(3) or "").strip()
|
||||||
# If we collapsed multi-word slug into kebab and there's a
|
out.append((kind, canonical, note_from_group))
|
||||||
# trailing-text group too, append it.
|
|
||||||
entry = (kind, canonical, note_from_group)
|
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
|
||||||
if kind == "sop-n/a":
|
gate = (m.group(1) or "").strip().lower()
|
||||||
na_directives.append(entry)
|
reason = (m.group(2) or "").strip()
|
||||||
else:
|
na_out.append(("sop-n/a", gate, reason))
|
||||||
directives.append(entry)
|
|
||||||
return directives, na_directives
|
return out, na_out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -180,8 +173,8 @@ def section_marker_present(body: str, marker: str) -> bool:
|
|||||||
on a non-empty line (i.e. the author actually filled it in).
|
on a non-empty line (i.e. the author actually filled it in).
|
||||||
|
|
||||||
We require the marker substring AND non-whitespace content on the
|
We require the marker substring AND non-whitespace content on the
|
||||||
same line OR within the next non-blank line — this prevents
|
same line OR within the next line — this prevents trivially-empty
|
||||||
trivially-empty checklists like:
|
checklists like:
|
||||||
|
|
||||||
## SOP-Checklist
|
## SOP-Checklist
|
||||||
- [ ] **Comprehensive testing performed**:
|
- [ ] **Comprehensive testing performed**:
|
||||||
@ -190,18 +183,9 @@ def section_marker_present(body: str, marker: str) -> bool:
|
|||||||
from auto-passing the section-present check. The peer-ack is still
|
from auto-passing the section-present check. The peer-ack is still
|
||||||
required, but answering with empty content is captured as a soft
|
required, but answering with empty content is captured as a soft
|
||||||
finding via the section-present test alone.
|
finding via the section-present test alone.
|
||||||
|
|
||||||
NOTE: we scan forward through blank lines (the markdown-header pattern
|
|
||||||
is ## Header\\n\\ncontent) so that a header + blank-line + content
|
|
||||||
structure still satisfies the check. The backward checkbox fallback
|
|
||||||
catches inline markers without a preceding checkbox (mc#1099).
|
|
||||||
"""
|
"""
|
||||||
if not body or not marker:
|
if not body or not marker:
|
||||||
return False
|
return False
|
||||||
# Strip trailing whitespace so the blank-line scan below can find
|
|
||||||
# content that appears on the very last line of the body (without
|
|
||||||
# being misled by a trailing \n or spaces).
|
|
||||||
body = body.rstrip()
|
|
||||||
body_lower = body.lower()
|
body_lower = body.lower()
|
||||||
marker_lower = marker.lower()
|
marker_lower = marker.lower()
|
||||||
idx = body_lower.find(marker_lower)
|
idx = body_lower.find(marker_lower)
|
||||||
@ -217,44 +201,13 @@ def section_marker_present(body: str, marker: str) -> bool:
|
|||||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
||||||
if stripped:
|
if stripped:
|
||||||
return True
|
return True
|
||||||
# Fall through: scan forward, skipping blank-only lines, until we find
|
# Fall through: check the NEXT line (multi-line answers).
|
||||||
# non-empty content or run out of body. Handles:
|
next_line_end = body.find("\n", line_end + 1)
|
||||||
# ## Header ← marker line (empty after marker)
|
if next_line_end < 0:
|
||||||
# ← blank line (skipped)
|
next_line_end = len(body)
|
||||||
# - actual content ← found
|
next_line = body[line_end + 1:next_line_end]
|
||||||
pos = line_end
|
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
|
||||||
while True:
|
return bool(stripped_next)
|
||||||
# Skip the current newline and any additional newlines (blank lines).
|
|
||||||
while pos < len(body) and body[pos] == "\n":
|
|
||||||
pos += 1
|
|
||||||
if pos >= len(body):
|
|
||||||
break
|
|
||||||
line_end = body.find("\n", pos)
|
|
||||||
if line_end < 0:
|
|
||||||
line_end = len(body)
|
|
||||||
line = body[pos:line_end]
|
|
||||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
|
||||||
if stripped:
|
|
||||||
return True
|
|
||||||
pos = line_end
|
|
||||||
# Last resort: the marker may appear mid-sentence (e.g.
|
|
||||||
# **Memory/saved-feedback consulted**: No applicable...).
|
|
||||||
# Search backward within the CURRENT LINE only (not preceding lines)
|
|
||||||
# to find a checkbox on the same line before the marker text.
|
|
||||||
# mc#1099 follow-up: memory-consulted detection was failing because
|
|
||||||
# the checkbox was on the same line before the inline marker.
|
|
||||||
_CHECKBOX_RE = re.compile(r"- \[[ x\]]|<input", re.IGNORECASE)
|
|
||||||
line_start = body.rfind("\n", 0, idx) + 1 # 0 if no newline before idx
|
|
||||||
before = body[line_start:idx]
|
|
||||||
m = _CHECKBOX_RE.search(before)
|
|
||||||
if not m:
|
|
||||||
return False
|
|
||||||
# Require meaningful content between the checkbox and the marker text
|
|
||||||
# (markdown formatting like ** or * must also be stripped).
|
|
||||||
# If only whitespace/markdown chars remain, the checkbox line is empty.
|
|
||||||
between = before[m.end() :]
|
|
||||||
stripped_between = re.sub(r"[\s\*:#\[\]_\-]+", "", between)
|
|
||||||
return bool(stripped_between)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -278,9 +231,8 @@ def compute_ack_state(
|
|||||||
{
|
{
|
||||||
"comprehensive-testing": {
|
"comprehensive-testing": {
|
||||||
"ackers": ["bob"], # non-author, team-verified
|
"ackers": ["bob"], # non-author, team-verified
|
||||||
"rejected_ackers": { # debugging info
|
"rejected": {
|
||||||
"self_ack": ["alice"],
|
"self_ack": ["alice"],
|
||||||
"unknown_slug": [],
|
|
||||||
"not_in_team": ["eve"],
|
"not_in_team": ["eve"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -297,7 +249,8 @@ def compute_ack_state(
|
|||||||
user = (c.get("user") or {}).get("login", "")
|
user = (c.get("user") or {}).get("login", "")
|
||||||
if not user:
|
if not user:
|
||||||
continue
|
continue
|
||||||
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
|
directives, _na_directives = parse_directives(body, numeric_aliases)
|
||||||
|
for kind, slug, _note in directives:
|
||||||
if not slug:
|
if not slug:
|
||||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||||
continue
|
continue
|
||||||
@ -307,25 +260,19 @@ def compute_ack_state(
|
|||||||
# Filter out self-acks and unknown slugs.
|
# Filter out self-acks and unknown slugs.
|
||||||
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||||
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||||
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
|
||||||
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||||
|
|
||||||
for (user, slug), kind in latest_directive.items():
|
for (user, slug), kind in latest_directive.items():
|
||||||
if kind != "sop-ack":
|
if kind != "sop-ack":
|
||||||
continue # revokes leave the (user,slug) state as "no ack"
|
continue # revokes leave the (user,slug) state as "no ack"
|
||||||
if slug not in items_by_slug:
|
if slug not in items_by_slug:
|
||||||
# Slug normalized to something not in our config — store
|
|
||||||
# under a synthetic key for diagnostic surfacing. Don't add
|
|
||||||
# to any item.
|
|
||||||
continue
|
continue
|
||||||
if user == pr_author:
|
if user == pr_author:
|
||||||
rejected_self[slug].append(user)
|
rejected_self[slug].append(user)
|
||||||
continue
|
continue
|
||||||
pending_team_check[slug].append(user)
|
pending_team_check[slug].append(user)
|
||||||
|
|
||||||
# Step 3: team membership probe per slug (batched per slug to keep
|
# Step 3: team membership probe per slug.
|
||||||
# API call count down — same user may ack multiple items but the
|
|
||||||
# required_teams differ per item, so we MUST probe per (user, item)).
|
|
||||||
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||||
for slug, candidates in pending_team_check.items():
|
for slug, candidates in pending_team_check.items():
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@ -334,7 +281,6 @@ def compute_ack_state(
|
|||||||
approved = team_membership_probe(slug, candidates) # returns subset
|
approved = team_membership_probe(slug, candidates) # returns subset
|
||||||
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
||||||
ackers_per_slug[slug] = approved
|
ackers_per_slug[slug] = approved
|
||||||
# Stash required teams for description rendering.
|
|
||||||
items_by_slug[slug]["_required_resolved"] = required
|
items_by_slug[slug]["_required_resolved"] = required
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -349,61 +295,111 @@ def compute_ack_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# N/A-gate evaluation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def compute_na_state(
|
def compute_na_state(
|
||||||
comments: list[dict[str, Any]],
|
comments: list[dict[str, Any]],
|
||||||
author: str,
|
pr_author: str,
|
||||||
na_gates: dict[str, Any],
|
na_gates: dict[str, dict[str, Any]],
|
||||||
probe: Callable[[str, list[str]], list[str]],
|
numeric_aliases: dict[int, str],
|
||||||
|
team_membership_probe: "callable[[str, list[str]], list[str]]",
|
||||||
|
client: "GiteaClient",
|
||||||
|
org: str,
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Evaluate which N/A gates have a valid declaration from a team member.
|
"""Compute per-gate N/A declaration state.
|
||||||
|
|
||||||
Returns dict[gate_name, dict] where each dict has:
|
Returns a dict keyed by gate name:
|
||||||
declared: bool — at least one valid non-author team-member declared N/A
|
{
|
||||||
decl_ackers: list[str] — usernames who declared this gate N/A
|
"qa-review": {
|
||||||
rejected: dict with keys:
|
"declared": ["alice"], # non-author, team-verified, not revoked
|
||||||
not_in_team: list[str] — users who tried but aren't in required teams
|
"rejected": ["eve (not-in-team)", "bob (self-decl)"],
|
||||||
|
"reason": "pure-infra change — no qa surface",
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
A gate is N/A-satisfied when at least one declaration from a valid
|
||||||
|
team member exists and has not been revoked by the same user.
|
||||||
"""
|
"""
|
||||||
# Build per-user latest N/A directive (most-recent wins per RFC#324).
|
if not na_gates:
|
||||||
latest_na: dict[str, tuple[str, str]] = {} # user → (gate, note)
|
return {}
|
||||||
|
|
||||||
|
# Collapse directives per (commenter, gate) — most recent wins.
|
||||||
|
latest_na: dict[tuple[str, str], str] = {} # (user, gate) → "sop-n/a"
|
||||||
|
latest_na_reason: dict[tuple[str, str], str] = {} # (user, gate) → reason
|
||||||
for c in comments:
|
for c in comments:
|
||||||
body = c.get("body", "") or ""
|
body = c.get("body", "") or ""
|
||||||
user = (c.get("user") or {}).get("login", "")
|
user = (c.get("user") or {}).get("login", "")
|
||||||
if not user:
|
if not user:
|
||||||
continue
|
continue
|
||||||
for kind, gate, note in parse_directives(body, {})[1]:
|
_directives, na_directives = parse_directives(body, numeric_aliases)
|
||||||
# [1] = na_directives only
|
for _kind, gate, reason in na_directives:
|
||||||
if gate in na_gates:
|
if gate not in na_gates:
|
||||||
latest_na[user] = (gate, note)
|
|
||||||
|
|
||||||
result: dict[str, dict[str, Any]] = {}
|
|
||||||
for gate, gate_cfg in na_gates.items():
|
|
||||||
result[gate] = {
|
|
||||||
"declared": False,
|
|
||||||
"decl_ackers": [],
|
|
||||||
"rejected": {"not_in_team": []},
|
|
||||||
}
|
|
||||||
decl_ackers: list[str] = []
|
|
||||||
not_in_team: list[str] = []
|
|
||||||
for user, (g, _note) in latest_na.items():
|
|
||||||
if g != gate:
|
|
||||||
continue
|
continue
|
||||||
if user == author:
|
latest_na[(user, gate)] = "sop-n/a"
|
||||||
continue # authors cannot self-declare N/A
|
latest_na_reason[(user, gate)] = reason
|
||||||
approved = probe(gate, [user])
|
|
||||||
if approved:
|
|
||||||
decl_ackers.append(user)
|
|
||||||
else:
|
|
||||||
not_in_team.append(user)
|
|
||||||
result[gate]["declared"] = bool(decl_ackers)
|
|
||||||
result[gate]["decl_ackers"] = decl_ackers
|
|
||||||
result[gate]["rejected"]["not_in_team"] = not_in_team
|
|
||||||
|
|
||||||
return result
|
# Determine candidate declarers per gate.
|
||||||
|
na_state: dict[str, dict[str, Any]] = {
|
||||||
|
gate: {"declared": [], "rejected": [], "reason": ""}
|
||||||
|
for gate in na_gates
|
||||||
|
}
|
||||||
|
pending_per_gate: dict[str, list[str]] = {gate: [] for gate in na_gates}
|
||||||
|
|
||||||
|
for (user, gate), kind in latest_na.items():
|
||||||
|
if kind != "sop-n/a":
|
||||||
|
continue
|
||||||
|
if user == pr_author:
|
||||||
|
na_state[gate]["rejected"].append(f"{user} (self-decl)")
|
||||||
|
continue
|
||||||
|
pending_per_gate[gate].append(user)
|
||||||
|
|
||||||
|
# Probe team membership per gate using that gate's required_teams.
|
||||||
|
for gate, candidates in pending_per_gate.items():
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
required_teams = na_gates[gate].get("required_teams", [])
|
||||||
|
# Resolve team names → ids using the client's resolver.
|
||||||
|
team_ids: list[int] = []
|
||||||
|
for tn in required_teams:
|
||||||
|
tid = client.resolve_team_id(org, tn)
|
||||||
|
if tid is not None:
|
||||||
|
team_ids.append(tid)
|
||||||
|
if not team_ids:
|
||||||
|
na_state[gate]["rejected"].extend(
|
||||||
|
f"{u} (no-team-id)" for u in candidates
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for u in candidates:
|
||||||
|
in_any_team = False
|
||||||
|
for tid in team_ids:
|
||||||
|
result = client.is_team_member(tid, u)
|
||||||
|
if result is True:
|
||||||
|
in_any_team = True
|
||||||
|
break
|
||||||
|
if result is None:
|
||||||
|
# 403 — token owner not in team. Fail-closed.
|
||||||
|
print(
|
||||||
|
f"::warning::na: team-probe for {u} in team-id {tid} "
|
||||||
|
"returned 403 — treating as not-in-team (fail-closed)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if in_any_team:
|
||||||
|
na_state[gate]["declared"].append(u)
|
||||||
|
else:
|
||||||
|
na_state[gate]["rejected"].append(f"{u} (not-in-team)")
|
||||||
|
|
||||||
|
# Build per-gate reason string from declared users.
|
||||||
|
for gate in na_gates:
|
||||||
|
decl = na_state[gate]["declared"]
|
||||||
|
if decl:
|
||||||
|
reasons: list[str] = []
|
||||||
|
for u in decl:
|
||||||
|
r = latest_na_reason.get((u, gate), "")
|
||||||
|
if r:
|
||||||
|
reasons.append(f"{u}: {r}")
|
||||||
|
else:
|
||||||
|
reasons.append(u)
|
||||||
|
na_state[gate]["reason"] = "; ".join(reasons)
|
||||||
|
|
||||||
|
return na_state
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -800,10 +796,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
cfg = load_config(args.config)
|
cfg = load_config(args.config)
|
||||||
items: list[dict[str, Any]] = cfg["items"]
|
items: list[dict[str, Any]] = cfg["items"]
|
||||||
items_by_slug = {it["slug"]: it for it in items}
|
items_by_slug = {it["slug"]: it for it in items}
|
||||||
na_gates: dict[str, Any] = cfg.get("n/a_gates", {})
|
|
||||||
numeric_aliases = {
|
numeric_aliases = {
|
||||||
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
||||||
}
|
}
|
||||||
|
na_gates: dict[str, dict[str, Any]] = cfg.get("n/a_gates") or {}
|
||||||
|
|
||||||
client = GiteaClient(args.gitea_host, token) if token else None
|
client = GiteaClient(args.gitea_host, token) if token else None
|
||||||
if not client:
|
if not client:
|
||||||
@ -823,6 +819,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
|
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
||||||
|
|
||||||
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
||||||
|
|
||||||
# Build team-membership probe closure that caches results per
|
# Build team-membership probe closure that caches results per
|
||||||
@ -880,6 +878,47 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
||||||
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
||||||
|
|
||||||
|
# --- N/A gate state (RFC#324 §N/A follow-up) ---
|
||||||
|
na_state: dict[str, dict[str, Any]] = {}
|
||||||
|
if na_gates:
|
||||||
|
na_state = compute_na_state(
|
||||||
|
comments, author, na_gates, numeric_aliases,
|
||||||
|
probe, client, args.owner,
|
||||||
|
)
|
||||||
|
# Post N/A declarations status (read by review-check.sh).
|
||||||
|
na_satisfied = [g for g, s in na_state.items() if s["declared"]]
|
||||||
|
na_missing = [g for g, s in na_state.items() if not s["declared"]]
|
||||||
|
if na_satisfied:
|
||||||
|
na_desc = f"N/A: {', '.join(na_satisfied)}"
|
||||||
|
na_post_state = "success"
|
||||||
|
elif na_missing:
|
||||||
|
na_desc = f"awaiting /sop-n/a declaration for: {', '.join(na_missing)}"
|
||||||
|
na_post_state = "pending"
|
||||||
|
else:
|
||||||
|
# Configured but no declarations yet.
|
||||||
|
na_desc = "no /sop-n/a declarations yet"
|
||||||
|
na_post_state = "pending"
|
||||||
|
na_context = "sop-checklist / na-declarations (pull_request)"
|
||||||
|
print(f"::notice::na-declarations status: {na_post_state} — {na_desc}")
|
||||||
|
if not args.dry_run:
|
||||||
|
client.post_status(
|
||||||
|
args.owner, args.repo, head_sha,
|
||||||
|
state=na_post_state, context=na_context,
|
||||||
|
description=na_desc,
|
||||||
|
target_url=target_url,
|
||||||
|
)
|
||||||
|
print(f"::notice::na-declarations status posted: {na_context} → {na_post_state}")
|
||||||
|
# Log per-gate diagnostics.
|
||||||
|
for gate in na_gates:
|
||||||
|
s = na_state.get(gate, {})
|
||||||
|
if s.get("declared"):
|
||||||
|
print(f"::notice:: [PASS] gate={gate} — N/A declared by {','.join(s['declared'])}"
|
||||||
|
+ (f" ({s['reason']})" if s.get("reason") else ""))
|
||||||
|
else:
|
||||||
|
extra = f" — rejected: {', '.join(s.get('rejected', []))}" if s.get("rejected") else ""
|
||||||
|
print(f"::notice:: [WAIT] gate={gate} — no valid N/A declaration yet{extra}")
|
||||||
|
|
||||||
|
|
||||||
state, description = render_status(items, ack_state, body_state)
|
state, description = render_status(items, ack_state, body_state)
|
||||||
mode = get_tier_mode(pr, cfg)
|
mode = get_tier_mode(pr, cfg)
|
||||||
if mode == "soft":
|
if mode == "soft":
|
||||||
@ -914,53 +953,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0 if state in ("success", "pending") else 1
|
return 0 if state in ("success", "pending") else 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
|
||||||
client.post_status(
|
client.post_status(
|
||||||
args.owner, args.repo, head_sha,
|
args.owner, args.repo, head_sha,
|
||||||
state=state, context=args.status_context,
|
state=state, context=args.status_context,
|
||||||
description=description, target_url=target_url,
|
description=description, target_url=target_url,
|
||||||
)
|
)
|
||||||
print(f"::notice::status posted: {args.status_context} → {state}")
|
print(f"::notice::status posted: {args.status_context} → {state}")
|
||||||
|
|
||||||
# --- N/A gate status (RFC#324 §N/A follow-up) ---
|
|
||||||
# Post a separate status so review-check.sh can discover N/A declarations
|
|
||||||
# and waive the Gitea-approve requirement for that gate.
|
|
||||||
na_state: dict[str, dict[str, Any]] = {}
|
|
||||||
if na_gates:
|
|
||||||
na_state = compute_na_state(comments, author, na_gates, probe)
|
|
||||||
|
|
||||||
na_descs: list[str] = []
|
|
||||||
for gate, s in na_state.items():
|
|
||||||
if s["declared"]:
|
|
||||||
na_descs.append(gate)
|
|
||||||
decl = s["decl_ackers"]
|
|
||||||
rej = s["rejected"]["not_in_team"]
|
|
||||||
if decl:
|
|
||||||
print(f"::notice:: [N/A OK] {gate} — declared by {','.join(decl)}")
|
|
||||||
if rej:
|
|
||||||
print(
|
|
||||||
f"::notice:: [N/A REJ] {gate} — not-in-team: {','.join(rej)}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
na_desc = ", ".join(sorted(na_descs)) if na_descs else "(none)"
|
|
||||||
na_status_state = "success" if na_descs else "pending"
|
|
||||||
# review-check.sh reads the description to discover which gates are N/A.
|
|
||||||
# Include the gate names so it can grep for them.
|
|
||||||
na_description = f"N/A: {na_desc}" if na_descs else "N/A: (none)"
|
|
||||||
|
|
||||||
if not args.dry_run:
|
|
||||||
client.post_status(
|
|
||||||
args.owner, args.repo, head_sha,
|
|
||||||
state=na_status_state,
|
|
||||||
context="sop-checklist / na-declarations (pull_request)",
|
|
||||||
description=na_description,
|
|
||||||
target_url=target_url,
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"::notice::na-declarations status → {na_status_state}: {na_description}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
||||||
# conclusion. If the job exits 1 BP will see TWO failure signals
|
# conclusion. If the job exits 1 BP will see TWO failure signals
|
||||||
# (one from the job's auto-status, one from our POST), making the
|
# (one from the job's auto-status, one from our POST), making the
|
||||||
@ -133,9 +133,6 @@ PUSH_COMPENSATION_DESCRIPTION = (
|
|||||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||||
)
|
)
|
||||||
# Backward-compatible alias for older tests/tooling that predate the split
|
|
||||||
# between push-suffix compensation and pull-request-shadow compensation.
|
|
||||||
COMPENSATION_DESCRIPTION = PUSH_COMPENSATION_DESCRIPTION
|
|
||||||
PR_SHADOW_COMPENSATION_DESCRIPTION = (
|
PR_SHADOW_COMPENSATION_DESCRIPTION = (
|
||||||
"Compensated by status-reaper (default-branch pull_request status "
|
"Compensated by status-reaper (default-branch pull_request status "
|
||||||
"shadowed by successful push status on same SHA; see "
|
"shadowed by successful push status on same SHA; see "
|
||||||
@ -614,10 +611,11 @@ def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
|
|||||||
(verified via vendor-truth probe 2026-05-11 against
|
(verified via vendor-truth probe 2026-05-11 against
|
||||||
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
|
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
|
||||||
|
|
||||||
Raises ApiError on non-2xx OR on unexpected response shape. The
|
Raises ApiError on non-2xx OR on unexpected response shape. This is
|
||||||
branch-level caller soft-skips this tick because the next scheduled
|
a HARD halt — without the commit list the sweep can't proceed. (The
|
||||||
tick can safely retry the listing. Per-SHA status/write errors remain
|
per-SHA error isolation downstream is a different concern: tolerating
|
||||||
separate and must not be mislabeled as commit-list outages.
|
a transient 5xx on ONE commit's status is best-effort; losing the
|
||||||
|
commit list itself means we don't even know which commits to try.)
|
||||||
"""
|
"""
|
||||||
_, body = api(
|
_, body = api(
|
||||||
"GET",
|
"GET",
|
||||||
@ -658,27 +656,7 @@ def reap_branch(
|
|||||||
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
|
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
|
||||||
SHAs that actually got at least one compensation are included
|
SHAs that actually got at least one compensation are included
|
||||||
"""
|
"""
|
||||||
try:
|
shas = list_recent_commit_shas(branch, limit)
|
||||||
shas = list_recent_commit_shas(branch, limit)
|
|
||||||
except ApiError as e:
|
|
||||||
print(
|
|
||||||
"::warning::status-reaper skipped this tick because the "
|
|
||||||
f"commit list could not be read after retries: {e}"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"scanned_shas": 0,
|
|
||||||
"compensated": 0,
|
|
||||||
"preserved_real_push": 0,
|
|
||||||
"preserved_unknown": 0,
|
|
||||||
"preserved_non_failure": 0,
|
|
||||||
"preserved_non_push_suffix": 0,
|
|
||||||
"preserved_unparseable": 0,
|
|
||||||
"compensated_pr_shadowed_by_push_success": 0,
|
|
||||||
"preserved_pr_without_push_success": 0,
|
|
||||||
"compensated_per_sha": {},
|
|
||||||
"skipped": True,
|
|
||||||
"skip_reason": "commit-list-api-error",
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregate: dict[str, Any] = {
|
aggregate: dict[str, Any] = {
|
||||||
"scanned_shas": 0,
|
"scanned_shas": 0,
|
||||||
|
|||||||
@ -85,10 +85,7 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
|
|||||||
|
|
||||||
def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
||||||
required = ["CI / all-required (pull_request)"]
|
required = ["CI / all-required (pull_request)"]
|
||||||
main_status = {
|
main_status = {"state": "success", "statuses": []}
|
||||||
"state": "success",
|
|
||||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
|
||||||
}
|
|
||||||
pr_status = {
|
pr_status = {
|
||||||
"state": "success",
|
"state": "success",
|
||||||
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
|
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
|
||||||
@ -107,10 +104,7 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base():
|
|||||||
|
|
||||||
def test_merge_decision_updates_stale_pr_before_merge():
|
def test_merge_decision_updates_stale_pr_before_merge():
|
||||||
decision = mq.evaluate_merge_readiness(
|
decision = mq.evaluate_merge_readiness(
|
||||||
main_status={
|
main_status={"state": "success", "statuses": []},
|
||||||
"state": "success",
|
|
||||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
|
||||||
},
|
|
||||||
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
|
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
|
||||||
required_contexts=["CI / all-required (pull_request)"],
|
required_contexts=["CI / all-required (pull_request)"],
|
||||||
pr_has_current_base=False,
|
pr_has_current_base=False,
|
||||||
@ -118,13 +112,3 @@ def test_merge_decision_updates_stale_pr_before_merge():
|
|||||||
|
|
||||||
assert decision.ready is False
|
assert decision.ready is False
|
||||||
assert decision.action == "update"
|
assert decision.action == "update"
|
||||||
|
|
||||||
|
|
||||||
def test_MergePermissionError_inherits_from_ApiError():
|
|
||||||
assert issubclass(mq.MergePermissionError, mq.ApiError)
|
|
||||||
|
|
||||||
|
|
||||||
def test_MergePermissionError_message_preserved():
|
|
||||||
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
|
|
||||||
assert "405" in str(exc)
|
|
||||||
assert "User not allowed" in str(exc)
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Unit tests for sop-checklist.py
|
# Unit tests for sop-checklist-gate.py
|
||||||
#
|
#
|
||||||
# Run: python3 .gitea/scripts/tests/test_sop_checklist.py
|
# Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py
|
||||||
# or: pytest .gitea/scripts/tests/test_sop_checklist.py
|
# or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py
|
||||||
#
|
#
|
||||||
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
|
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
|
||||||
# - slug normalization (the 4 example variants in the script header)
|
# - slug normalization (the 4 example variants in the script header)
|
||||||
@ -33,7 +33,7 @@ sys.path.insert(0, PARENT)
|
|||||||
import importlib.util # noqa: E402
|
import importlib.util # noqa: E402
|
||||||
|
|
||||||
_spec = importlib.util.spec_from_file_location(
|
_spec = importlib.util.spec_from_file_location(
|
||||||
"sop_checklist", os.path.join(PARENT, "sop-checklist.py")
|
"sop_checklist_gate", os.path.join(PARENT, "sop-checklist-gate.py")
|
||||||
)
|
)
|
||||||
sop = importlib.util.module_from_spec(_spec)
|
sop = importlib.util.module_from_spec(_spec)
|
||||||
_spec.loader.exec_module(sop) # type: ignore[union-attr]
|
_spec.loader.exec_module(sop) # type: ignore[union-attr]
|
||||||
@ -551,55 +551,3 @@ class TestEndToEndAckFlow(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# compute_na_state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestComputeNaState(unittest.TestCase):
|
|
||||||
"""Tests for /sop-n/a directive evaluation."""
|
|
||||||
|
|
||||||
def test_no_na_declarations(self):
|
|
||||||
cfg = sop.load_config(CONFIG_PATH)
|
|
||||||
na_gates = cfg.get("n/a_gates", {})
|
|
||||||
comments = []
|
|
||||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda *_: [])
|
|
||||||
self.assertFalse(na_state["qa-review"]["declared"])
|
|
||||||
self.assertFalse(na_state["security-review"]["declared"])
|
|
||||||
|
|
||||||
def test_na_declared_by_authorized_user(self):
|
|
||||||
cfg = sop.load_config(CONFIG_PATH)
|
|
||||||
na_gates = cfg.get("n/a_gates", {})
|
|
||||||
comments = [_comment("bob", "/sop-n/a qa-review N/A: pure tooling change")]
|
|
||||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
|
|
||||||
self.assertTrue(na_state["qa-review"]["declared"])
|
|
||||||
self.assertEqual(na_state["qa-review"]["decl_ackers"], ["bob"])
|
|
||||||
|
|
||||||
def test_na_declared_by_unauthorized_user_rejected(self):
|
|
||||||
cfg = sop.load_config(CONFIG_PATH)
|
|
||||||
na_gates = cfg.get("n/a_gates", {})
|
|
||||||
comments = [_comment("mallory", "/sop-n/a qa-review N/A: not real team")]
|
|
||||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: [])
|
|
||||||
self.assertFalse(na_state["qa-review"]["declared"])
|
|
||||||
self.assertEqual(na_state["qa-review"]["rejected"]["not_in_team"], ["mallory"])
|
|
||||||
|
|
||||||
def test_author_cannot_self_declare_na(self):
|
|
||||||
cfg = sop.load_config(CONFIG_PATH)
|
|
||||||
na_gates = cfg.get("n/a_gates", {})
|
|
||||||
comments = [_comment("alice", "/sop-n/a qa-review N/A: I am the author")]
|
|
||||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
|
|
||||||
self.assertFalse(na_state["qa-review"]["declared"])
|
|
||||||
|
|
||||||
def test_parse_directives_separates_na_from_ack(self):
|
|
||||||
directives, na_directives = sop.parse_directives(
|
|
||||||
"/sop-ack comprehensive-testing\n/sop-n/a qa-review N/A: no surface",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
self.assertEqual(len(directives), 1)
|
|
||||||
self.assertEqual(directives[0][0], "sop-ack")
|
|
||||||
self.assertEqual(len(na_directives), 1)
|
|
||||||
self.assertEqual(na_directives[0][0], "sop-n/a")
|
|
||||||
self.assertEqual(na_directives[0][1], "qa-review")
|
|
||||||
self.assertIn("no surface", na_directives[0][2])
|
|
||||||
@ -111,7 +111,7 @@ items:
|
|||||||
# N/A gate declarations (RFC#324 §N/A follow-up).
|
# N/A gate declarations (RFC#324 §N/A follow-up).
|
||||||
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
|
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
|
||||||
# qa surface, or docs-only) can be declared N/A by a non-author peer
|
# qa surface, or docs-only) can be declared N/A by a non-author peer
|
||||||
# who is in one of the gate's required_teams. The sop-checklist
|
# who is in one of the gate's required_teams. The sop-checklist-gate
|
||||||
# posts a `sop-checklist / na-declarations (pull_request)` status that
|
# posts a `sop-checklist / na-declarations (pull_request)` status that
|
||||||
# review-check.sh reads to skip the Gitea-APPROVE requirement.
|
# review-check.sh reads to skip the Gitea-APPROVE requirement.
|
||||||
#
|
#
|
||||||
|
|||||||
@ -133,6 +133,7 @@ jobs:
|
|||||||
# the name match works on PRs that don't touch workspace-server/).
|
# the name match works on PRs that don't touch workspace-server/).
|
||||||
platform-build:
|
platform-build:
|
||||||
name: Platform (Go)
|
name: Platform (Go)
|
||||||
|
needs: changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
||||||
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
|
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
|
||||||
@ -145,37 +146,33 @@ jobs:
|
|||||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
|
||||||
# this cap catches any step that leaks past that. Set well above 10m so
|
|
||||||
# the per-step timeout is the active constraint.
|
|
||||||
timeout-minutes: 15
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: workspace-server
|
working-directory: workspace-server
|
||||||
steps:
|
steps:
|
||||||
- if: false
|
- if: needs.changes.outputs.platform != 'true'
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
run: go mod download
|
run: go mod download
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
run: go build ./cmd/server
|
run: go build ./cmd/server
|
||||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Install golangci-lint
|
name: Install golangci-lint
|
||||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Run golangci-lint
|
name: Run golangci-lint
|
||||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Diagnostic — per-package verbose 60s
|
name: Diagnostic — per-package verbose 60s
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
@ -191,15 +188,11 @@ jobs:
|
|||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Run tests with race detection and coverage
|
name: Run tests with race detection and coverage
|
||||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
run: go test -race -coverprofile=coverage.out ./...
|
||||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
|
||||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
|
||||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
|
||||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Per-file coverage report
|
name: Per-file coverage report
|
||||||
# Advisory — lists every source file with its coverage so reviewers
|
# Advisory — lists every source file with its coverage so reviewers
|
||||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||||
@ -213,7 +206,7 @@ jobs:
|
|||||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||||
| sort -n
|
| sort -n
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.platform == 'true'
|
||||||
name: Check coverage thresholds
|
name: Check coverage thresholds
|
||||||
# Enforces two gates from #1823 Layer 1:
|
# Enforces two gates from #1823 Layer 1:
|
||||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||||
@ -301,28 +294,28 @@ jobs:
|
|||||||
# siblings — verified empirically on PR #2314).
|
# siblings — verified empirically on PR #2314).
|
||||||
canvas-build:
|
canvas-build:
|
||||||
name: Canvas (Next.js)
|
name: Canvas (Next.js)
|
||||||
|
needs: changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
|
||||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: canvas
|
working-directory: canvas
|
||||||
steps:
|
steps:
|
||||||
- if: false
|
- if: needs.changes.outputs.canvas != 'true'
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||||
- if: always()
|
- if: needs.changes.outputs.canvas == 'true'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- if: always()
|
- if: needs.changes.outputs.canvas == 'true'
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- if: always()
|
- if: needs.changes.outputs.canvas == 'true'
|
||||||
run: rm -f package-lock.json && npm install
|
run: rm -f package-lock.json && npm install
|
||||||
- if: always()
|
- if: needs.changes.outputs.canvas == 'true'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- if: always()
|
- if: needs.changes.outputs.canvas == 'true'
|
||||||
name: Run tests with coverage
|
name: Run tests with coverage
|
||||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||||
@ -331,7 +324,7 @@ jobs:
|
|||||||
# tracked in #1815) after the team sees what current coverage is.
|
# tracked in #1815) after the team sees what current coverage is.
|
||||||
run: npx vitest run --coverage
|
run: npx vitest run --coverage
|
||||||
- name: Upload coverage summary as artifact
|
- name: Upload coverage summary as artifact
|
||||||
if: always()
|
if: needs.changes.outputs.canvas == 'true' && always()
|
||||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||||
@ -348,15 +341,16 @@ jobs:
|
|||||||
# Shellcheck (E2E scripts) — required check, always runs.
|
# Shellcheck (E2E scripts) — required check, always runs.
|
||||||
shellcheck:
|
shellcheck:
|
||||||
name: Shellcheck (E2E scripts)
|
name: Shellcheck (E2E scripts)
|
||||||
|
needs: changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
steps:
|
steps:
|
||||||
- if: false
|
- if: needs.changes.outputs.scripts != 'true'
|
||||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
||||||
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
||||||
# infra/scripts/ is included because setup.sh + nuke.sh gate the
|
# infra/scripts/ is included because setup.sh + nuke.sh gate the
|
||||||
@ -367,16 +361,16 @@ jobs:
|
|||||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||||
| xargs -0 shellcheck --severity=warning
|
| xargs -0 shellcheck --severity=warning
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
name: Lint cleanup-trap hygiene (RFC #2873)
|
name: Lint cleanup-trap hygiene (RFC #2873)
|
||||||
run: bash tests/e2e/lint_cleanup_traps.sh
|
run: bash tests/e2e/lint_cleanup_traps.sh
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
name: Run E2E bash unit tests (no live infra)
|
name: Run E2E bash unit tests (no live infra)
|
||||||
run: |
|
run: |
|
||||||
bash tests/e2e/test_model_slug.sh
|
bash tests/e2e/test_model_slug.sh
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
|
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
|
||||||
# Covers scripts/promote-tenant-image.sh — the codified
|
# Covers scripts/promote-tenant-image.sh — the codified
|
||||||
# :staging-latest → :latest ECR promote + tenant fleet redeploy
|
# :staging-latest → :latest ECR promote + tenant fleet redeploy
|
||||||
@ -386,7 +380,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bash scripts/test-promote-tenant-image.sh
|
bash scripts/test-promote-tenant-image.sh
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.scripts == 'true'
|
||||||
name: Shellcheck promote-tenant-image script
|
name: Shellcheck promote-tenant-image script
|
||||||
# scripts/ is excluded from the bulk shellcheck pass above (legacy
|
# scripts/ is excluded from the bulk shellcheck pass above (legacy
|
||||||
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
|
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
|
||||||
@ -397,18 +391,15 @@ jobs:
|
|||||||
scripts/promote-tenant-image.sh \
|
scripts/promote-tenant-image.sh \
|
||||||
scripts/test-promote-tenant-image.sh
|
scripts/test-promote-tenant-image.sh
|
||||||
|
|
||||||
# mc#959 root-fix (sre)
|
|
||||||
|
|
||||||
canvas-deploy-reminder:
|
canvas-deploy-reminder:
|
||||||
name: Canvas Deploy Reminder
|
name: Canvas Deploy Reminder
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
|
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||||
# ci_job_names() detects this as github.ref-gated and skips it from F1.
|
continue-on-error: true
|
||||||
# The step-level exit 0 handles the "not main push" case; the job-level
|
|
||||||
# `if:` makes the gating explicit so the drift script sees it.
|
|
||||||
# Runs on both main and staging pushes; step exits 0 when not applicable.
|
|
||||||
if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' }}
|
|
||||||
needs: [changes, canvas-build]
|
needs: [changes, canvas-build]
|
||||||
|
# Keep the job itself always runnable. Gitea 1.22.6 leaves job-level
|
||||||
|
# event/ref `if:` gates as pending on PRs, which blocks the combined
|
||||||
|
# status even though this reminder is intentionally non-required.
|
||||||
steps:
|
steps:
|
||||||
- name: Write deploy reminder to step summary
|
- name: Write deploy reminder to step summary
|
||||||
env:
|
env:
|
||||||
@ -458,6 +449,7 @@ jobs:
|
|||||||
# Python Lint & Test — required check, always runs.
|
# Python Lint & Test — required check, always runs.
|
||||||
python-lint:
|
python-lint:
|
||||||
name: Python Lint & Test
|
name: Python Lint & Test
|
||||||
|
needs: changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
@ -467,25 +459,25 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: workspace
|
working-directory: workspace
|
||||||
steps:
|
steps:
|
||||||
- if: false
|
- if: needs.changes.outputs.python != 'true'
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
||||||
- if: always()
|
- if: needs.changes.outputs.python == 'true'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- if: always()
|
- if: needs.changes.outputs.python == 'true'
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: workspace/requirements.txt
|
cache-dependency-path: workspace/requirements.txt
|
||||||
- if: always()
|
- if: needs.changes.outputs.python == 'true'
|
||||||
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
|
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
|
||||||
# Coverage flags + fail-under floor moved into workspace/pytest.ini
|
# Coverage flags + fail-under floor moved into workspace/pytest.ini
|
||||||
# (issue #1817) so local `pytest` and CI use identical config.
|
# (issue #1817) so local `pytest` and CI use identical config.
|
||||||
- if: always()
|
- if: needs.changes.outputs.python == 'true'
|
||||||
run: python -m pytest --tb=short
|
run: python -m pytest --tb=short
|
||||||
|
|
||||||
- if: always()
|
- if: needs.changes.outputs.python == 'true'
|
||||||
name: Per-file critical-path coverage (MCP / inbox / auth)
|
name: Per-file critical-path coverage (MCP / inbox / auth)
|
||||||
# MCP-critical Python files have a per-file floor on top of the
|
# MCP-critical Python files have a per-file floor on top of the
|
||||||
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
|
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
|
||||||
@ -550,104 +542,85 @@ jobs:
|
|||||||
# red silently merged through. See internal#286 for the three concrete
|
# red silently merged through. See internal#286 for the three concrete
|
||||||
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
||||||
#
|
#
|
||||||
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
|
# Three properties of this job each close a failure mode:
|
||||||
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
|
|
||||||
# jobs settle, leaving branch protection with a permanent pending
|
|
||||||
# `CI / all-required` context. Instead, this independent sentinel polls the
|
|
||||||
# required commit-status contexts for this SHA and fails if any fail, skip,
|
|
||||||
# or never emit.
|
|
||||||
#
|
#
|
||||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
# 1. `if: always()` — runs even when an upstream fails. Without it the
|
||||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
# sentinel is `skipped` and protection treats that as missing → merge
|
||||||
# it in this dependency list lets a skipped reminder skip the required
|
# ungated.
|
||||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
|
||||||
#
|
#
|
||||||
|
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
|
||||||
|
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
|
||||||
|
# entry that couldn't run) must NOT silently pass through.
|
||||||
|
# `skipped`-as-green is exactly the failure mode this gate closes.
|
||||||
|
#
|
||||||
|
# 3. `needs:` is the canonical list of "what counts as required."
|
||||||
|
# status_check_contexts will reference only `ci/all-required` (Step 5
|
||||||
|
# follow-up — branch-protection PATCH is Owners-tier per
|
||||||
|
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
|
||||||
|
# added simply by listing it in `needs:` here.
|
||||||
|
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
|
||||||
|
# hourly if this list diverges from status_check_contexts or from
|
||||||
|
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||||
|
#
|
||||||
|
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
|
||||||
|
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
|
||||||
|
# Including it in all-required.needs causes all-required to hang on
|
||||||
|
# every CI-only PR. Keep it runnable on PRs via its own
|
||||||
|
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
|
||||||
|
#
|
||||||
|
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||||
|
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||||
|
# (Gitea suppresses status reporting for CoE jobs). This sentinel
|
||||||
|
# runs with continue-on-error: false so it always reports its
|
||||||
|
# result to the API — without this, the required-status entry
|
||||||
|
# (CI / all-required (pull_request)) is never created, which
|
||||||
|
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
|
||||||
|
# continue-on-error: false; this sentinel can then be flipped to
|
||||||
|
# continue-on-error: true if a Phase-4 regression requires it.
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 1
|
||||||
|
needs:
|
||||||
|
- changes
|
||||||
|
- platform-build
|
||||||
|
- canvas-build
|
||||||
|
- shellcheck
|
||||||
|
- python-lint
|
||||||
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for required CI contexts
|
- name: Assert every required dependency succeeded
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
API_ROOT: ${{ github.server_url }}/api/v1
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
COMMIT_SHA: ${{ github.sha }}
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 - <<'PY'
|
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
|
||||||
import json
|
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
|
||||||
import os
|
# Null results are skipped: they come from Phase 3 (continue-on-error: true
|
||||||
import sys
|
# suppresses status) or from jobs still in-flight. The sentinel succeeds
|
||||||
import time
|
# rather than blocking PRs on Phase 3 noise.
|
||||||
import urllib.error
|
results='${{ toJSON(needs) }}'
|
||||||
import urllib.request
|
echo "$results"
|
||||||
|
echo "$results" | python3 -c '
|
||||||
token = os.environ["GITEA_TOKEN"]
|
import json, sys
|
||||||
api_root = os.environ["API_ROOT"].rstrip("/")
|
ns = json.load(sys.stdin)
|
||||||
repo = os.environ["REPOSITORY"]
|
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
|
||||||
sha = os.environ["COMMIT_SHA"]
|
# Remove when mc#774 handler test failures are resolved.
|
||||||
event = os.environ["EVENT_NAME"]
|
PHASE3_MASKED = {"platform-build"}
|
||||||
required = [
|
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
|
||||||
f"CI / Detect changes ({event})",
|
bad = [(k, v.get("result")) for k, v in ns.items()
|
||||||
f"CI / Platform (Go) ({event})",
|
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
|
||||||
f"CI / Canvas (Next.js) ({event})",
|
if bad:
|
||||||
f"CI / Shellcheck (E2E scripts) ({event})",
|
print(f"FAIL: jobs not green:", file=sys.stderr)
|
||||||
f"CI / Python Lint & Test ({event})",
|
for k, r in bad:
|
||||||
]
|
print(f" - {k}: {r}", file=sys.stderr)
|
||||||
terminal_bad = {"failure", "error"}
|
sys.exit(1)
|
||||||
deadline = time.time() + 40 * 60
|
pending = [(k, v.get("result")) for k, v in ns.items()
|
||||||
last_summary = None
|
if v.get("result") is None]
|
||||||
|
cancelled = [(k, v.get("result")) for k, v in ns.items()
|
||||||
def fetch_statuses():
|
if v.get("result") == "cancelled"]
|
||||||
statuses = []
|
if pending:
|
||||||
for page in range(1, 6):
|
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
|
||||||
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
|
", ".join(k for k, _ in pending), file=sys.stderr)
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
if cancelled:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
|
||||||
chunk = json.load(resp)
|
", ".join(k for k, _ in cancelled), file=sys.stderr)
|
||||||
if not chunk:
|
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
|
||||||
break
|
'
|
||||||
statuses.extend(chunk)
|
|
||||||
latest = {}
|
|
||||||
for item in statuses:
|
|
||||||
ctx = item.get("context")
|
|
||||||
if not ctx:
|
|
||||||
continue
|
|
||||||
prev = latest.get(ctx)
|
|
||||||
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
|
|
||||||
latest[ctx] = item
|
|
||||||
return latest
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
latest = fetch_statuses()
|
|
||||||
except (TimeoutError, OSError, urllib.error.URLError) as exc:
|
|
||||||
if time.time() >= deadline:
|
|
||||||
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
|
|
||||||
time.sleep(15)
|
|
||||||
continue
|
|
||||||
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
|
|
||||||
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
|
|
||||||
if summary != last_summary:
|
|
||||||
print(summary, flush=True)
|
|
||||||
last_summary = summary
|
|
||||||
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
|
|
||||||
if bad:
|
|
||||||
print("FAIL: required CI context failed:", file=sys.stderr)
|
|
||||||
for ctx, state in bad.items():
|
|
||||||
desc = (latest.get(ctx) or {}).get("description") or ""
|
|
||||||
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
if all(state == "success" for state in states.values()):
|
|
||||||
print(f"OK: all {len(required)} required CI contexts succeeded")
|
|
||||||
sys.exit(0)
|
|
||||||
if time.time() >= deadline:
|
|
||||||
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
|
|
||||||
for ctx, state in states.items():
|
|
||||||
print(f" - {ctx}: {state}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
time.sleep(15)
|
|
||||||
PY
|
|
||||||
|
|||||||
@ -69,13 +69,6 @@ name: E2E API Smoke Test
|
|||||||
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
|
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
|
||||||
# they DO come up. Timeouts are not the bottleneck; not bumped.
|
# they DO come up. Timeouts are not the bottleneck; not bumped.
|
||||||
#
|
#
|
||||||
# Item #1046 (fixed 2026-05-14): Stale platform-server from cancelled runs
|
|
||||||
# lingers on :8080 after "Stop platform" step is skipped (workflow cancelled
|
|
||||||
# before reaching line 335). Added a pre-start "Kill stale platform-server"
|
|
||||||
# step (line 286) that scans /proc for zombie platform-server processes
|
|
||||||
# and kills them before the port probe or bind. Makes the ephemeral port
|
|
||||||
# probe + start sequence deterministic.
|
|
||||||
#
|
|
||||||
# Item explicitly NOT fixed here: failing test `Status back online`
|
# Item explicitly NOT fixed here: failing test `Status back online`
|
||||||
# fails because the platform's langgraph workspace template image
|
# fails because the platform's langgraph workspace template image
|
||||||
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
|
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
|
||||||
@ -290,35 +283,6 @@ jobs:
|
|||||||
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||||
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||||
echo "Platform host port: ${PLATFORM_PORT}"
|
echo "Platform host port: ${PLATFORM_PORT}"
|
||||||
- name: Kill stale platform-server before start (issue #1046)
|
|
||||||
if: needs.detect-changes.outputs.api == 'true'
|
|
||||||
run: |
|
|
||||||
# Concurrent runs on the same host-network act_runner can leave a
|
|
||||||
# zombie platform-server from a cancelled/timeout run. Cancelled
|
|
||||||
# runs never reach the "Stop platform" step (line 335), so the
|
|
||||||
# old process lingers. Kill it before the ephemeral port probe
|
|
||||||
# or start so the port is definitively free.
|
|
||||||
#
|
|
||||||
# /proc scan — works on any Linux without pkill/lsof/ss.
|
|
||||||
# comm field is truncated to 15 chars: "platform-serve" matches
|
|
||||||
# "platform-server". Verify with cmdline to avoid false positives.
|
|
||||||
killed=0
|
|
||||||
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
|
|
||||||
kpid="${pid%/comm}"
|
|
||||||
kpid="${kpid##*/}"
|
|
||||||
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
|
|
||||||
if echo "$cmdline" | grep -q "platform-server"; then
|
|
||||||
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
|
|
||||||
kill "$kpid" 2>/dev/null || true
|
|
||||||
killed=$((killed + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$killed" -gt 0 ]; then
|
|
||||||
sleep 2
|
|
||||||
echo "Killed $killed stale process(es); port(s) released."
|
|
||||||
else
|
|
||||||
echo "No stale platform-server found."
|
|
||||||
fi
|
|
||||||
- name: Start platform (background)
|
- name: Start platform (background)
|
||||||
if: needs.detect-changes.outputs.api == 'true'
|
if: needs.detect-changes.outputs.api == 'true'
|
||||||
working-directory: workspace-server
|
working-directory: workspace-server
|
||||||
@ -382,4 +346,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@ -1,288 +0,0 @@
|
|||||||
name: E2E Chat
|
|
||||||
|
|
||||||
# Comprehensive Playwright E2E for the unified chat stack (desktop
|
|
||||||
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
|
|
||||||
# workspace-server, or this workflow file.
|
|
||||||
#
|
|
||||||
# Architecture:
|
|
||||||
# 1. Ephemeral Postgres + Redis (docker, unique container names)
|
|
||||||
# 2. workspace-server built from source, started with
|
|
||||||
# MOLECULE_ENV=development (fail-open auth)
|
|
||||||
# 3. canvas dev server (npm run dev) on :3000
|
|
||||||
# 4. Playwright tests create workspaces via API, point them at an
|
|
||||||
# in-process echo runtime, and exercise the full send/receive
|
|
||||||
# round-trip through the browser.
|
|
||||||
#
|
|
||||||
# Parallel-safety: same pattern as e2e-api.yml — per-run container names
|
|
||||||
# and ephemeral host ports so concurrent jobs on the host-network runner
|
|
||||||
# don't collide.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, staging]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, staging]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
|
|
||||||
detect-changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
|
||||||
continue-on-error: true
|
|
||||||
outputs:
|
|
||||||
chat: ${{ steps.decide.outputs.chat }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: decide
|
|
||||||
run: |
|
|
||||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
|
||||||
BASE="${{ github.event.pull_request.base.sha }}"
|
|
||||||
fi
|
|
||||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
|
|
||||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
|
||||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
|
||||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
CHANGED=$(git diff --name-only "$BASE" HEAD)
|
|
||||||
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
|
|
||||||
echo "chat=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "chat=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
|
|
||||||
e2e-chat:
|
|
||||||
needs: detect-changes
|
|
||||||
name: E2E Chat
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 15
|
|
||||||
env:
|
|
||||||
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
steps:
|
|
||||||
- name: No-op pass (paths filter excluded this commit)
|
|
||||||
if: needs.detect-changes.outputs.chat != 'true'
|
|
||||||
run: |
|
|
||||||
echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests."
|
|
||||||
echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)."
|
|
||||||
|
|
||||||
- if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
|
|
||||||
- if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
||||||
with:
|
|
||||||
go-version: 'stable'
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: workspace-server/go.sum
|
|
||||||
|
|
||||||
- if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: canvas/package-lock.json
|
|
||||||
|
|
||||||
- name: Start Postgres (docker)
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
|
||||||
docker run -d --name "$PG_CONTAINER" \
|
|
||||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
|
||||||
-p 0:5432 postgres:16 >/dev/null
|
|
||||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
|
||||||
if [ -z "$PG_PORT" ]; then
|
|
||||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
|
||||||
fi
|
|
||||||
if [ -z "$PG_PORT" ]; then
|
|
||||||
echo "::error::Could not resolve host port for $PG_CONTAINER"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
|
|
||||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
|
||||||
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
|
|
||||||
echo "Postgres ready after ${i}s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "::error::Postgres did not become ready in 30s"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Start Redis (docker)
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
|
||||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
|
||||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
|
||||||
if [ -z "$REDIS_PORT" ]; then
|
|
||||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
|
||||||
fi
|
|
||||||
if [ -z "$REDIS_PORT" ]; then
|
|
||||||
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
|
|
||||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
|
||||||
for i in $(seq 1 15); do
|
|
||||||
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
|
|
||||||
echo "Redis ready after ${i}s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "::error::Redis did not become ready in 15s"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Build platform
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: workspace-server
|
|
||||||
run: go build -o platform-server ./cmd/server
|
|
||||||
|
|
||||||
- name: Pick platform port
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
PLATFORM_PORT=$(python3 - <<'PY'
|
|
||||||
import socket
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
s.bind(("127.0.0.1", 0))
|
|
||||||
print(s.getsockname()[1])
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
|
||||||
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
|
||||||
echo "Platform host port: ${PLATFORM_PORT}"
|
|
||||||
|
|
||||||
- name: Pick canvas port
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
CANVAS_PORT=$(python3 - <<'PY'
|
|
||||||
import socket
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
s.bind(("127.0.0.1", 0))
|
|
||||||
print(s.getsockname()[1])
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
echo "CANVAS_PORT=${CANVAS_PORT}" >> "$GITHUB_ENV"
|
|
||||||
echo "Canvas host port: ${CANVAS_PORT}"
|
|
||||||
|
|
||||||
- name: Start platform (background)
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: workspace-server
|
|
||||||
run: |
|
|
||||||
export MOLECULE_ENV=development
|
|
||||||
export DATABASE_URL="${DATABASE_URL}"
|
|
||||||
export REDIS_URL="${REDIS_URL}"
|
|
||||||
export PORT="${PLATFORM_PORT}"
|
|
||||||
export CORS_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:${CANVAS_PORT},http://127.0.0.1:${CANVAS_PORT}"
|
|
||||||
./platform-server > platform.log 2>&1 &
|
|
||||||
echo $! > platform.pid
|
|
||||||
|
|
||||||
- name: Wait for /health
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
|
|
||||||
echo "Platform up after ${i}s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "::error::Platform did not become healthy in 30s"
|
|
||||||
cat workspace-server/platform.log || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Install canvas dependencies
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: canvas
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: canvas
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Start canvas dev server (background)
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: canvas
|
|
||||||
run: |
|
|
||||||
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
|
|
||||||
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
|
|
||||||
npx next dev --turbopack -p "${CANVAS_PORT}" > canvas.log 2>&1 &
|
|
||||||
echo $! > canvas.pid
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -sf "http://localhost:${CANVAS_PORT}" > /dev/null 2>&1; then
|
|
||||||
echo "Canvas up after ${i}s"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "::error::Canvas did not start in 30s"
|
|
||||||
cat canvas.log || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
|
||||||
if: needs.detect-changes.outputs.chat == 'true'
|
|
||||||
working-directory: canvas
|
|
||||||
run: |
|
|
||||||
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
|
|
||||||
export E2E_DATABASE_URL="${DATABASE_URL}"
|
|
||||||
export PLAYWRIGHT_BASE_URL="http://localhost:${CANVAS_PORT}"
|
|
||||||
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts
|
|
||||||
|
|
||||||
- name: Dump platform log on failure
|
|
||||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: cat workspace-server/platform.log || true
|
|
||||||
|
|
||||||
- name: Dump canvas log on failure
|
|
||||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: cat canvas/canvas.log || true
|
|
||||||
|
|
||||||
- name: Upload Playwright report
|
|
||||||
if: failure() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
uses: actions/upload-artifact@v3.2.2
|
|
||||||
with:
|
|
||||||
name: playwright-report-chat
|
|
||||||
path: canvas/playwright-report/
|
|
||||||
|
|
||||||
- name: Stop canvas
|
|
||||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
if [ -f canvas/canvas.pid ]; then
|
|
||||||
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Stop platform
|
|
||||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
if [ -f workspace-server/platform.pid ]; then
|
|
||||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Stop service containers
|
|
||||||
if: always() && needs.detect-changes.outputs.chat == 'true'
|
|
||||||
run: |
|
|
||||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
|
||||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
name: E2E Peer Visibility (literal MCP list_peers)
|
|
||||||
|
|
||||||
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
|
|
||||||
# --------------------------------------------------------------
|
|
||||||
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
|
|
||||||
# were reported "fleet-verified / cascade-complete" because the *proxy*
|
|
||||||
# signals were green (registry registration + heartbeat for Hermes; model
|
|
||||||
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
|
|
||||||
# canvas "can you see your peers" actually FAILS:
|
|
||||||
# - Hermes: 401 on the molecule MCP `list_peers` call
|
|
||||||
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
|
|
||||||
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
|
|
||||||
#
|
|
||||||
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
|
|
||||||
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
|
|
||||||
# claude-code) in ONE org and assert each sees the others. The
|
|
||||||
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
|
|
||||||
# a multi-runtime matrix into it would conflate concerns and bloat its
|
|
||||||
# already-45-min run.
|
|
||||||
# - It needs its own concurrency group so it doesn't fight full-saas /
|
|
||||||
# canvas for the staging org-creation quota.
|
|
||||||
# - It needs an independent, non-required status-context name so it can
|
|
||||||
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
|
|
||||||
# have not landed) WITHOUT wedging unrelated merges — and flipped to
|
|
||||||
# REQUIRED in one branch-protection edit once it goes green
|
|
||||||
# (flip-to-required checklist: molecule-core#1296).
|
|
||||||
#
|
|
||||||
# THE ASSERTION IS NOT A PROXY. The driving script
|
|
||||||
# tests/e2e/test_peer_visibility_mcp_staging.sh issues the byte-for-byte
|
|
||||||
# JSON-RPC `tools/call name=list_peers` envelope to `POST
|
|
||||||
# /workspaces/:id/mcp` using each workspace's OWN bearer token, through
|
|
||||||
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
|
|
||||||
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
|
|
||||||
# read a registry row, /health, the heartbeat table, or
|
|
||||||
# GET /registry/:id/peers.
|
|
||||||
#
|
|
||||||
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
|
|
||||||
# fake-green mask would defeat the entire purpose. This workflow goes red
|
|
||||||
# on today's broken behavior and green only when the root-cause fixes
|
|
||||||
# actually land. It is intentionally NOT in branch_protections — see PR
|
|
||||||
# body for the required-vs-not decision + flip tracking issue.
|
|
||||||
#
|
|
||||||
# Gitea 1.22.6 / act_runner notes honored:
|
|
||||||
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
|
|
||||||
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
|
|
||||||
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
|
|
||||||
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
|
|
||||||
# - Workflow-level GITHUB_SERVER_URL pinned
|
|
||||||
# (feedback_act_runner_github_server_url).
|
|
||||||
# - pr-validate posts a status under the same check name so a
|
|
||||||
# workflow-only PR is not silently statusless and the context is
|
|
||||||
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
|
|
||||||
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
|
|
||||||
# and cannot run per-PR-update).
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'workspace-server/internal/handlers/mcp.go'
|
|
||||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
|
||||||
- 'workspace-server/internal/middleware/**'
|
|
||||||
- 'workspace-server/internal/handlers/registry.go'
|
|
||||||
- 'workspace-server/internal/handlers/workspace.go'
|
|
||||||
- 'workspace/a2a_mcp_server.py'
|
|
||||||
- 'workspace/platform_tools/registry.py'
|
|
||||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
|
||||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'workspace-server/internal/handlers/mcp.go'
|
|
||||||
- 'workspace-server/internal/handlers/mcp_tools.go'
|
|
||||||
- 'workspace-server/internal/middleware/**'
|
|
||||||
- 'workspace-server/internal/handlers/registry.go'
|
|
||||||
- 'workspace-server/internal/handlers/workspace.go'
|
|
||||||
- 'workspace/a2a_mcp_server.py'
|
|
||||||
- 'workspace/platform_tools/registry.py'
|
|
||||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
|
||||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
|
|
||||||
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
|
|
||||||
# so the two don't collide on the staging org-creation quota.
|
|
||||||
- cron: '30 7 * * *'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
|
|
||||||
# would let a queued staging/main push behind a PR run get cancelled,
|
|
||||||
# leaving any gate that reads "completed run at SHA" stuck.
|
|
||||||
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# PR path: post a real status under the required-ready check name so a
|
|
||||||
# workflow-only PR is never silently statusless. The actual EC2 E2E is
|
|
||||||
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
|
|
||||||
# the real assertion — it validates the driving script's bash syntax
|
|
||||||
# and inline-python so a broken test script fails at PR time.
|
|
||||||
pr-validate:
|
|
||||||
name: E2E Peer Visibility
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
- name: Validate driving script
|
|
||||||
run: |
|
|
||||||
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
|
|
||||||
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
|
|
||||||
echo "Real fresh-provision MCP list_peers E2E runs on push to"
|
|
||||||
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
|
|
||||||
|
|
||||||
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
|
|
||||||
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
|
|
||||||
# peer set, then scoped teardown. push(main)/dispatch/cron only.
|
|
||||||
peer-visibility:
|
|
||||||
name: E2E Peer Visibility
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
env:
|
|
||||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
|
||||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
|
||||||
# LLM provider key so each runtime can authenticate at boot.
|
|
||||||
# Priority MiniMax → direct-Anthropic → OpenAI matches
|
|
||||||
# test_staging_full_saas.sh's secrets-injection chain.
|
|
||||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
|
||||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
|
||||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
|
|
||||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
|
||||||
PV_RUNTIMES: "hermes openclaw claude-code"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
|
|
||||||
- name: Verify admin token present
|
|
||||||
run: |
|
|
||||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
|
||||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
echo "Admin token present"
|
|
||||||
|
|
||||||
- name: Verify an LLM key present
|
|
||||||
run: |
|
|
||||||
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
|
|
||||||
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
echo "LLM key present"
|
|
||||||
|
|
||||||
- name: CP staging health preflight
|
|
||||||
run: |
|
|
||||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
|
||||||
if [ "$code" != "200" ]; then
|
|
||||||
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Staging CP healthy"
|
|
||||||
|
|
||||||
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
|
|
||||||
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
|
|
||||||
|
|
||||||
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
|
|
||||||
# TERM trap, but if the runner itself is cancelled the trap may not
|
|
||||||
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
|
|
||||||
# run created — never a cluster-wide sweep
|
|
||||||
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
|
|
||||||
# admin DELETE is idempotent so double-invoking is safe;
|
|
||||||
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
|
|
||||||
- name: Teardown safety net (runs on cancel/failure)
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
|
||||||
| python3 -c "
|
|
||||||
import json, sys, os, datetime
|
|
||||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
|
||||||
try:
|
|
||||||
d = json.load(sys.stdin)
|
|
||||||
except Exception:
|
|
||||||
print(''); sys.exit(0)
|
|
||||||
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
|
|
||||||
# Sweep today AND yesterday's UTC date so a midnight-crossing run
|
|
||||||
# still matches its own slug (same bug class as the saas/canvas
|
|
||||||
# safety nets).
|
|
||||||
today = datetime.date.today()
|
|
||||||
yest = today - datetime.timedelta(days=1)
|
|
||||||
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
|
|
||||||
if run_id:
|
|
||||||
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
|
|
||||||
else:
|
|
||||||
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
|
|
||||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
|
||||||
cands = [o['slug'] for o in orgs
|
|
||||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
|
||||||
and o.get('instance_status') not in ('purged',)]
|
|
||||||
print('\n'.join(cands))
|
|
||||||
" 2>/dev/null)
|
|
||||||
for slug in $orgs; do
|
|
||||||
echo "Safety-net teardown: $slug"
|
|
||||||
set +e
|
|
||||||
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
|
|
||||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
|
|
||||||
set -e
|
|
||||||
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
|
|
||||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
|
||||||
echo "[teardown] deleted $slug (HTTP $code)"
|
|
||||||
else
|
|
||||||
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
exit 0
|
|
||||||
@ -83,41 +83,25 @@ jobs:
|
|||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Fetch all open PRs and run gate-check on each. This scheduled
|
# Fetch all open PRs and run gate-check on each
|
||||||
# refresher is advisory; a transient Gitea list timeout must not turn
|
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
|
||||||
# main red. PR-specific gate-check runs still use normal failure
|
# gate_check.py uses timeout=15 on every urlopen call; this catches the
|
||||||
# semantics.
|
# inline Python polling loop too (issue #603).
|
||||||
pr_numbers=$(python3 <<'PY'
|
pr_numbers=$(python3 <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
socket.setdefaulttimeout(30)
|
socket.setdefaulttimeout(15)
|
||||||
token = os.environ["GITEA_TOKEN"]
|
token = os.environ["GITEA_TOKEN"]
|
||||||
repo = os.environ["REPO"]
|
repo = os.environ["REPO"]
|
||||||
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
|
req = urllib.request.Request(
|
||||||
last_error = None
|
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
|
||||||
for attempt in range(1, 4):
|
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
||||||
req = urllib.request.Request(
|
)
|
||||||
url,
|
with urllib.request.urlopen(req) as r:
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
prs = json.loads(r.read())
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as r:
|
|
||||||
prs = json.loads(r.read())
|
|
||||||
break
|
|
||||||
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
|
|
||||||
last_error = exc
|
|
||||||
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
|
|
||||||
if attempt < 3:
|
|
||||||
time.sleep(2 * attempt)
|
|
||||||
else:
|
|
||||||
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
|
|
||||||
raise SystemExit(0)
|
|
||||||
for pr in prs:
|
for pr in prs:
|
||||||
print(pr["number"])
|
print(pr["number"])
|
||||||
PY
|
PY
|
||||||
|
|||||||
@ -48,9 +48,4 @@ jobs:
|
|||||||
REQUIRED_CONTEXTS: >-
|
REQUIRED_CONTEXTS: >-
|
||||||
CI / all-required (pull_request),
|
CI / all-required (pull_request),
|
||||||
sop-checklist / all-items-acked (pull_request)
|
sop-checklist / all-items-acked (pull_request)
|
||||||
# Push-side required contexts. Checking CI / all-required (push)
|
|
||||||
# explicitly instead of the combined state avoids false-pause when
|
|
||||||
# non-blocking jobs (continue-on-error: true) have failed — those
|
|
||||||
# failures pollute combined state but do not gate merges.
|
|
||||||
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
|
|
||||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||||
|
|||||||
@ -86,11 +86,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
# A full-history checkout can exceed the runner's quiet/startup
|
fetch-depth: 0
|
||||||
# window before the path filter emits logs. Fetch the common push
|
|
||||||
# case cheaply; the script below fetches the exact BASE SHA if it is
|
|
||||||
# not present in the shallow checkout.
|
|
||||||
fetch-depth: 2
|
|
||||||
- id: filter
|
- id: filter
|
||||||
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
|
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@ -93,7 +93,7 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: lint-continue-on-error-tracking
|
name: lint-continue-on-error-tracking
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 10
|
||||||
# Phase 3 (RFC #219 §1): surface masked defects without blocking
|
# Phase 3 (RFC #219 §1): surface masked defects without blocking
|
||||||
# PRs. Pre-existing continue-on-error: true directives on main
|
# PRs. Pre-existing continue-on-error: true directives on main
|
||||||
# all violate this lint at first — intentional. Flip to false
|
# all violate this lint at first — intentional. Flip to false
|
||||||
|
|||||||
@ -49,17 +49,13 @@ jobs:
|
|||||||
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
||||||
build-and-push:
|
build-and-push:
|
||||||
name: Build & push canvas image
|
name: Build & push canvas image
|
||||||
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
|
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||||
# path (on: push:main, canvas/**) — reserved capacity so a merged
|
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
|
||||||
# canvas fix's image build never FIFO-queues behind PR required-CI.
|
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
|
||||||
# The `publish` label resolves ONLY to the molecule-runner-publish-*
|
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
|
||||||
# sub-pool (config.publish.yaml). HARD DEPENDENCY: this MUST land
|
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
|
||||||
# AFTER the publish-lane runners are registered/advertising `publish`
|
# See issue #576 + infra-lead pulse ~00:30Z.
|
||||||
# — the earlier #599 `docker` label attempt queued indefinitely with
|
runs-on: ubuntu-latest
|
||||||
# zero eligible runners precisely because the label was targeted
|
|
||||||
# before any runner advertised it (see #576). The lane is registered
|
|
||||||
# in this rollout (internal#462) so the precondition holds.
|
|
||||||
runs-on: publish
|
|
||||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@ -66,10 +66,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
|
runs-on: ubuntu-latest
|
||||||
# path (on: push tag runtime-v*) — reserved capacity, never FIFO
|
|
||||||
# behind PR-CI. `publish` resolves only to molecule-runner-publish-*.
|
|
||||||
runs-on: publish
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
|
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
|
||||||
@ -162,7 +159,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
python -m twine upload \
|
python -m twine upload \
|
||||||
--verbose \
|
|
||||||
--repository pypi \
|
--repository pypi \
|
||||||
--username __token__ \
|
--username __token__ \
|
||||||
--password "$PYPI_TOKEN" \
|
--password "$PYPI_TOKEN" \
|
||||||
@ -170,9 +166,7 @@ jobs:
|
|||||||
|
|
||||||
cascade:
|
cascade:
|
||||||
needs: publish
|
needs: publish
|
||||||
# Publish/release lane (internal#462) — downstream of the runtime
|
runs-on: ubuntu-latest
|
||||||
# publish ship job; keep it on the reserved lane too.
|
|
||||||
runs-on: publish
|
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for PyPI to propagate the new version
|
- name: Wait for PyPI to propagate the new version
|
||||||
env:
|
env:
|
||||||
|
|||||||
@ -54,14 +54,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
# Dedicated publish/release lane (internal#462 / #394 / #399). This
|
runs-on: ubuntu-latest
|
||||||
# is a post-merge ship job (on: push:main) — it must NOT FIFO-compete
|
|
||||||
# with PR required-CI on the shared pool (PR#1350's prod image build
|
|
||||||
# was delayed ~25min this way). The `publish` label resolves ONLY to
|
|
||||||
# the reserved molecule-runner-publish-* sub-pool (config.publish.yaml,
|
|
||||||
# OUTSIDE the managed 1..20 range) so a merged fix's image build
|
|
||||||
# starts immediately while PR-CI keeps the general pool.
|
|
||||||
runs-on: publish
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@ -75,14 +68,12 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "::group::Docker daemon health check"
|
echo "::group::Docker daemon health check"
|
||||||
echo "Runner: ${HOSTNAME:-unknown}"
|
echo "Runner: ${HOSTNAME:-unknown}"
|
||||||
docker_info="$(docker info 2>&1)" || {
|
docker info 2>&1 | head -5 || {
|
||||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||||
printf '%s\n' "${docker_info}"
|
|
||||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
printf '%s\n' "${docker_info}" | sed -n '1,5p'
|
|
||||||
echo "Docker daemon OK"
|
echo "Docker daemon OK"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
@ -188,9 +179,7 @@ jobs:
|
|||||||
name: Production auto-deploy
|
name: Production auto-deploy
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
# Publish/release lane (internal#462) — production deploy of a merged
|
runs-on: ubuntu-latest
|
||||||
# fix; reserved capacity, never queued behind PR-CI.
|
|
||||||
runs-on: publish
|
|
||||||
timeout-minutes: 75
|
timeout-minutes: 75
|
||||||
env:
|
env:
|
||||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||||
|
|||||||
@ -9,17 +9,19 @@ name: redeploy-tenants-on-main
|
|||||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||||
# feedback_act_runner_github_server_url.
|
# feedback_act_runner_github_server_url.
|
||||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||||
# - Dropped unsupported `workflow_run` (task #81).
|
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||||
# - Later changed to manual-only after publish-workspace-server-image.yml
|
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||||
# gained an integrated ordered production deploy job.
|
# `workflow_run` (task #81). The push trigger fires on every
|
||||||
|
# commit to publish-workspace-server-image.yml which is the
|
||||||
|
# same signal (only successful runs commit to main).
|
||||||
#
|
#
|
||||||
|
|
||||||
# Manual production tenant redeploy/rollback helper.
|
# Auto-refresh prod tenant EC2s after every main merge.
|
||||||
#
|
#
|
||||||
# Why this workflow is manual-only: publish-workspace-server-image now owns
|
# Why this workflow exists: publish-workspace-server-image builds and
|
||||||
# the ordered build -> push -> production auto-deploy sequence in one workflow.
|
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
|
||||||
# A separate push-triggered redeploy workflow races before the new ECR image
|
# but running tenants pulled their image once at boot and never re-pull.
|
||||||
# exists and can paint main red with a false deployment failure.
|
# Users see stale code indefinitely.
|
||||||
#
|
#
|
||||||
# This workflow closes the gap by calling the control-plane admin
|
# This workflow closes the gap by calling the control-plane admin
|
||||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||||
@ -32,11 +34,16 @@ name: redeploy-tenants-on-main
|
|||||||
# Gitea suspension migration. The staging-verify.yml promote step now
|
# Gitea suspension migration. The staging-verify.yml promote step now
|
||||||
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
||||||
#
|
#
|
||||||
# Runtime ordering for automatic deploys now lives in
|
# Runtime ordering:
|
||||||
# publish-workspace-server-image.yml:
|
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
|
||||||
# 1. build-and-push creates new :staging-<sha> images in ECR.
|
# 2. The merge that updates publish-workspace-server-image.yml triggers
|
||||||
# 2. deploy-production waits for required push contexts on that SHA.
|
# this push/path-filtered workflow, which calls redeploy-fleet with
|
||||||
# 3. deploy-production calls redeploy-fleet canary-first.
|
# target_tag=staging-<sha>. No CDN propagation wait needed — ECR image
|
||||||
|
# manifest is consistent immediately after push.
|
||||||
|
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
|
||||||
|
# period. Canary proves the image boots; batches follow.
|
||||||
|
# 4. Any failure aborts the rollout and leaves older tenants on the
|
||||||
|
# prior image — safer default than half-and-half state.
|
||||||
#
|
#
|
||||||
# Rollback path: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
|
# Rollback path: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
|
||||||
# variable or secret, run workflow_dispatch, then unset it after the
|
# variable or secret, run workflow_dispatch, then unset it after the
|
||||||
@ -44,14 +51,21 @@ name: redeploy-tenants-on-main
|
|||||||
# re-pulling the pinned image on every tenant.
|
# re-pulling the pinned image on every tenant.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||||
# not the GitHub API.
|
# not the GitHub API.
|
||||||
|
|
||||||
# Serialize manual redeploys so two operator-triggered rollbacks do not
|
# Serialize redeploys so two rapid main pushes' redeploys don't overlap
|
||||||
# overlap and cause confusing per-tenant SSM state.
|
# and cause confusing per-tenant SSM state. Without this, GitHub's
|
||||||
|
# implicit workflow_run queueing would *probably* serialize them, but
|
||||||
|
# the explicit block makes the invariant defensible. Mirrors the
|
||||||
|
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
|
||||||
#
|
#
|
||||||
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
|
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
|
||||||
# cancels queued runs regardless of this setting, so it provides no
|
# cancels queued runs regardless of this setting, so it provides no
|
||||||
@ -67,18 +81,18 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
|
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
|
||||||
redeploy:
|
redeploy:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
# Gitea 1.22.6 does not support workflow_run. This workflow is now
|
||||||
# Dedicated publish/release lane (internal#462 / #394 / #399).
|
# controlled by push/path triggers plus an explicit kill switch.
|
||||||
# Production tenant redeploy — a deploy action, reserved capacity so
|
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||||
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
|
runs-on: ubuntu-latest
|
||||||
runs-on: publish
|
|
||||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
env:
|
env:
|
||||||
# Rule 9 fix: keep the same operational kill switch surface as the
|
# Rule 9 fix: operational kill switch for auto-triggered deployments.
|
||||||
# integrated auto-deploy workflow.
|
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
|
||||||
|
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
|
||||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Kill-switch guard
|
- name: Kill-switch guard
|
||||||
@ -100,8 +114,13 @@ jobs:
|
|||||||
# tag) → used verbatim. Lets ops pin `latest` for emergency
|
# tag) → used verbatim. Lets ops pin `latest` for emergency
|
||||||
# rollback to last canary-verified digest, or pin a specific
|
# rollback to last canary-verified digest, or pin a specific
|
||||||
# `staging-<sha>` to roll back to a known-good build.
|
# `staging-<sha>` to roll back to a known-good build.
|
||||||
# 2. Default → `staging-<short_head_sha>` for manual reruns from
|
# 2. Default → `staging-<short_head_sha>`. The just-published
|
||||||
# the current default-branch SHA.
|
# digest. Bypasses the `:latest` retag path that's currently
|
||||||
|
# dead (staging-verify soft-skips without canary fleet, so
|
||||||
|
# the only thing retagging `:latest` today is the manual
|
||||||
|
# promote-latest.yml — last run 2026-04-28). Auto-trigger
|
||||||
|
# from the main push uses github.sha; manual
|
||||||
|
# dispatch with no variable falls through to github.sha.
|
||||||
env:
|
env:
|
||||||
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
||||||
HEAD_SHA: ${{ github.sha }}
|
HEAD_SHA: ${{ github.sha }}
|
||||||
@ -255,11 +274,13 @@ jobs:
|
|||||||
# fail the workflow, which is what `ok=true` should have
|
# fail the workflow, which is what `ok=true` should have
|
||||||
# guaranteed all along.
|
# guaranteed all along.
|
||||||
#
|
#
|
||||||
# When the redeploy is triggered manually with a specific tag
|
# When the redeploy was triggered by workflow_dispatch with a
|
||||||
# (target_tag != "latest"), the expected SHA may not equal
|
# specific tag (target_tag != "latest"), the expected SHA may
|
||||||
# ${{ github.sha }}.
|
# not equal ${{ github.sha }} — in that case we resolve via
|
||||||
|
# GHCR's manifest. For workflow_run (default :latest) the
|
||||||
|
# workflow_run.head_sha is the SHA that just published.
|
||||||
env:
|
env:
|
||||||
EXPECTED_SHA: ${{ github.sha }}
|
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||||
# Tenant subdomain template — slugs from the response are
|
# Tenant subdomain template — slugs from the response are
|
||||||
# appended. Production CP issues `<slug>.moleculesai.app`;
|
# appended. Production CP issues `<slug>.moleculesai.app`;
|
||||||
|
|||||||
@ -75,10 +75,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
||||||
redeploy:
|
redeploy:
|
||||||
# Dedicated publish/release lane (internal#462 / #394 / #399).
|
runs-on: ubuntu-latest
|
||||||
# Post-merge staging redeploy — a deploy action, reserved capacity.
|
|
||||||
# `publish` -> molecule-runner-publish-* sub-pool.
|
|
||||||
runs-on: publish
|
|
||||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
|
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
|
||||||
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
|
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
|
||||||
# qa-review, security-review, sop-checklist, and sop-tier-refire all
|
# qa-review, security-review, sop-checklist-gate, and sop-tier-refire all
|
||||||
# listened to comments. This workflow is the single non-SOP comment subscriber:
|
# listened to comments. This workflow is the single non-SOP comment subscriber:
|
||||||
# ordinary comments no-op quickly; slash commands post the required status
|
# ordinary comments no-op quickly; slash commands post the required status
|
||||||
# contexts to the PR head SHA.
|
# contexts to the PR head SHA.
|
||||||
@ -18,10 +18,6 @@ permissions:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
statuses: write
|
statuses: write
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dispatch:
|
dispatch:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# sop-checklist — peer-ack merge gate for SOP-checklist items.
|
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
|
||||||
#
|
#
|
||||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||||
#
|
#
|
||||||
@ -65,15 +65,7 @@
|
|||||||
# membership, compute, post status). Re-running on any event is safe —
|
# membership, compute, post status). Re-running on any event is safe —
|
||||||
# the new status overwrites the previous one for the same context.
|
# the new status overwrites the previous one for the same context.
|
||||||
|
|
||||||
name: sop-checklist
|
name: sop-checklist-gate
|
||||||
|
|
||||||
# Cancel any in-progress runs for the same PR to prevent
|
|
||||||
# stale runs from overwriting newer status contexts.
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@ -91,7 +83,7 @@ permissions:
|
|||||||
statuses: write
|
statuses: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
all-items-acked:
|
gate:
|
||||||
# Run on pull_request_target events always. On issue_comment events,
|
# Run on pull_request_target events always. On issue_comment events,
|
||||||
# only when the comment is on a PR (issue_comment fires for issues
|
# only when the comment is on a PR (issue_comment fires for issues
|
||||||
# too) and the body contains one of the slash-commands.
|
# too) and the body contains one of the slash-commands.
|
||||||
@ -114,7 +106,7 @@ jobs:
|
|||||||
# qa-review.yml so the script source is always trusted.
|
# qa-review.yml so the script source is always trusted.
|
||||||
ref: ${{ github.event.repository.default_branch }}
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
- name: Run sop-checklist
|
- name: Run sop-checklist-gate
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||||
@ -122,7 +114,7 @@ jobs:
|
|||||||
REPO_NAME: ${{ github.event.repository.name }}
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 .gitea/scripts/sop-checklist.py \
|
python3 .gitea/scripts/sop-checklist-gate.py \
|
||||||
--owner "$OWNER" \
|
--owner "$OWNER" \
|
||||||
--repo "$REPO_NAME" \
|
--repo "$REPO_NAME" \
|
||||||
--pr "$PR_NUMBER" \
|
--pr "$PR_NUMBER" \
|
||||||
@ -61,10 +61,6 @@ on:
|
|||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [submitted, dismissed, edited]
|
types: [submitted, dismissed, edited]
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tier-check:
|
tier-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
staging trigger 2026-05-14T17:35:02Z
|
staging trigger
|
||||||
@ -1 +0,0 @@
|
|||||||
trigger
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { startEchoRuntime } from "./fixtures/echo-runtime";
|
|
||||||
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
|
|
||||||
|
|
||||||
|
|
||||||
test.describe("Desktop ChatTab", () => {
|
|
||||||
let cleanup: () => Promise<void> = async () => {};
|
|
||||||
let workspaceId = "";
|
|
||||||
let workspaceName = "";
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
const echo = await startEchoRuntime();
|
|
||||||
const ws = await seedWorkspace(echo.baseURL);
|
|
||||||
workspaceId = ws.id;
|
|
||||||
workspaceName = ws.name;
|
|
||||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
|
||||||
|
|
||||||
cleanup = async () => {
|
|
||||||
stopHeartbeat();
|
|
||||||
await echo.stop();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await cleanupWorkspace(workspaceId);
|
|
||||||
await cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
|
||||||
await page.goto("/");
|
|
||||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
|
||||||
// Dismiss onboarding guide if present.
|
|
||||||
const skipGuide = page.getByText("Skip guide");
|
|
||||||
if (await skipGuide.isVisible().catch(() => false)) {
|
|
||||||
await skipGuide.click();
|
|
||||||
}
|
|
||||||
// Click the workspace node by its exact name label.
|
|
||||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
|
||||||
// Wait for the side panel chat tab to be clickable, then click it.
|
|
||||||
await page.locator('#tab-chat').click();
|
|
||||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
|
||||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
|
||||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("chat panel loads without error", async ({ page }) => {
|
|
||||||
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
|
|
||||||
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
|
|
||||||
expect(hasEmptyState || hasHistory).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("send text message and receive echo response", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("What is the weather?");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("history persists across reload", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Persistence test");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
|
||||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
|
||||||
await page.locator('#tab-chat').click();
|
|
||||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
|
||||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
|
||||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("file attachment round-trip", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Please read this file");
|
|
||||||
|
|
||||||
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
|
|
||||||
await fileInput.setInputFiles({
|
|
||||||
name: "test.txt",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
buffer: Buffer.from("secret content abc123"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("activity log appears during send", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Trigger activity");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
// Activity log container should appear during the send flow.
|
|
||||||
await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => {
|
|
||||||
// Activity log may not be present in all layouts.
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Desktop ChatTab — Markdown rendering", () => {
|
|
||||||
let cleanup: () => Promise<void> = async () => {};
|
|
||||||
let workspaceId = "";
|
|
||||||
let workspaceName = "";
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
const echo = await startEchoRuntime();
|
|
||||||
const ws = await seedWorkspace(echo.baseURL);
|
|
||||||
workspaceId = ws.id;
|
|
||||||
workspaceName = ws.name;
|
|
||||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
|
||||||
|
|
||||||
cleanup = async () => {
|
|
||||||
stopHeartbeat();
|
|
||||||
await echo.stop();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await cleanupWorkspace(workspaceId);
|
|
||||||
await cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
|
||||||
await page.goto("/");
|
|
||||||
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
|
|
||||||
const skipGuide2 = page.getByText("Skip guide");
|
|
||||||
if (await skipGuide2.isVisible().catch(() => false)) {
|
|
||||||
await skipGuide2.click();
|
|
||||||
}
|
|
||||||
await page.getByText(workspaceName, { exact: true }).first().click();
|
|
||||||
await page.locator('#tab-chat').click();
|
|
||||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
|
|
||||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
|
||||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("code block renders <pre>", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("```js\nconst x = 1;\n```");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const pre = page.locator("pre").first();
|
|
||||||
await expect(pre).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(pre).toContainText("const x = 1;");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("table renders <table>", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const table = page.locator("table").first();
|
|
||||||
await expect(table).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(table).toContainText("A");
|
|
||||||
await expect(table).toContainText("1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
import { startEchoRuntime } from "./fixtures/echo-runtime";
|
|
||||||
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
|
|
||||||
|
|
||||||
|
|
||||||
test.describe("MobileChat", () => {
|
|
||||||
let cleanup: () => Promise<void> = async () => {};
|
|
||||||
let workspaceId = "";
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
const echo = await startEchoRuntime();
|
|
||||||
const ws = await seedWorkspace(echo.baseURL);
|
|
||||||
workspaceId = ws.id;
|
|
||||||
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
|
|
||||||
|
|
||||||
cleanup = async () => {
|
|
||||||
stopHeartbeat();
|
|
||||||
await echo.stop();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await cleanupWorkspace(workspaceId);
|
|
||||||
await cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
|
||||||
// Navigate directly to the mobile chat view.
|
|
||||||
await page.goto(`/?m=chat&a=${workspaceId}`);
|
|
||||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
|
|
||||||
// Wait for the workspace status to flip to online and the textarea to be enabled.
|
|
||||||
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
|
|
||||||
// Dismiss onboarding guide if present.
|
|
||||||
const skipGuide = page.getByText("Skip guide");
|
|
||||||
if (await skipGuide.isVisible().catch(() => false)) {
|
|
||||||
await skipGuide.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("chat panel loads without error", async ({ page }) => {
|
|
||||||
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
|
|
||||||
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
|
|
||||||
expect(hasEmptyState || hasHistory).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("send text message and receive echo response", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Mobile test message");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("history persists across reload", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Mobile persistence");
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
|
|
||||||
|
|
||||||
await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
|
|
||||||
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("composer auto-grows with multi-line text", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
|
|
||||||
|
|
||||||
await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
|
|
||||||
expect(grownHeight).toBeGreaterThan(initialHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("file attachment in mobile chat", async ({ page }) => {
|
|
||||||
const textarea = page.locator("textarea").first();
|
|
||||||
await textarea.fill("Mobile file test");
|
|
||||||
|
|
||||||
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
|
|
||||||
await fileInput.setInputFiles({
|
|
||||||
name: "mobile.txt",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
buffer: Buffer.from("mobile secret"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Send/ }).first().click();
|
|
||||||
await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* E2E seed fixture for chat tests.
|
|
||||||
*
|
|
||||||
* Creates an external workspace via the workspace-server API, extracts the
|
|
||||||
* auto-minted auth token, then overrides the DB row so it appears "online"
|
|
||||||
* with an echo-runtime URL. External runtime is used because the health
|
|
||||||
* sweep skips Docker checks for external workspaces; we keep the workspace
|
|
||||||
* alive with periodic heartbeats.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
export interface SeededWorkspace {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
agentURL: string;
|
|
||||||
authToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an external workspace and wire it to the echo runtime.
|
|
||||||
*/
|
|
||||||
export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
|
|
||||||
// 1. Create external workspace (no URL — platform will mint an auth token).
|
|
||||||
const runId = Math.random().toString(36).slice(2, 8);
|
|
||||||
const wsName = `Chat E2E Agent ${runId}`;
|
|
||||||
const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) {
|
|
||||||
const text = await createRes.text();
|
|
||||||
throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
|
|
||||||
}
|
|
||||||
const ws = (await createRes.json()) as {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
connection?: { auth_token?: string };
|
|
||||||
};
|
|
||||||
const authToken = ws.connection?.auth_token;
|
|
||||||
if (!authToken) {
|
|
||||||
throw new Error("Workspace created but no auth_token returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Direct DB update: mark online + point url at echo runtime.
|
|
||||||
// The platform blocks loopback URLs at the API layer (SSRF guard),
|
|
||||||
// so we bypass via psql for local E2E.
|
|
||||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
|
||||||
if (!dbUrl) {
|
|
||||||
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
|
|
||||||
}
|
|
||||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
|
||||||
const m = dbUrl.match(pgRegex);
|
|
||||||
if (!m) {
|
|
||||||
throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
|
|
||||||
}
|
|
||||||
const [, user, pass, host, port, db] = m;
|
|
||||||
|
|
||||||
// Pre-seed a platform_inbound_secret so chat file uploads don't trigger
|
|
||||||
// the lazy-heal 503 "retry in 30 s" path on first use.
|
|
||||||
const inboundSecret = Array.from({ length: 43 }, () =>
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
|
|
||||||
Math.floor(Math.random() * 64)
|
|
||||||
],
|
|
||||||
).join("");
|
|
||||||
|
|
||||||
const psql = [
|
|
||||||
`PGPASSWORD=${pass} psql`,
|
|
||||||
`-h ${host} -p ${port} -U ${user} -d ${db}`,
|
|
||||||
`-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
|
|
||||||
].join(" ");
|
|
||||||
|
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
try {
|
|
||||||
execSync(psql, { stdio: "pipe", timeout: 30_000 });
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`DB update failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a heartbeat interval that keeps an external workspace alive.
|
|
||||||
* Returns a stop function.
|
|
||||||
*/
|
|
||||||
export function startHeartbeat(
|
|
||||||
workspaceId: string,
|
|
||||||
authToken: string,
|
|
||||||
intervalMs = 30_000,
|
|
||||||
): () => void {
|
|
||||||
const send = () => {
|
|
||||||
fetch(`${PLATFORM_URL}/registry/heartbeat`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${authToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
error_rate: 0,
|
|
||||||
sample_error: "",
|
|
||||||
active_tasks: 0,
|
|
||||||
current_task: "",
|
|
||||||
uptime_seconds: 0,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send immediately so the first heartbeat lands before the stale sweep.
|
|
||||||
send();
|
|
||||||
const timer = setInterval(send, intervalMs);
|
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed chat-history rows for a workspace.
|
|
||||||
*/
|
|
||||||
export async function seedChatHistory(
|
|
||||||
workspaceId: string,
|
|
||||||
messages: Array<{ role: "user" | "agent"; content: string }>,
|
|
||||||
): Promise<void> {
|
|
||||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
|
||||||
if (!dbUrl) return;
|
|
||||||
|
|
||||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
|
||||||
const m = dbUrl.match(pgRegex);
|
|
||||||
if (!m) return;
|
|
||||||
const [, user, pass, host, port, db] = m;
|
|
||||||
|
|
||||||
const values = messages
|
|
||||||
.map(
|
|
||||||
(msg, i) =>
|
|
||||||
`('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
|
|
||||||
)
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
|
|
||||||
|
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
|
|
||||||
execSync(psql, { stdio: "pipe", timeout: 10_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a seeded workspace row directly from the DB.
|
|
||||||
* Uses psql (same credentials as seedWorkspace) so we bypass any
|
|
||||||
* workspace-server side-effects (container stop, cascade cleanup, etc.)
|
|
||||||
* that can race or 500 on external workspaces.
|
|
||||||
*/
|
|
||||||
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
|
|
||||||
const dbUrl = process.env.E2E_DATABASE_URL;
|
|
||||||
if (!dbUrl) return;
|
|
||||||
|
|
||||||
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
|
|
||||||
const m = dbUrl.match(pgRegex);
|
|
||||||
if (!m) return;
|
|
||||||
const [, user, pass, host, port, db] = m;
|
|
||||||
|
|
||||||
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
|
|
||||||
|
|
||||||
const { execSync } = await import("node:child_process");
|
|
||||||
try {
|
|
||||||
execSync(psql, { stdio: "pipe", timeout: 30_000 });
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup; don't fail the test suite if the row is already gone.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mint a workspace auth token so the canvas can make authenticated API
|
|
||||||
* calls (WorkspaceAuth middleware).
|
|
||||||
*/
|
|
||||||
export async function mintTestToken(workspaceId: string): Promise<string> {
|
|
||||||
const res = await fetch(
|
|
||||||
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to mint test token: ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as { auth_token: string };
|
|
||||||
return data.auth_token;
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal A2A echo runtime for E2E tests.
|
|
||||||
*
|
|
||||||
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
|
|
||||||
* requests, and returns a response with the original text echoed back.
|
|
||||||
* Also implements the workspace-side chat upload ingest endpoint so
|
|
||||||
* file-attachment E2E can exercise the full upload → send → echo
|
|
||||||
* round-trip.
|
|
||||||
*
|
|
||||||
* Usage (inside test fixture):
|
|
||||||
* const echo = await startEchoRuntime();
|
|
||||||
* // ... seed workspace with agent_url pointing to echo.baseURL ...
|
|
||||||
* echo.stop();
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createServer, type Server } from "node:http";
|
|
||||||
|
|
||||||
export interface EchoRuntime {
|
|
||||||
baseURL: string;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
lastRequest: { method: string; text: string; files: unknown[] } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a minimal multipart body and extract the first file's name + content. */
|
|
||||||
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
|
|
||||||
// Find the boundary line (first line starting with "--").
|
|
||||||
const str = body.toString("binary");
|
|
||||||
const firstDash = str.indexOf("--");
|
|
||||||
if (firstDash === -1) return null;
|
|
||||||
const eol = str.indexOf("\r\n", firstDash);
|
|
||||||
if (eol === -1) return null;
|
|
||||||
const boundary = str.slice(firstDash + 2, eol);
|
|
||||||
const boundaryMarker = "\r\n--" + boundary;
|
|
||||||
|
|
||||||
// Find the first part that has a filename in Content-Disposition.
|
|
||||||
let pos = eol + 2;
|
|
||||||
while (pos < str.length) {
|
|
||||||
const nextBoundary = str.indexOf(boundaryMarker, pos);
|
|
||||||
if (nextBoundary === -1) break;
|
|
||||||
const part = str.slice(pos, nextBoundary);
|
|
||||||
|
|
||||||
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
|
|
||||||
if (cdMatch) {
|
|
||||||
const name = cdMatch[1];
|
|
||||||
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
||||||
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
|
|
||||||
// Body starts after the first double-CRLF in the part.
|
|
||||||
const bodyStart = part.indexOf("\r\n\r\n");
|
|
||||||
if (bodyStart !== -1) {
|
|
||||||
// Extract the raw bytes (not the string) so binary is safe.
|
|
||||||
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
|
|
||||||
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
|
|
||||||
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
|
|
||||||
const content = body.subarray(partStartInBody, partEndInBody);
|
|
||||||
return { name, mimeType, content };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = nextBoundary + boundaryMarker.length;
|
|
||||||
// Skip trailing "--" (end marker) or CRLF.
|
|
||||||
if (str.slice(pos, pos + 2) === "--") break;
|
|
||||||
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startEchoRuntime(): Promise<EchoRuntime> {
|
|
||||||
let lastRequest: EchoRuntime["lastRequest"] = null;
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
// CORS: allow the canvas origin (localhost:3000) to call us.
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = req.url ?? "/";
|
|
||||||
|
|
||||||
// Workspace-side chat upload ingest (RFC #2312).
|
|
||||||
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
req.on("end", () => {
|
|
||||||
const body = Buffer.concat(chunks);
|
|
||||||
const file = parseMultipart(body);
|
|
||||||
if (!file) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: "no files field" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
|
|
||||||
const prefix = Array.from({ length: 32 }, () =>
|
|
||||||
Math.floor(Math.random() * 16).toString(16),
|
|
||||||
).join("");
|
|
||||||
const response = {
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
|
|
||||||
name: sanitized,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
size: file.content.length,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify(response));
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: A2A JSON-RPC handler.
|
|
||||||
let body = "";
|
|
||||||
req.setEncoding("utf8");
|
|
||||||
req.on("data", (chunk: string) => {
|
|
||||||
body += chunk;
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
try {
|
|
||||||
const rpc = JSON.parse(body);
|
|
||||||
const msg = rpc.params?.message;
|
|
||||||
const textParts =
|
|
||||||
msg?.parts
|
|
||||||
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
|
|
||||||
.map((p: { text?: string }) => p.text)
|
|
||||||
.filter(Boolean) ?? [];
|
|
||||||
const fileParts =
|
|
||||||
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
|
|
||||||
const text = textParts.join("\n");
|
|
||||||
|
|
||||||
lastRequest = {
|
|
||||||
method: rpc.method ?? "unknown",
|
|
||||||
text,
|
|
||||||
files: fileParts,
|
|
||||||
};
|
|
||||||
|
|
||||||
const replyText = text
|
|
||||||
? `Echo: ${text}`
|
|
||||||
: fileParts.length > 0
|
|
||||||
? "Echo: received your file(s)."
|
|
||||||
: "Echo: hello";
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: rpc.id ?? null,
|
|
||||||
result: {
|
|
||||||
parts: [{ kind: "text", text: replyText }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify(response));
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: "invalid json" }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
const address = server.address();
|
|
||||||
const port = typeof address === "object" && address ? address.port : 0;
|
|
||||||
const baseURL = `http://127.0.0.1:${port}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseURL,
|
|
||||||
stop: () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
server.close(() => resolve(undefined));
|
|
||||||
}),
|
|
||||||
get lastRequest() {
|
|
||||||
return lastRequest;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -5,10 +5,9 @@ export default defineConfig({
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
|
||||||
retries: 0,
|
retries: 0,
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
headless: true,
|
headless: true,
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -251,7 +251,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{isError && entry.error_detail && (
|
{isError && entry.error_detail && (
|
||||||
<div className="text-[10px] text-bad mt-1 truncate">
|
<div className="text-[10px] text-bad/80 mt-1 truncate">
|
||||||
{entry.error_detail.slice(0, 200)}
|
{entry.error_detail.slice(0, 200)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -272,7 +272,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
|||||||
)}
|
)}
|
||||||
{responseText && (
|
{responseText && (
|
||||||
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||||
<div className="text-[8px] text-good uppercase mb-1">Response</div>
|
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
|
||||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||||
{responseText.slice(0, 2000)}
|
{responseText.slice(0, 2000)}
|
||||||
{responseText.length > 2000 && (
|
{responseText.length > 2000 && (
|
||||||
|
|||||||
@ -344,7 +344,7 @@ function ProviderPickerModal({
|
|||||||
// wrapper's bounds instead of the viewport.
|
// wrapper's bounds instead of the viewport.
|
||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
const allSaved = entries.every((e) => e.saved);
|
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||||
const anySaving = entries.some((e) => e.saving);
|
const anySaving = entries.some((e) => e.saving);
|
||||||
const runtimeLabel = runtime
|
const runtimeLabel = runtime
|
||||||
.replace(/[-_]/g, " ")
|
.replace(/[-_]/g, " ")
|
||||||
@ -616,7 +616,7 @@ function AllKeysModal({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
if (typeof document === "undefined") return null;
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
const allSaved = entries.every((e) => e.saved);
|
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||||
const anySaving = entries.some((e) => e.saving);
|
const anySaving = entries.some((e) => e.saving);
|
||||||
const runtimeLabel = runtime
|
const runtimeLabel = runtime
|
||||||
.replace(/[-_]/g, " ")
|
.replace(/[-_]/g, " ")
|
||||||
|
|||||||
@ -62,11 +62,11 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
setTheme(OPTIONS[next].value);
|
setTheme(OPTIONS[next].value);
|
||||||
// Move focus to the new button so arrow-key navigation is continuous.
|
// Move focus to the new button so arrow-key navigation is continuous.
|
||||||
// Query is already scoped to radiogroup so no child-combinator needed;
|
// Use direct-child query to scope strictly to this radiogroup's buttons
|
||||||
// avoids accidentally focusing unrelated [role=radio] elements
|
// and avoid accidentally focusing unrelated [role=radio] elements
|
||||||
// elsewhere in the DOM (e.g. React Flow canvas nodes).
|
// elsewhere in the DOM (e.g. React Flow canvas nodes).
|
||||||
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
|
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
|
||||||
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("[role=radio]");
|
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("> [role=radio]");
|
||||||
btns?.[next]?.focus();
|
btns?.[next]?.focus();
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@ -13,20 +13,17 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
|||||||
|
|
||||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||||
* rendered as full cards inside this one via React Flow's native parentId,
|
* rendered as full cards inside this one via React Flow's native parentId,
|
||||||
* so we don't need to subscribe to the actual child list here.
|
* so we don't need to subscribe to the actual child list here. */
|
||||||
* Selecting `nodes` stably avoids a new selector reference on every store
|
|
||||||
* update (React error #185 / Zustand + React 19 Object.is strictness). */
|
|
||||||
function useDescendantCount(nodeId: string): number {
|
function useDescendantCount(nodeId: string): number {
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
return useCanvasStore(
|
||||||
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
|
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boolean flag used to drive min-size and NodeResizer dimensions.
|
|
||||||
* Selecting `nodes` stably avoids re-render loops (same issue as
|
|
||||||
* useDescendantCount). */
|
|
||||||
function useHasChildren(nodeId: string): boolean {
|
function useHasChildren(nodeId: string): boolean {
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
return useCanvasStore(
|
||||||
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
|
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
/** Eject/extract arrow icon — visually distinct from delete ✕ */
|
||||||
|
|||||||
@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Wrap cleanup in act() so any pending React state updates (e.g. from
|
|
||||||
// keyDown handlers that call setTheme) flush before DOM unmount. Without
|
|
||||||
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
|
|
||||||
// when the handleKeyDown callback tries to query the DOM mid-teardown.
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => { cleanup(); });
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
|||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
|
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
|
||||||
act(() => { radios[2].focus(); });
|
act(() => { radios[2].focus(); });
|
||||||
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
|
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
|
||||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
|||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
// light (index 0) is current; ArrowLeft should go to dark (index 2)
|
// light (index 0) is current; ArrowLeft should go to dark (index 2)
|
||||||
act(() => { radios[0].focus(); });
|
act(() => { radios[0].focus(); });
|
||||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
|
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
|
||||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
|||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
// light (index 0) is current; ArrowDown should go to system (index 1)
|
// light (index 0) is current; ArrowDown should go to system (index 1)
|
||||||
act(() => { radios[0].focus(); });
|
act(() => { radios[0].focus(); });
|
||||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
|
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
|
||||||
expect(mockSetTheme).toHaveBeenCalledWith("system");
|
expect(mockSetTheme).toHaveBeenCalledWith("system");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
|||||||
render(<ThemeToggle />);
|
render(<ThemeToggle />);
|
||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
act(() => { radios[2].focus(); });
|
act(() => { radios[2].focus(); });
|
||||||
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
|
fireEvent.keyDown(radios[2], { key: "Home" });
|
||||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
|||||||
render(<ThemeToggle />);
|
render(<ThemeToggle />);
|
||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
act(() => { radios[0].focus(); });
|
act(() => { radios[0].focus(); });
|
||||||
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
|
fireEvent.keyDown(radios[0], { key: "End" });
|
||||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing on unrelated keys", () => {
|
it("does nothing on unrelated keys", () => {
|
||||||
render(<ThemeToggle />);
|
render(<ThemeToggle />);
|
||||||
const radios = screen.getAllByRole("radio");
|
const radios = screen.getAllByRole("radio");
|
||||||
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
|
fireEvent.keyDown(radios[0], { key: "Enter" });
|
||||||
expect(mockSetTheme).not.toHaveBeenCalled();
|
expect(mockSetTheme).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,20 +24,16 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function DropTargetBadge() {
|
export function DropTargetBadge() {
|
||||||
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
|
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
|
||||||
// Select nodes stably first — deriving targetName and childCount inside
|
const targetName = useCanvasStore((s) => {
|
||||||
// the same selector creates a new return value on every store mutation
|
if (!s.dragOverNodeId) return null;
|
||||||
// even when neither has changed (React error #185 / Zustand Object.is).
|
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
|
||||||
const targetName = (() => {
|
|
||||||
if (!dragOverNodeId) return null;
|
|
||||||
const n = nodes.find((nn) => nn.id === dragOverNodeId);
|
|
||||||
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
|
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
|
||||||
})();
|
});
|
||||||
const childCount = (() =>
|
const childCount = useCanvasStore((s) =>
|
||||||
!dragOverNodeId
|
!s.dragOverNodeId
|
||||||
? 0
|
? 0
|
||||||
: nodes.filter((n) => n.parentId === dragOverNodeId).length
|
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
|
||||||
)();
|
);
|
||||||
const { getInternalNode, flowToScreenPosition } = useReactFlow();
|
const { getInternalNode, flowToScreenPosition } = useReactFlow();
|
||||||
if (!dragOverNodeId || !targetName) return null;
|
if (!dragOverNodeId || !targetName) return null;
|
||||||
const internal = getInternalNode(dragOverNodeId);
|
const internal = getInternalNode(dragOverNodeId);
|
||||||
|
|||||||
@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for buildDeployMap — the pure tree-traversal core of
|
|
||||||
* useOrgDeployState.
|
|
||||||
*
|
|
||||||
* What is tested here:
|
|
||||||
* - Root / leaf identification via parent-chain walk
|
|
||||||
* - isDeployingRoot: true when any descendant is "provisioning"
|
|
||||||
* - isActivelyProvisioning: true only for the node itself in that state
|
|
||||||
* - isLockedChild: true for non-root nodes in a deploying tree
|
|
||||||
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
|
|
||||||
* - descendantProvisioningCount: non-zero only on root nodes
|
|
||||||
* - Performance contract: O(n) single-pass walk — tested by verifying
|
|
||||||
* correctness across 50-node trees (n=50, all cases above)
|
|
||||||
*
|
|
||||||
* What is NOT tested here (hook integration — appropriate for E2E):
|
|
||||||
* - The useMemo / Zustand subscription wiring
|
|
||||||
* - React Flow integration (flowToScreenPosition, getInternalNode)
|
|
||||||
*
|
|
||||||
* Issue: #2071 (Canvas test gaps follow-up).
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Projection = { id: string; parentId: string | null; status: string };
|
|
||||||
|
|
||||||
function proj(
|
|
||||||
id: string,
|
|
||||||
parentId: string | null,
|
|
||||||
status: string,
|
|
||||||
): Projection {
|
|
||||||
return { id, parentId, status };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unchecked cast — test helpers aren't production code paths. */
|
|
||||||
function m(
|
|
||||||
ps: Projection[],
|
|
||||||
deletingIds: string[] = [],
|
|
||||||
): Map<string, OrgDeployState> {
|
|
||||||
return buildDeployMap(ps, new Set(deletingIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
function s(
|
|
||||||
map: Map<string, OrgDeployState>,
|
|
||||||
id: string,
|
|
||||||
): OrgDeployState {
|
|
||||||
const got = map.get(id);
|
|
||||||
if (!got) throw new Error(`no entry for id=${id}`);
|
|
||||||
return got;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Empty / trivial ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — empty", () => {
|
|
||||||
it("returns empty map for empty projections", () => {
|
|
||||||
expect(m([]).size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Single node ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — single node", () => {
|
|
||||||
it("isolated node is its own root and not deploying", () => {
|
|
||||||
const map = m([proj("a", null, "online")]);
|
|
||||||
expect(s(map, "a")).toEqual({
|
|
||||||
isActivelyProvisioning: false,
|
|
||||||
isDeployingRoot: false,
|
|
||||||
isLockedChild: false,
|
|
||||||
descendantProvisioningCount: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolated provisioning node is deploying root", () => {
|
|
||||||
const map = m([proj("a", null, "provisioning")]);
|
|
||||||
expect(s(map, "a")).toEqual({
|
|
||||||
isActivelyProvisioning: true,
|
|
||||||
isDeployingRoot: true,
|
|
||||||
isLockedChild: false,
|
|
||||||
descendantProvisioningCount: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Parent / child chains ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — parent / child chains", () => {
|
|
||||||
it("root with online child: root is not deploying, child is not locked", () => {
|
|
||||||
// A ──► B
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("root with provisioning child: root is deploying, child is locked", () => {
|
|
||||||
// A ──► B (B is provisioning)
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "provisioning"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("provisioning root with online child: root is deploying, child is locked", () => {
|
|
||||||
// A (provisioning) ──► B (online)
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grandchild inherits deploy lock through intermediate online node", () => {
|
|
||||||
// A ──► B ──► C (A is provisioning)
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
proj("C", "B", "online"),
|
|
||||||
]);
|
|
||||||
// B and C are both non-root descendants of the deploying root
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deep chain: only the topmost node with a null parent counts as root", () => {
|
|
||||||
// A ──► B ──► C ──► D (A is provisioning)
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
proj("C", "B", "online"),
|
|
||||||
proj("D", "C", "online"),
|
|
||||||
]);
|
|
||||||
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
|
|
||||||
expect(roots).toEqual(["A"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Sibling branching ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — sibling branching", () => {
|
|
||||||
it("parent with multiple children: deploying root propagates to all children", () => {
|
|
||||||
// A (provisioning)
|
|
||||||
// / \
|
|
||||||
// B C
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
proj("C", "A", "online"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only one provisioning descendant marks the root as deploying", () => {
|
|
||||||
// A
|
|
||||||
// / | \
|
|
||||||
// B C D (only C is provisioning)
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
proj("C", "A", "provisioning"),
|
|
||||||
proj("D", "A", "online"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
|
||||||
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("two provisioning siblings: count reflects both", () => {
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "provisioning"),
|
|
||||||
proj("C", "A", "provisioning"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
|
|
||||||
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multiple disjoint trees ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — multiple disjoint trees", () => {
|
|
||||||
it("each tree has its own root; deploying nodes are independent", () => {
|
|
||||||
// Tree 1: X (provisioning) ──► Y
|
|
||||||
// Tree 2: P ──► Q (no provisioning)
|
|
||||||
const map = m([
|
|
||||||
proj("X", null, "provisioning"),
|
|
||||||
proj("Y", "X", "online"),
|
|
||||||
proj("P", null, "online"),
|
|
||||||
proj("Q", "P", "online"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
|
|
||||||
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
|
|
||||||
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
|
||||||
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Deleting nodes ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — deletingIds", () => {
|
|
||||||
it("node in deletingIds is locked even if tree is not deploying", () => {
|
|
||||||
const map = m(
|
|
||||||
[
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
],
|
|
||||||
["B"], // B is being deleted
|
|
||||||
);
|
|
||||||
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
|
|
||||||
const map = m(
|
|
||||||
[
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
],
|
|
||||||
["B"],
|
|
||||||
);
|
|
||||||
// B is both a deploying-child AND a deleting node — either alone locks it
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty deletingIds set has no effect", () => {
|
|
||||||
const map = m(
|
|
||||||
[
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── descendantProvisioningCount ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — descendantProvisioningCount", () => {
|
|
||||||
it("is 0 for non-root nodes", () => {
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "provisioning"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "B").descendantProvisioningCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the root's own status when provisioning", () => {
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "provisioning"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
]);
|
|
||||||
// A is both root and provisioning → count includes itself
|
|
||||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accumulates all provisioning descendants (not just immediate children)", () => {
|
|
||||||
const map = m([
|
|
||||||
proj("A", null, "online"),
|
|
||||||
proj("B", "A", "online"),
|
|
||||||
proj("C", "B", "provisioning"),
|
|
||||||
]);
|
|
||||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── O(n) performance ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("buildDeployMap — O(n) performance contract", () => {
|
|
||||||
it("handles a 50-node three-level tree without incorrect node assignments", () => {
|
|
||||||
// Level 0: 1 root
|
|
||||||
// Level 1: 7 children
|
|
||||||
// Level 2: 42 leaves
|
|
||||||
// Total: 50 nodes
|
|
||||||
const projections: Projection[] = [];
|
|
||||||
projections.push(proj("root", null, "provisioning"));
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
projections.push(proj(`l1-${i}`, "root", "online"));
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 42; i++) {
|
|
||||||
const parent = `l1-${Math.floor(i / 6)}`;
|
|
||||||
projections.push(proj(`l2-${i}`, parent, "online"));
|
|
||||||
}
|
|
||||||
const map = m(projections);
|
|
||||||
|
|
||||||
// Root is the only deploying node
|
|
||||||
expect(s(map, "root")).toMatchObject({
|
|
||||||
isDeployingRoot: true,
|
|
||||||
isLockedChild: false,
|
|
||||||
descendantProvisioningCount: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every other node is a locked child
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 42; i++) {
|
|
||||||
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { appendClass, removeClass } from "@/store/classNames";
|
import { appendClass, removeClass } from "@/store/classNames";
|
||||||
@ -153,17 +153,10 @@ export function useCanvasViewport() {
|
|||||||
// fit, the user has to manually pan + zoom to find what they just
|
// fit, the user has to manually pan + zoom to find what they just
|
||||||
// created. Only fires when TRANSITIONING from some-provisioning to
|
// created. Only fires when TRANSITIONING from some-provisioning to
|
||||||
// zero-provisioning — not on every re-render.
|
// zero-provisioning — not on every re-render.
|
||||||
//
|
const provisioningCount = useCanvasStore(
|
||||||
// Selecting `nodes` stably (array reference) avoids the
|
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
|
||||||
// `.filter().length` anti-pattern which creates a new number on every
|
|
||||||
// store update and breaks the wasProvisioning/hasProvisioning
|
|
||||||
// transition detection (React error #185 / Zustand + React 19).
|
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
|
||||||
const provisioningCount = useMemo(
|
|
||||||
() => nodes.filter((n) => n.data.status === "provisioning").length,
|
|
||||||
[nodes],
|
|
||||||
);
|
);
|
||||||
const nodeCount = nodes.length;
|
const nodeCount = useCanvasStore((s) => s.nodes.length);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasProvisioning = provisioningCount > 0;
|
const hasProvisioning = provisioningCount > 0;
|
||||||
|
|||||||
@ -5,22 +5,22 @@
|
|||||||
// that the desktop ChatTab uses, but with a slimmer surface: no
|
// that the desktop ChatTab uses, but with a slimmer surface: no
|
||||||
// attachments, no A2A topology overlay, no conversation tracing.
|
// attachments, no A2A topology overlay, no conversation tracing.
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
|
|
||||||
import {
|
|
||||||
useChatHistory,
|
|
||||||
useChatSend,
|
|
||||||
useChatSocket,
|
|
||||||
} from "@/components/tabs/chat/hooks";
|
|
||||||
|
|
||||||
import { toMobileAgent } from "./components";
|
import { toMobileAgent } from "./components";
|
||||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "agent" | "system";
|
||||||
|
text: string;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
const formatStoredTimestamp = (iso: string): string => {
|
const formatStoredTimestamp = (iso: string): string => {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (isNaN(d.getTime())) return "";
|
if (isNaN(d.getTime())) return "";
|
||||||
@ -29,171 +29,16 @@ const formatStoredTimestamp = (iso: string): string => {
|
|||||||
|
|
||||||
type SubTab = "my" | "a2a";
|
type SubTab = "my" | "a2a";
|
||||||
|
|
||||||
function MarkdownBubble({
|
interface A2AResponseShape {
|
||||||
children,
|
result?: {
|
||||||
dark,
|
parts?: Array<{ kind?: string; text?: string }>;
|
||||||
accent,
|
};
|
||||||
}: {
|
error?: { message?: string };
|
||||||
children: string;
|
|
||||||
dark: boolean;
|
|
||||||
accent: string;
|
|
||||||
}) {
|
|
||||||
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
|
|
||||||
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
|
|
||||||
const linkColor = accent;
|
|
||||||
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
p: ({ children }) => (
|
|
||||||
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
|
|
||||||
),
|
|
||||||
a: ({ href, children }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: linkColor, textDecoration: "underline" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
pre: ({ children }) => (
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
background: codeBlockBg,
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "auto",
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
fontFamily: MOBILE_FONT_MONO,
|
|
||||||
margin: "4px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
code: ({ children, className }) => {
|
|
||||||
const isBlock = className != null && String(className).length > 0;
|
|
||||||
if (isBlock) {
|
|
||||||
return (
|
|
||||||
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code
|
|
||||||
style={{
|
|
||||||
background: codeBg,
|
|
||||||
padding: "1px 4px",
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: MOBILE_FONT_MONO,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ul: ({ children }) => (
|
|
||||||
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }) => (
|
|
||||||
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
|
|
||||||
strong: ({ children }) => (
|
|
||||||
<strong style={{ fontWeight: 600 }}>{children}</strong>
|
|
||||||
),
|
|
||||||
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
|
|
||||||
h1: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
h2: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
h3: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
h4: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
h5: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
h6: ({ children }) => (
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
|
||||||
),
|
|
||||||
blockquote: ({ children }) => (
|
|
||||||
<blockquote
|
|
||||||
style={{
|
|
||||||
borderLeft: `2px solid ${quoteBorder}`,
|
|
||||||
margin: "4px 0",
|
|
||||||
paddingLeft: 8,
|
|
||||||
opacity: 0.85,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
hr: () => (
|
|
||||||
<hr
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderTop: `0.5px solid ${quoteBorder}`,
|
|
||||||
margin: "6px 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
table: ({ children }) => (
|
|
||||||
<table
|
|
||||||
style={{
|
|
||||||
borderCollapse: "collapse",
|
|
||||||
fontSize: 13,
|
|
||||||
margin: "4px 0",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
),
|
|
||||||
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
|
|
||||||
th: ({ children }) => (
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
border: `0.5px solid ${quoteBorder}`,
|
|
||||||
padding: "4px 6px",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</th>
|
|
||||||
),
|
|
||||||
td: ({ children }) => (
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
border: `0.5px solid ${quoteBorder}`,
|
|
||||||
padding: "4px 6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ReactMarkdown>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTime = (date: Date) =>
|
||||||
|
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
|
||||||
export function MobileChat({
|
export function MobileChat({
|
||||||
agentId,
|
agentId,
|
||||||
dark,
|
dark,
|
||||||
@ -204,40 +49,34 @@ export function MobileChat({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const p = usePalette(dark);
|
const p = usePalette(dark);
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
// Bootstrap from the canvas store's per-workspace message buffer so the
|
||||||
|
// user sees their prior thread on entry. The store is updated by the
|
||||||
|
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||||
|
// same buffer to keep state coherent across viewports.
|
||||||
|
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
|
||||||
|
// that creates a new [] reference on every store update when the key is
|
||||||
|
// absent, causing infinite re-render (React error #185).
|
||||||
|
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||||
|
(storedMessages ?? []).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: "agent",
|
||||||
|
text: m.content,
|
||||||
|
ts: formatStoredTimestamp(m.timestamp),
|
||||||
|
})),
|
||||||
|
);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [tab, setTab] = useState<SubTab>("my");
|
const [tab, setTab] = useState<SubTab>("my");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Synchronous re-entry guard. `setSending(true)` schedules a state
|
||||||
|
// update but doesn't flush before a second tap can fire send() — a ref
|
||||||
|
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
|
||||||
|
// double-send race a stale `sending` lets through.
|
||||||
|
const sendInFlightRef = useRef(false);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
loading: historyLoading,
|
|
||||||
loadError: historyError,
|
|
||||||
loadInitial,
|
|
||||||
appendMessageDeduped,
|
|
||||||
} = useChatHistory(agentId);
|
|
||||||
|
|
||||||
const {
|
|
||||||
sending,
|
|
||||||
uploading,
|
|
||||||
sendMessage,
|
|
||||||
error: sendError,
|
|
||||||
clearError,
|
|
||||||
releaseSendGuards,
|
|
||||||
} = useChatSend(agentId, {
|
|
||||||
getHistoryMessages: () => messages,
|
|
||||||
onUserMessage: appendMessageDeduped,
|
|
||||||
onAgentMessage: appendMessageDeduped,
|
|
||||||
});
|
|
||||||
|
|
||||||
useChatSocket(agentId, {
|
|
||||||
onAgentMessage: appendMessageDeduped,
|
|
||||||
onSendComplete: releaseSendGuards,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||||
// shrinks when the user deletes text, then size to scrollHeight up to
|
// shrinks when the user deletes text, then size to scrollHeight up to
|
||||||
@ -256,20 +95,6 @@ export function MobileChat({
|
|||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Consume any agent messages that arrived while history was loading.
|
|
||||||
const initialConsumeDoneRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (historyLoading || initialConsumeDoneRef.current) return;
|
|
||||||
initialConsumeDoneRef.current = true;
|
|
||||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
|
||||||
const msgs = consume(agentId);
|
|
||||||
for (const m of msgs) {
|
|
||||||
appendMessageDeduped(
|
|
||||||
createMessage("agent", m.content, m.attachments),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [historyLoading, agentId, appendMessageDeduped]);
|
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -291,32 +116,58 @@ export function MobileChat({
|
|||||||
const a = toMobileAgent(node);
|
const a = toMobileAgent(node);
|
||||||
const reachable = a.status === "online" || a.status === "degraded";
|
const reachable = a.status === "online" || a.status === "degraded";
|
||||||
|
|
||||||
const onFilesPicked = (fileList: FileList | null) => {
|
|
||||||
if (!fileList) return;
|
|
||||||
const picked = Array.from(fileList);
|
|
||||||
setPendingFiles((prev) => {
|
|
||||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
|
||||||
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
|
||||||
});
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePendingFile = (index: number) =>
|
|
||||||
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
|
if (!text || sending || !reachable) return;
|
||||||
clearError();
|
if (sendInFlightRef.current) return;
|
||||||
|
sendInFlightRef.current = true;
|
||||||
setDraft("");
|
setDraft("");
|
||||||
const files = pendingFiles;
|
setError(null);
|
||||||
setPendingFiles([]);
|
setSending(true);
|
||||||
await sendMessage(text, files);
|
const myMsg: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
text,
|
||||||
|
ts: formatTime(new Date()),
|
||||||
|
};
|
||||||
|
setMessages((m) => [...m, myMsg]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
|
||||||
|
method: "message/send",
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
messageId: crypto.randomUUID(),
|
||||||
|
parts: [{ kind: "text", text }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reply =
|
||||||
|
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
|
||||||
|
if (reply) {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "agent",
|
||||||
|
text: reply,
|
||||||
|
ts: formatTime(new Date()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else if (res.error?.message) {
|
||||||
|
setError(res.error.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to send");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
sendInFlightRef.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="chat-panel"
|
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -457,42 +308,7 @@ export function MobileChat({
|
|||||||
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tab === "my" && historyLoading && (
|
{tab === "my" && messages.length === 0 && (
|
||||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
|
||||||
Loading chat history…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
style={{
|
|
||||||
padding: "14px 4px",
|
|
||||||
textAlign: "center",
|
|
||||||
color: p.failed,
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
loadInitial();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
borderRadius: 14,
|
|
||||||
border: `0.5px solid ${p.failed}`,
|
|
||||||
background: "transparent",
|
|
||||||
color: p.failed,
|
|
||||||
fontSize: 12,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
|
|
||||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||||
Send a message to start chatting.
|
Send a message to start chatting.
|
||||||
</div>
|
</div>
|
||||||
@ -521,9 +337,7 @@ export function MobileChat({
|
|||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownBubble dark={dark} accent={p.accent}>
|
{m.text}
|
||||||
{m.content}
|
|
||||||
</MarkdownBubble>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@ -532,13 +346,13 @@ export function MobileChat({
|
|||||||
fontFamily: MOBILE_FONT_MONO,
|
fontFamily: MOBILE_FONT_MONO,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatStoredTimestamp(m.timestamp)}
|
{m.ts}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sendError && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
style={{
|
style={{
|
||||||
@ -550,7 +364,7 @@ export function MobileChat({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sendError}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -581,60 +395,6 @@ export function MobileChat({
|
|||||||
backdropFilter: "blur(14px)",
|
backdropFilter: "blur(14px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pendingFiles.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 6,
|
|
||||||
marginBottom: 8,
|
|
||||||
paddingLeft: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pendingFiles.map((f, i) => (
|
|
||||||
<div
|
|
||||||
key={`${f.name}:${f.size}`}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: 10,
|
|
||||||
background: dark ? "#2a2823" : "#ece9e0",
|
|
||||||
fontSize: 12,
|
|
||||||
color: p.text2,
|
|
||||||
maxWidth: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removePendingFile(i)}
|
|
||||||
aria-label={`Remove ${f.name}`}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: p.text3,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: 0,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -646,32 +406,21 @@ export function MobileChat({
|
|||||||
padding: "6px 6px 6px 12px",
|
padding: "6px 6px 6px 12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(e) => onFilesPicked(e.target.files)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={!reachable || sending || uploading}
|
|
||||||
aria-label="Attach"
|
aria-label="Attach"
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
|
cursor: "pointer",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: p.text3,
|
color: p.text3,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: !reachable || sending || uploading ? 0.4 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Icons.attach({ size: 16 })}
|
{Icons.attach({ size: 16 })}
|
||||||
@ -717,32 +466,28 @@ export function MobileChat({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
|
disabled={!draft.trim() || !reachable || sending}
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
|
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background:
|
background:
|
||||||
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
|
draft.trim() && reachable && !sending
|
||||||
? p.accent
|
? p.accent
|
||||||
: dark
|
: dark
|
||||||
? "#2a2823"
|
? "#2a2823"
|
||||||
: "#ece9e0",
|
: "#ece9e0",
|
||||||
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
|
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{Icons.send({ size: 16 })}
|
||||||
<span style={{ fontSize: 10, fontWeight: 600 }}>↑</span>
|
|
||||||
) : (
|
|
||||||
Icons.send({ size: 16 })
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
|
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
@ -32,10 +32,7 @@ export function MobileDetail({
|
|||||||
onChat: () => void;
|
onChat: () => void;
|
||||||
}) {
|
}) {
|
||||||
const p = usePalette(dark);
|
const p = usePalette(dark);
|
||||||
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
|
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
|
||||||
// creates a new return value on every store update (React error #185).
|
|
||||||
const nodes = useCanvasStore((s) => s.nodes);
|
|
||||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
|
||||||
const [tab, setTab] = useState<TabId>("overview");
|
const [tab, setTab] = useState<TabId>("overview");
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@ -214,7 +211,6 @@ export function MobileDetail({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChat}
|
onClick={onChat}
|
||||||
data-testid="mobile-chat-cta"
|
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 52,
|
height: 52,
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { type Template } from "@/lib/deploy-preflight";
|
import { type Template } from "@/lib/deploy-preflight";
|
||||||
import { isSaaSTenant } from "@/lib/tenant";
|
|
||||||
|
|
||||||
import { tierCode } from "./palette";
|
import { tierCode } from "./palette";
|
||||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
|
||||||
@ -27,7 +26,6 @@ const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
|
|||||||
|
|
||||||
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
|
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
|
||||||
const p = usePalette(dark);
|
const p = usePalette(dark);
|
||||||
const isSaaS = isSaaSTenant();
|
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
const [loadingTemplates, setLoadingTemplates] = useState(true);
|
const [loadingTemplates, setLoadingTemplates] = useState(true);
|
||||||
const [tplId, setTplId] = useState<string | null>(null);
|
const [tplId, setTplId] = useState<string | null>(null);
|
||||||
@ -45,7 +43,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
|||||||
setTemplates(list);
|
setTemplates(list);
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
setTplId(list[0].id);
|
setTplId(list[0].id);
|
||||||
setTier(isSaaS ? "T4" : tierCode(list[0].tier));
|
setTier(tierCode(list[0].tier));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@ -57,7 +55,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isSaaS]);
|
}, []);
|
||||||
|
|
||||||
const handleSpawn = async () => {
|
const handleSpawn = async () => {
|
||||||
if (busy || !tplId) return;
|
if (busy || !tplId) return;
|
||||||
@ -69,7 +67,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
|||||||
await api.post<{ id: string }>("/workspaces", {
|
await api.post<{ id: string }>("/workspaces", {
|
||||||
name: (name.trim() || chosen.name),
|
name: (name.trim() || chosen.name),
|
||||||
template: chosen.id,
|
template: chosen.id,
|
||||||
tier: isSaaS ? 4 : Number(tier.slice(1)),
|
tier: Number(tier.slice(1)),
|
||||||
canvas: {
|
canvas: {
|
||||||
x: Math.random() * 400 + 100,
|
x: Math.random() * 400 + 100,
|
||||||
y: Math.random() * 300 + 100,
|
y: Math.random() * 300 + 100,
|
||||||
@ -205,7 +203,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
|||||||
>
|
>
|
||||||
{templates.map((t) => {
|
{templates.map((t) => {
|
||||||
const on = tplId === t.id;
|
const on = tplId === t.id;
|
||||||
const tCode = isSaaS ? "T4" : tierCode(t.tier);
|
const tCode = tierCode(t.tier);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|||||||
@ -8,19 +8,11 @@
|
|||||||
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { MobileChat } from "../MobileChat";
|
import { MobileChat } from "../MobileChat";
|
||||||
|
|
||||||
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
|
||||||
// vi.mock without a factory auto-mocks the module. In tests, we configure
|
|
||||||
// api.get / api.post directly (they are vi.fn() from the auto-mock).
|
|
||||||
// Tests that need specific behaviour use mockResolvedValueOnce on the
|
|
||||||
// auto-mocked functions.
|
|
||||||
vi.mock("@/lib/api");
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
|
|
||||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockAgentId = "ws-chat-test";
|
const mockAgentId = "ws-chat-test";
|
||||||
@ -36,19 +28,12 @@ const mockStoreState = {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}>,
|
}>,
|
||||||
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
|
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
|
||||||
consumeAgentMessages: () => [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/store/canvas", () => ({
|
vi.mock("@/store/canvas", () => ({
|
||||||
useCanvasStore: Object.assign(
|
useCanvasStore: Object.assign(
|
||||||
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
|
vi.fn((sel) => sel(mockStoreState)),
|
||||||
if (sel) return sel(mockStoreState);
|
{ getState: () => mockStoreState },
|
||||||
return mockStoreState;
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
getState: () => mockStoreState,
|
|
||||||
subscribe: vi.fn(() => vi.fn()),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
|
||||||
const agentCard = data.agentCard as Record<string, unknown> | null;
|
const agentCard = data.agentCard as Record<string, unknown> | null;
|
||||||
@ -69,6 +54,16 @@ vi.mock("@/store/canvas", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ─── Mock API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { mockApiPost } = vi.hoisted(() => ({
|
||||||
|
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: { post: mockApiPost },
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const onlineNode = {
|
const onlineNode = {
|
||||||
@ -155,15 +150,7 @@ beforeEach(() => {
|
|||||||
mockOnBack.mockClear();
|
mockOnBack.mockClear();
|
||||||
mockStoreState.nodes = [];
|
mockStoreState.nodes = [];
|
||||||
mockStoreState.agentMessages = {};
|
mockStoreState.agentMessages = {};
|
||||||
// Set up spies on the real api methods. Tests override these per-call.
|
mockApiPost.mockClear();
|
||||||
const getSpy = vi.spyOn(api, "get");
|
|
||||||
const postSpy = vi.spyOn(api, "post");
|
|
||||||
getSpy.mockResolvedValue({ messages: [], reached_end: true });
|
|
||||||
postSpy.mockResolvedValue({ result: { parts: [] } });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -279,26 +266,15 @@ describe("MobileChat — empty state", () => {
|
|||||||
mockStoreState.nodes = [onlineNode];
|
mockStoreState.nodes = [onlineNode];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Send a message to start chatting." when no messages', async () => {
|
it('shows "Send a message to start chatting." when no messages', () => {
|
||||||
// History fetch resolves immediately in tests (mockResolvedValue).
|
const { container } = renderChat(mockAgentId);
|
||||||
// act() flushes the microtask queue so the component reaches its
|
|
||||||
// post-load state before we assert.
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
|
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
|
||||||
// Explicitly set to empty to simulate no stored messages
|
// Explicitly set to empty to simulate no stored messages
|
||||||
mockStoreState.agentMessages = {};
|
mockStoreState.agentMessages = {};
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
const { container } = renderChat(mockAgentId);
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -345,132 +321,3 @@ describe("MobileChat — dark mode", () => {
|
|||||||
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Chat history loading ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("MobileChat — chat history", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockStoreState.nodes = [onlineNode];
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls GET /workspaces/:id/chat-history on mount", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
expect(api.get).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading state while history is fetching", () => {
|
|
||||||
// Do NOT await — check the pre-resolve state.
|
|
||||||
const { container } = renderChat(mockAgentId);
|
|
||||||
expect(container.textContent ?? "").toContain("Loading chat history…");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows empty state after history resolves with no messages", async () => {
|
|
||||||
// beforeEach already sets api.get to resolve with empty — no override needed.
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders messages from history response", async () => {
|
|
||||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "msg-1",
|
|
||||||
role: "user",
|
|
||||||
content: "Hello agent",
|
|
||||||
timestamp: "2026-04-25T10:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "msg-2",
|
|
||||||
role: "agent",
|
|
||||||
content: "Hello back",
|
|
||||||
timestamp: "2026-04-25T10:00:01Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reached_end: true,
|
|
||||||
});
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("Hello agent");
|
|
||||||
expect(container.textContent ?? "").toContain("Hello back");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps user role from API correctly", async () => {
|
|
||||||
vi.spyOn(api, "get").mockResolvedValueOnce({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: "msg-u",
|
|
||||||
role: "user",
|
|
||||||
content: "user message",
|
|
||||||
timestamp: "2026-04-25T10:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
reached_end: true,
|
|
||||||
});
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
// User messages render right-aligned. The text content check is sufficient
|
|
||||||
// to confirm the message appeared.
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("user message");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error state when history fetch fails", async () => {
|
|
||||||
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
|
||||||
expect(container.textContent ?? "").toContain("Retry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Retry button re-fetches history after error", async () => {
|
|
||||||
// Make the initial mount call fail so the Retry button appears, then
|
|
||||||
// make the retry call succeed so we can verify the full flow.
|
|
||||||
const getSpy = vi.spyOn(api, "get");
|
|
||||||
getSpy
|
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
|
||||||
.mockResolvedValueOnce({ messages: [], reached_end: true });
|
|
||||||
|
|
||||||
let renderResult: ReturnType<typeof renderChat>;
|
|
||||||
await act(async () => {
|
|
||||||
renderResult = renderChat(mockAgentId);
|
|
||||||
});
|
|
||||||
const { container } = renderResult!;
|
|
||||||
|
|
||||||
// Error state should be shown with Retry button.
|
|
||||||
expect(container.textContent ?? "").toContain("Could not load chat history.");
|
|
||||||
expect(container.textContent ?? "").toContain("Retry");
|
|
||||||
|
|
||||||
// Click Retry — the button's onClick fires api.get again.
|
|
||||||
// The second mockResolvedValueOnce makes it succeed.
|
|
||||||
const retryBtn = Array.from(container.querySelectorAll("button")).find(
|
|
||||||
(b) => b.textContent?.trim() === "Retry",
|
|
||||||
);
|
|
||||||
expect(retryBtn).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
retryBtn?.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// waitFor polls until the retry resolves and component re-renders.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
|
|
||||||
});
|
|
||||||
// Initial call + retry = 2.
|
|
||||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -288,7 +288,6 @@ export function AgentCard({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="workspace-card"
|
|
||||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -307,7 +307,7 @@ function ActivityRow({
|
|||||||
|
|
||||||
{/* Error detail */}
|
{/* Error detail */}
|
||||||
{isError && entry.error_detail && (
|
{isError && entry.error_detail && (
|
||||||
<div className="text-[9px] text-bad mt-1 truncate">
|
<div className="text-[9px] text-bad/80 mt-1 truncate">
|
||||||
{entry.error_detail}
|
{entry.error_detail}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -358,10 +358,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
|
|||||||
const hint = inferA2AErrorHint(detail);
|
const hint = inferA2AErrorHint(detail);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[8px] text-bad uppercase tracking-wider mb-1">{label} — delivery failed</div>
|
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} — delivery failed</div>
|
||||||
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
|
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
|
||||||
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
|
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
|
||||||
<div className="text-[9px] text-bad leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
|
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
data-testid="budget-save-btn"
|
data-testid="budget-save-btn"
|
||||||
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -255,7 +255,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={() => setShowForm(!showForm)}
|
||||||
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
|
||||||
>
|
>
|
||||||
{showForm ? "Cancel" : "+ Connect"}
|
{showForm ? "Cancel" : "+ Connect"}
|
||||||
</button>
|
</button>
|
||||||
@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDiscover}
|
onClick={handleDiscover}
|
||||||
disabled={discovering || !formValues["bot_token"]}
|
disabled={discovering || !formValues["bot_token"]}
|
||||||
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{discovering ? "Detecting..." : "Detect Chats"}
|
{discovering ? "Detecting..." : "Detect Chats"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,19 +5,16 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||||
|
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||||
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
|
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
|
||||||
import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||||
import { PendingAttachmentPill } from "./chat/AttachmentViews";
|
import { PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||||
import { AttachmentPreview } from "./chat/AttachmentPreview";
|
import { AttachmentPreview } from "./chat/AttachmentPreview";
|
||||||
|
import { extractFilesFromTask } from "./chat/message-parser";
|
||||||
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
||||||
import { appendActivityLine } from "./chat/activityLog";
|
import { appendActivityLine } from "./chat/activityLog";
|
||||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { useChatHistory } from "./chat/hooks/useChatHistory";
|
|
||||||
import { useChatSend } from "./chat/hooks/useChatSend";
|
|
||||||
import { useChatSocket } from "./chat/hooks/useChatSocket";
|
|
||||||
|
|
||||||
export { extractReplyText } from "./chat/hooks/useChatSend";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@ -26,6 +23,147 @@ interface Props {
|
|||||||
|
|
||||||
type ChatSubTab = "my-chat" | "agent-comms";
|
type ChatSubTab = "my-chat" | "agent-comms";
|
||||||
|
|
||||||
|
// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only
|
||||||
|
// need parts/artifacts text + file extraction for the synchronous fallback.
|
||||||
|
interface A2AFileRef {
|
||||||
|
name?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
uri?: string;
|
||||||
|
bytes?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
|
||||||
|
// Pydantic union (TextPart | FilePart | DataPart). The flat
|
||||||
|
// protobuf shape `{url, filename, mediaType}` is rejected at the
|
||||||
|
// request boundary with `Field required` errors — keep this
|
||||||
|
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
|
||||||
|
interface A2APart {
|
||||||
|
kind: string;
|
||||||
|
text?: string;
|
||||||
|
file?: A2AFileRef;
|
||||||
|
}
|
||||||
|
interface A2AResponse {
|
||||||
|
result?: {
|
||||||
|
parts?: A2APart[];
|
||||||
|
artifacts?: Array<{ parts: A2APart[] }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal-self-message filtering moved server-side in RFC #2945
|
||||||
|
// PR-C/D — the platform's /chat-history endpoint applies the
|
||||||
|
// IsInternalSelfMessage predicate before returning rows, so the
|
||||||
|
// client no longer needs the local backstop on the history path.
|
||||||
|
// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
|
||||||
|
// the platform-side prefix filter handles the residual cases.
|
||||||
|
|
||||||
|
// extractReplyText pulls the agent's text reply out of an A2A response.
|
||||||
|
// Concatenates ALL text parts (joined with "\n") rather than returning
|
||||||
|
// just the first. Claude Code and other runtimes commonly emit multi-
|
||||||
|
// part text replies for long content (markdown tables, code blocks),
|
||||||
|
// and the prior "first part wins" implementation silently truncated
|
||||||
|
// the rest — observed on a 15k-char Wave 1 brief that rendered only
|
||||||
|
// the table header. Mirrors extractTextsFromParts in message-parser.ts.
|
||||||
|
//
|
||||||
|
// Server-side counterpart in workspace-server/internal/channels/
|
||||||
|
// manager.go has the same single-part bug; fix that too if/when a
|
||||||
|
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
|
||||||
|
export function extractReplyText(resp: A2AResponse): string {
|
||||||
|
const collect = (parts: A2APart[] | undefined): string => {
|
||||||
|
if (!parts) return "";
|
||||||
|
return parts
|
||||||
|
.filter((p) => p.kind === "text")
|
||||||
|
.map((p) => p.text ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
const result = resp?.result;
|
||||||
|
const collected: string[] = [];
|
||||||
|
const fromParts = collect(result?.parts);
|
||||||
|
if (fromParts) collected.push(fromParts);
|
||||||
|
// Walk artifacts even if parts had text — some producers (Hermes
|
||||||
|
// tool calls) emit a summary in parts AND details in artifacts.
|
||||||
|
// Returning early on parts dropped the artifact body silently.
|
||||||
|
if (result?.artifacts) {
|
||||||
|
for (const a of result.artifacts) {
|
||||||
|
const t = collect(a.parts);
|
||||||
|
if (t) collected.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collected.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent-returned files live on the same response shape as text —
|
||||||
|
// delegated to extractFilesFromTask in message-parser.ts, which also
|
||||||
|
// walks status.message.parts (that ChatTab's legacy text extractor
|
||||||
|
// doesn't). Single source of truth for file-part parsing across
|
||||||
|
// live chat, activity log replay, and any future consumers.
|
||||||
|
|
||||||
|
/** Initial chat history page size. The newest N messages are rendered
|
||||||
|
* on first paint; older history is fetched on demand via loadOlder()
|
||||||
|
* when the user scrolls the top sentinel into view. */
|
||||||
|
const INITIAL_HISTORY_LIMIT = 10;
|
||||||
|
/** Subsequent older-history batch size. Larger than INITIAL so a long
|
||||||
|
* scroll-back doesn't fan out into many round-trips. */
|
||||||
|
const OLDER_HISTORY_BATCH = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load chat history from the platform's typed /chat-history endpoint.
|
||||||
|
*
|
||||||
|
* Server-side rendering of activity_logs rows into ChatMessage shape
|
||||||
|
* lives in workspace-server/internal/messagestore/postgres_store.go
|
||||||
|
* (RFC #2945 PR-C/D). The server already applies the canvas-source
|
||||||
|
* filter, the internal-self-message predicate, the role decision
|
||||||
|
* (status=error vs agent-error prefix → system), and the v0/v1
|
||||||
|
* file-shape extraction. Canvas just renders what it receives.
|
||||||
|
*
|
||||||
|
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
|
||||||
|
*
|
||||||
|
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
|
||||||
|
* 200 → {"messages": ChatMessage[], "reached_end": boolean}
|
||||||
|
*
|
||||||
|
* Pagination:
|
||||||
|
* - Pass `limit` to bound the page size (newest-first from server).
|
||||||
|
* - Pass `beforeTs` (RFC3339) to fetch rows STRICTLY OLDER than that
|
||||||
|
* timestamp. Combined with limit, this yields the next-older page
|
||||||
|
* when scrolling backward through history.
|
||||||
|
*
|
||||||
|
* `reachedEnd` is propagated from the server. The server computes it
|
||||||
|
* by comparing rowCount vs limit so a partial last page is correctly
|
||||||
|
* detected even when the row→bubble fan-out is non-1:1 (each row
|
||||||
|
* produces 1-2 bubbles).
|
||||||
|
*/
|
||||||
|
async function loadMessagesFromDB(
|
||||||
|
workspaceId: string,
|
||||||
|
limit: number,
|
||||||
|
beforeTs?: string,
|
||||||
|
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (beforeTs) params.set("before_ts", beforeTs);
|
||||||
|
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
|
||||||
|
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server emits oldest-first within the page (RFC #2945 PR-C-2
|
||||||
|
// post-fix: server reverses row-aware before returning so the
|
||||||
|
// wire is display-ready). Canvas appends/prepends without
|
||||||
|
// reordering — this avoids the pair-flip bug a naive flat
|
||||||
|
// reverse causes when each row produces a (user, agent) pair
|
||||||
|
// with the same timestamp.
|
||||||
|
return {
|
||||||
|
messages: resp.messages ?? [],
|
||||||
|
error: null,
|
||||||
|
reachedEnd: resp.reached_end,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load chat history",
|
||||||
|
reachedEnd: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
|
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
|
||||||
*/
|
*/
|
||||||
@ -33,7 +171,7 @@ export function ChatTab({ workspaceId, data }: Props) {
|
|||||||
const [subTab, setSubTab] = useState<ChatSubTab>("my-chat");
|
const [subTab, setSubTab] = useState<ChatSubTab>("my-chat");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="chat-panel" className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
|
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
@ -109,68 +247,268 @@ export function ChatTab({ workspaceId, data }: Props) {
|
|||||||
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
|
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
|
||||||
*/
|
*/
|
||||||
function MyChatPanel({ workspaceId, data }: Props) {
|
function MyChatPanel({ workspaceId, data }: Props) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
// `sending` is strictly the "this tab kicked off a send and hasn't
|
||||||
const [activityLog, setActivityLog] = useState<string[]>([]);
|
// seen the reply yet" signal. Previously this was initialized from
|
||||||
|
// data.currentTask to pick up in-flight agent work on mount, but
|
||||||
|
// that conflated agent-busy (workspace heartbeat) with user-
|
||||||
|
// in-flight (local send): when the WS dropped a TASK_COMPLETE event,
|
||||||
|
// currentTask lingered, the component re-mounted with sending=true,
|
||||||
|
// and the Send button stayed disabled forever even though nothing
|
||||||
|
// local was in flight. For the "agent is busy, show spinner" UX,
|
||||||
|
// use data.currentTask directly in the render path.
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
const [thinkingElapsed, setThinkingElapsed] = useState(0);
|
const [thinkingElapsed, setThinkingElapsed] = useState(0);
|
||||||
|
const [activityLog, setActivityLog] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const currentTaskRef = useRef(data.currentTask);
|
||||||
|
const sendingFromAPIRef = useRef(false);
|
||||||
const [agentReachable, setAgentReachable] = useState(false);
|
const [agentReachable, setAgentReachable] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
// First-mount scroll-to-bottom needs `behavior: "instant"` — long
|
||||||
|
// conversations smooth-animate for ~300ms which any concurrent
|
||||||
|
// re-render can interrupt, leaving the user stuck mid-conversation
|
||||||
|
// when the chat tab opens. Subsequent appends (new agent messages)
|
||||||
|
// keep `smooth` for the visual "landing" feel. Flipped the first
|
||||||
|
// time messages.length goes positive, so a workspace switch (which
|
||||||
|
// remounts ChatTab) gets a fresh instant jump too.
|
||||||
|
const hasInitialScrollRef = useRef(false);
|
||||||
|
// Lazy-load older history on scroll-up.
|
||||||
|
// - containerRef = the scrollable messages viewport
|
||||||
|
// - topRef = sentinel above the messages list; IO observes it
|
||||||
|
// and triggers loadOlder() when it enters view
|
||||||
|
// - hasMore = false once a fetch returns < limit rows; stops IO
|
||||||
|
// - loadingOlder = drives the "Loading older messages…" UI label
|
||||||
|
// - inflightRef = synchronous guard against double-entry of loadOlder
|
||||||
|
// when the IO callback fires twice in the same
|
||||||
|
// microtask (state-based guard would be stale until
|
||||||
|
// the next React commit)
|
||||||
|
// - scrollAnchorRef = saves distance-from-bottom before a prepend
|
||||||
|
// so the useLayoutEffect below can restore the
|
||||||
|
// user's exact viewport position. Without this,
|
||||||
|
// prepending older messages would jump the scroll
|
||||||
|
// position by the height of the new content.
|
||||||
|
// - oldestMessageRef / hasMoreRef = let the loadOlder closure read
|
||||||
|
// the latest values without taking them as deps —
|
||||||
|
// every live agent push mutates `messages`, and
|
||||||
|
// having loadOlder depend on `messages` would tear
|
||||||
|
// down + re-arm the IntersectionObserver on every
|
||||||
|
// push. Refs decouple the observer lifecycle from
|
||||||
|
// message-list updates.
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const topRef = useRef<HTMLDivElement>(null);
|
const topRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const hasInitialScrollRef = useRef(false);
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const inflightRef = useRef(false);
|
||||||
|
// The scroll anchor includes the first-message id as it was BEFORE
|
||||||
|
// the prepend — see useLayoutEffect below for why. Without this tag,
|
||||||
|
// a live agent push that appends WHILE loadOlder is in flight would
|
||||||
|
// run useLayoutEffect against the append (anchor still set), the
|
||||||
|
// "restore" math would scroll the user to a stale offset, AND the
|
||||||
|
// append's normal scroll-to-bottom would be swallowed.
|
||||||
|
const scrollAnchorRef = useRef<
|
||||||
|
{ savedDistanceFromBottom: number; expectFirstIdNotEqual: string | null } | null
|
||||||
|
>(null);
|
||||||
|
const oldestMessageRef = useRef<ChatMessage | null>(null);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
// Monotonic token bumped on workspace switch + on every loadOlder
|
||||||
|
// entry. Each fetch's .then() captures its own token; if the token
|
||||||
|
// has moved, the resolved messages belong to a stale workspace or a
|
||||||
|
// superseded fetch and we silently drop them. Without this guard, a
|
||||||
|
// workspace switch mid-fetch would have the in-flight promise
|
||||||
|
// resolve into the new workspace's setMessages — the user sees
|
||||||
|
// someone else's history briefly.
|
||||||
|
const fetchTokenRef = useRef(0);
|
||||||
|
// Files the user has picked but not yet sent. Cleared on send
|
||||||
|
// (upload success) or by the × on each pill.
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
// Guard against a double-click during the upload phase: React
|
||||||
const pasteCounterRef = useRef(0);
|
// state updates from the click that started the upload haven't
|
||||||
|
// flushed yet, so the disabled-button logic sees `uploading=false`
|
||||||
|
// from the closure and lets a second `sendMessage` enter. A ref
|
||||||
|
// observes the latest value synchronously.
|
||||||
|
const sendInFlightRef = useRef(false);
|
||||||
|
// Monotonic token bumped on every sendMessage entry. Each .then()/
|
||||||
|
// .catch() captures its own token in closure and bails if a newer
|
||||||
|
// send has superseded it — prevents a late HTTP response for an
|
||||||
|
// earlier message from clobbering the flags / appending text that
|
||||||
|
// belong to a newer in-flight send. Race scenario the token closes:
|
||||||
|
// (1) send msg #1 (2) WS push for msg #1 arrives, releases guards
|
||||||
|
// (3) user sends msg #2 (4) HTTP for msg #1 finally lands — without
|
||||||
|
// the token check, .then() sees sendingFromAPIRef=true (set by
|
||||||
|
// msg #2's send), enters the main body, and processes msg #1's body
|
||||||
|
// as if it were msg #2's reply.
|
||||||
|
const sendTokenRef = useRef(0);
|
||||||
|
|
||||||
const history = useChatHistory(workspaceId, containerRef);
|
// Release every in-flight send guard at once. Used by every site
|
||||||
const chatSend = useChatSend(workspaceId, {
|
// that ends a send: pendingAgentMsgs WS push, ACTIVITY_LOGGED
|
||||||
getHistoryMessages: () => history.messages,
|
// a2a_receive ok/error WS event, HTTP .then() success, and HTTP
|
||||||
onUserMessage: (msg) => history.setMessages((prev) => [...prev, msg]),
|
// .catch() success. Keep these in lockstep — a future contributor
|
||||||
onAgentMessage: (msg) => history.setMessages((prev) => appendMessageDeduped(prev, msg)),
|
// adding a new "I saw the reply" path that only clears `sending` +
|
||||||
});
|
// `sendingFromAPIRef` (the natural pair) silently re-introduces
|
||||||
const { sending, uploading, sendMessage, error: sendError, clearError: clearSendError, releaseSendGuards, sendingFromAPIRef } = chatSend;
|
// the post-WS Send-button freeze, because the disabled-button
|
||||||
|
// logic can't see `sendInFlightRef` and so the visible state diverges
|
||||||
|
// from the synchronous re-entry guard at line 464.
|
||||||
|
const releaseSendGuards = useCallback(() => {
|
||||||
|
setSending(false);
|
||||||
|
sendingFromAPIRef.current = false;
|
||||||
|
sendInFlightRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const displayError = error || sendError;
|
// Initial-load fetch — used by the mount effect and the "Retry"
|
||||||
|
// button below. Single source of truth so the two paths can't drift
|
||||||
|
// (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
|
||||||
|
// retry, leading to inconsistent first-paint sizes).
|
||||||
|
const loadInitial = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
setHasMore(true);
|
||||||
|
// Bump the token; any in-flight fetch from the previous workspace
|
||||||
|
// (or a previous retry) will see token != myToken in its .then()
|
||||||
|
// and silently bail — the late response can't clobber the new
|
||||||
|
// workspace's state.
|
||||||
|
fetchTokenRef.current += 1;
|
||||||
|
const myToken = fetchTokenRef.current;
|
||||||
|
loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
|
||||||
|
({ messages: msgs, error: fetchErr, reachedEnd }) => {
|
||||||
|
if (fetchTokenRef.current !== myToken) return;
|
||||||
|
setMessages(msgs);
|
||||||
|
setLoadError(fetchErr);
|
||||||
|
setHasMore(!reachedEnd);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [workspaceId]);
|
||||||
|
|
||||||
useChatSocket(workspaceId, {
|
// Load chat history on mount / workspace switch.
|
||||||
onAgentMessage: (msg) => {
|
// Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
|
||||||
history.setMessages((prev) => appendMessageDeduped(prev, msg));
|
// rest streams in as the user scrolls up via loadOlder() below. Pre-
|
||||||
if (sendingFromAPIRef.current) {
|
// 2026-05-05 this fetched the newest 50 in one shot; on a long-running
|
||||||
releaseSendGuards();
|
// workspace that meant 50× message-bubble paint + DOM cost on every
|
||||||
|
// tab-open even when the user only wanted to read the last few.
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial();
|
||||||
|
}, [loadInitial]);
|
||||||
|
|
||||||
|
// Mirror the latest oldest-message + hasMore into refs so loadOlder
|
||||||
|
// can read them without taking `messages` as a dep. Every live push
|
||||||
|
// through agentMessages would otherwise recreate loadOlder and tear
|
||||||
|
// down the IO observer.
|
||||||
|
useEffect(() => {
|
||||||
|
oldestMessageRef.current = messages[0] ?? null;
|
||||||
|
}, [messages]);
|
||||||
|
useEffect(() => {
|
||||||
|
hasMoreRef.current = hasMore;
|
||||||
|
}, [hasMore]);
|
||||||
|
|
||||||
|
// Fetch the next-older batch and prepend. Stable identity (deps =
|
||||||
|
// [workspaceId]) so the IntersectionObserver effect below doesn't
|
||||||
|
// re-arm on every messages update.
|
||||||
|
const loadOlder = useCallback(async () => {
|
||||||
|
// inflightRef is the load-bearing guard — synchronous, set BEFORE
|
||||||
|
// any await, so two IO callbacks dispatched in the same microtask
|
||||||
|
// can't both pass. The state checks are defensive secondary
|
||||||
|
// gates for the slow-scroll case.
|
||||||
|
if (inflightRef.current || !hasMoreRef.current) return;
|
||||||
|
const oldest = oldestMessageRef.current;
|
||||||
|
if (!oldest) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
inflightRef.current = true;
|
||||||
|
// Capture the user's distance-from-bottom BEFORE we prepend so the
|
||||||
|
// useLayoutEffect can restore it after the new DOM lands. The
|
||||||
|
// expectFirstIdNotEqual tag is what the layout effect checks
|
||||||
|
// against `messages[0].id` to disambiguate prepend (id changed) vs
|
||||||
|
// append (id unchanged → live message landed mid-fetch). Without
|
||||||
|
// it, an agent push during loadOlder runs the "restore" against a
|
||||||
|
// stale anchor — user gets yanked + the append's bottom-pin is
|
||||||
|
// swallowed.
|
||||||
|
scrollAnchorRef.current = {
|
||||||
|
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
|
||||||
|
expectFirstIdNotEqual: oldest.id,
|
||||||
|
};
|
||||||
|
fetchTokenRef.current += 1;
|
||||||
|
const myToken = fetchTokenRef.current;
|
||||||
|
setLoadingOlder(true);
|
||||||
|
try {
|
||||||
|
const { messages: older, reachedEnd } = await loadMessagesFromDB(
|
||||||
|
workspaceId,
|
||||||
|
OLDER_HISTORY_BATCH,
|
||||||
|
oldest.timestamp,
|
||||||
|
);
|
||||||
|
// Workspace switched (or another loadOlder bumped the token)
|
||||||
|
// mid-fetch — drop these results, they belong to a stale tab.
|
||||||
|
if (fetchTokenRef.current !== myToken) {
|
||||||
|
scrollAnchorRef.current = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
},
|
if (older.length > 0) {
|
||||||
onActivityLog: (entry) => {
|
setMessages((prev) => [...older, ...prev]);
|
||||||
if (!sending) return;
|
} else {
|
||||||
setActivityLog((prev) => appendActivityLine(prev, entry));
|
// Nothing came back — clear the anchor so the next paint doesn't
|
||||||
},
|
// try to "restore" against a no-op prepend.
|
||||||
onSendComplete: () => {
|
scrollAnchorRef.current = null;
|
||||||
if (sendingFromAPIRef.current) {
|
|
||||||
releaseSendGuards();
|
|
||||||
}
|
}
|
||||||
},
|
setHasMore(!reachedEnd);
|
||||||
onSendError: (err) => {
|
} finally {
|
||||||
if (sendingFromAPIRef.current) {
|
setLoadingOlder(false);
|
||||||
releaseSendGuards();
|
inflightRef.current = false;
|
||||||
setError(err);
|
}
|
||||||
}
|
}, [workspaceId]);
|
||||||
},
|
|
||||||
});
|
// IntersectionObserver on the top sentinel. Fires loadOlder() the
|
||||||
|
// moment the user scrolls within 200px of the top. AbortController
|
||||||
|
// unwires cleanly on workspace switch / unmount; root is the
|
||||||
|
// scrollable container so we observe only what's visible inside it.
|
||||||
|
//
|
||||||
|
// Dependencies:
|
||||||
|
// - loadOlder — stable per workspaceId (refs decouple it from
|
||||||
|
// message updates), so this dep is here for the
|
||||||
|
// workspace-switch case only
|
||||||
|
// - hasMore — re-run when older history runs out so we
|
||||||
|
// disconnect cleanly
|
||||||
|
// - hasMessages — load-bearing: the sentinel JSX is gated on
|
||||||
|
// `messages.length > 0`, so topRef.current is null
|
||||||
|
// on the empty-messages render. We re-arm exactly
|
||||||
|
// once when messages first land. NOT depending on
|
||||||
|
// `messages.length` (or `messages`) directly so
|
||||||
|
// each subsequent message append doesn't tear down
|
||||||
|
// + re-arm the observer.
|
||||||
|
const hasMessages = messages.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
const top = topRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!top || !container) return;
|
||||||
|
if (!hasMore) return; // stop observing when no older history exists
|
||||||
|
const ac = new AbortController();
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
if (entries[0]?.isIntersecting) loadOlder();
|
||||||
|
},
|
||||||
|
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
|
||||||
|
);
|
||||||
|
io.observe(top);
|
||||||
|
ac.signal.addEventListener("abort", () => io.disconnect());
|
||||||
|
return () => ac.abort();
|
||||||
|
}, [loadOlder, hasMore, hasMessages]);
|
||||||
|
|
||||||
// Agent reachability
|
// Agent reachability
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reachable = data.status === "online" || data.status === "degraded";
|
const reachable = data.status === "online" || data.status === "degraded";
|
||||||
setAgentReachable(reachable);
|
setAgentReachable(reachable);
|
||||||
if (reachable) {
|
setError(reachable ? null : `Agent is ${data.status}`);
|
||||||
setError(null);
|
}, [data.status]);
|
||||||
clearSendError();
|
|
||||||
} else {
|
useEffect(() => {
|
||||||
setError(`Agent is ${data.status}`);
|
currentTaskRef.current = data.currentTask;
|
||||||
}
|
}, [data.currentTask]);
|
||||||
}, [data.status, clearSendError]);
|
|
||||||
|
|
||||||
// Scroll behavior across messages updates:
|
// Scroll behavior across messages updates:
|
||||||
// - Prepend (loadOlder landed) → restore the user's saved
|
// - Prepend (loadOlder landed) → restore the user's saved
|
||||||
@ -180,24 +518,71 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
// paint — otherwise the user sees the page jump for one frame.
|
// paint — otherwise the user sees the page jump for one frame.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const anchor = history.scrollAnchorRef.current;
|
const anchor = scrollAnchorRef.current;
|
||||||
|
// Only honor the anchor when this messages-update is the prepend
|
||||||
|
// we expected. messages[0].id is the test:
|
||||||
|
// - prepend → messages[0] is one of the older rows → id !== expectFirstIdNotEqual
|
||||||
|
// - append → messages[0] unchanged → id === expectFirstIdNotEqual → fall through
|
||||||
|
// Without this check, an agent push that lands mid-loadOlder would
|
||||||
|
// run the restore against the append's update, yank the user's
|
||||||
|
// scroll, AND swallow the append's bottom-pin.
|
||||||
if (
|
if (
|
||||||
anchor &&
|
anchor &&
|
||||||
container &&
|
container &&
|
||||||
history.messages.length > 0 &&
|
messages.length > 0 &&
|
||||||
history.messages[0].id !== anchor.expectFirstIdNotEqual
|
messages[0].id !== anchor.expectFirstIdNotEqual
|
||||||
) {
|
) {
|
||||||
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
|
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
|
||||||
history.scrollAnchorRef.current = null;
|
scrollAnchorRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasInitialScrollRef.current && history.messages.length > 0) {
|
// Instant on first arrival of messages — smooth-scroll on a long
|
||||||
|
// conversation gets interrupted by concurrent renders and leaves
|
||||||
|
// the user stuck in the middle. After the first jump, subsequent
|
||||||
|
// appends animate as before.
|
||||||
|
if (!hasInitialScrollRef.current && messages.length > 0) {
|
||||||
hasInitialScrollRef.current = true;
|
hasInitialScrollRef.current = true;
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
|
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [history.messages, history.scrollAnchorRef]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Consume agent push messages (send_message_to_user) from global store.
|
||||||
|
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
|
||||||
|
// than the /a2a HTTP response — when that happens, the push is the
|
||||||
|
// authoritative "reply arrived" signal for the UI, so clear `sending`
|
||||||
|
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
|
||||||
|
// whichever path clears first wins.
|
||||||
|
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
||||||
|
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||||
|
const msgs = consume(workspaceId);
|
||||||
|
for (const m of msgs) {
|
||||||
|
// Dedupe in case the agent proactively pushed the same text the
|
||||||
|
// HTTP /a2a response already delivered (observed with the Hermes
|
||||||
|
// runtime, which emits both a reply body and a send_message_to_user
|
||||||
|
// push for the same content). Attachments ride along with the
|
||||||
|
// message so files returned by the A2A_RESPONSE WS path render
|
||||||
|
// their download chips.
|
||||||
|
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments)));
|
||||||
|
}
|
||||||
|
if (sendingFromAPIRef.current && msgs.length > 0) {
|
||||||
|
// Reply arrived via WS push (e.g. claude-code SDK). Release all
|
||||||
|
// three guards together — without sendInFlightRef the next
|
||||||
|
// sendMessage() silently no-ops at the synchronous re-entry
|
||||||
|
// check.
|
||||||
|
releaseSendGuards();
|
||||||
|
}
|
||||||
|
}, [pendingAgentMsgs, workspaceId]);
|
||||||
|
|
||||||
|
// Resolve workspace ID → name for activity display
|
||||||
|
const resolveWorkspaceName = useCallback((id: string) => {
|
||||||
|
const nodes = useCanvasStore.getState().nodes;
|
||||||
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Elapsed timer while sending
|
// Elapsed timer while sending
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -224,43 +609,211 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
|
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
|
||||||
}, [sending, data.runtime]);
|
}, [sending, data.runtime]);
|
||||||
|
|
||||||
// IntersectionObserver on the top sentinel. Fires loadOlder() the
|
// Subscribe to global WS via the singleton ReconnectingSocket (no
|
||||||
// moment the user scrolls within 200px of the top. AbortController
|
// per-component WebSocket — the previous pattern dropped events
|
||||||
// unwires cleanly on workspace switch / unmount; root is the
|
// silently on any reconnect because each panel's raw socket had no
|
||||||
// scrollable container so we observe only what's visible inside it.
|
// onclose handler).
|
||||||
const hasMessages = history.messages.length > 0;
|
useSocketEvent((msg) => {
|
||||||
useEffect(() => {
|
if (!sending) return;
|
||||||
const top = topRef.current;
|
try {
|
||||||
const container = containerRef.current;
|
if (msg.event === "ACTIVITY_LOGGED") {
|
||||||
if (!top || !container) return;
|
// Filter to events for THIS workspace. The platform's
|
||||||
if (!history.hasMore) return;
|
// BroadcastOnly fires to every connected client, and
|
||||||
const ac = new AbortController();
|
// without this guard a sibling workspace's a2a_send would
|
||||||
const io = new IntersectionObserver(
|
// surface as "→ Delegating to X..." inside the wrong
|
||||||
(entries) => {
|
// chat panel. (workspace_id on the WS envelope is the
|
||||||
if (ac.signal.aborted) return;
|
// workspace whose activity_log row we just wrote.)
|
||||||
if (entries[0]?.isIntersecting) history.loadOlder();
|
if (msg.workspace_id !== workspaceId) return;
|
||||||
},
|
|
||||||
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
|
|
||||||
);
|
|
||||||
io.observe(top);
|
|
||||||
ac.signal.addEventListener("abort", () => io.disconnect());
|
|
||||||
return () => ac.abort();
|
|
||||||
}, [history.loadOlder, history.hasMore, hasMessages]);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
const p = msg.payload || {};
|
||||||
|
const type = p.activity_type as string;
|
||||||
|
const method = (p.method as string) || "";
|
||||||
|
const status = (p.status as string) || "";
|
||||||
|
const targetId = (p.target_id as string) || "";
|
||||||
|
const durationMs = p.duration_ms as number | undefined;
|
||||||
|
const summary = (p.summary as string) || "";
|
||||||
|
|
||||||
|
let line = "";
|
||||||
|
if (type === "a2a_receive" && method === "message/send") {
|
||||||
|
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
|
||||||
|
if (status === "ok" && durationMs) {
|
||||||
|
const sec = Math.round(durationMs / 1000);
|
||||||
|
line = `← ${targetName} responded (${sec}s)`;
|
||||||
|
// The platform logs a successful a2a_receive once the workspace
|
||||||
|
// has fully produced its reply. That's the authoritative "done"
|
||||||
|
// signal for the spinner — clear it even if the reply hasn't
|
||||||
|
// surfaced through the store yet (it may be delivered shortly
|
||||||
|
// via pendingAgentMsgs or the HTTP .then()).
|
||||||
|
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||||
|
if (own && sendingFromAPIRef.current) {
|
||||||
|
releaseSendGuards();
|
||||||
|
}
|
||||||
|
} else if (status === "error") {
|
||||||
|
line = `⚠ ${targetName} error`;
|
||||||
|
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||||
|
if (own && sendingFromAPIRef.current) {
|
||||||
|
releaseSendGuards();
|
||||||
|
setError("Agent error (Exception) — see workspace logs for details.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === "a2a_send") {
|
||||||
|
const targetName = resolveWorkspaceName(targetId);
|
||||||
|
line = `→ Delegating to ${targetName}...`;
|
||||||
|
} else if (type === "task_update") {
|
||||||
|
if (summary) line = `⟳ ${summary}`;
|
||||||
|
} else if (type === "agent_log") {
|
||||||
|
// Per-tool-use telemetry from claude_sdk_executor's
|
||||||
|
// _report_tool_use. The summary already carries an icon
|
||||||
|
// + human-readable args (📄 Read /path, ⚡ Bash: …)
|
||||||
|
// so we render it verbatim. No icon prefix here — the
|
||||||
|
// emoji at the start of summary is the visual marker.
|
||||||
|
if (summary) line = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
setActivityLog((prev) => appendActivityLine(prev, line));
|
||||||
|
}
|
||||||
|
} else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) {
|
||||||
|
const task = (msg.payload?.current_task as string) || "";
|
||||||
|
if (task) {
|
||||||
|
setActivityLog((prev) => appendActivityLine(prev, `⟳ ${task}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A2A_RESPONSE is already consumed by the store and its text is
|
||||||
|
// appended to messages via the pendingAgentMsgs effect above; we
|
||||||
|
// don't need to duplicate it here.
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
const files = pendingFiles;
|
const filesToSend = pendingFiles;
|
||||||
if ((!text && files.length === 0) || !agentReachable || sending || uploading) return;
|
// Allow sending if EITHER text OR attachments are present — a user
|
||||||
|
// can drop a file with no text and the agent still receives it.
|
||||||
|
if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return;
|
||||||
|
// Synchronous re-entry guard — see sendInFlightRef comment.
|
||||||
|
if (sendInFlightRef.current) return;
|
||||||
|
sendInFlightRef.current = true;
|
||||||
|
|
||||||
|
// Upload attachments first so we can include URIs in the A2A
|
||||||
|
// message parts. Sequential-before-send: a message with references
|
||||||
|
// to files not yet staged would fail agent-side; staging happens
|
||||||
|
// synchronously via /chat/uploads before message/send dispatch.
|
||||||
|
let uploaded: ChatAttachment[] = [];
|
||||||
|
if (filesToSend.length > 0) {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
uploaded = await uploadChatFiles(workspaceId, filesToSend);
|
||||||
|
} catch (e) {
|
||||||
|
setUploading(false);
|
||||||
|
sendInFlightRef.current = false;
|
||||||
|
setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
setInput("");
|
setInput("");
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
clearSendError();
|
setMessages((prev) => [...prev, createMessage("user", text, uploaded)]);
|
||||||
|
setSending(true);
|
||||||
|
sendingFromAPIRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
await sendMessage(text, files);
|
// Capture this send's token so the .then()/.catch() callbacks can
|
||||||
|
// detect a newer send that may have superseded them. See the
|
||||||
|
// sendTokenRef declaration for the race scenario this closes.
|
||||||
|
const myToken = ++sendTokenRef.current;
|
||||||
|
|
||||||
|
// Build conversation history from prior messages (last 20)
|
||||||
|
const history = messages
|
||||||
|
.filter((m) => m.role === "user" || m.role === "agent")
|
||||||
|
.slice(-20)
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role === "user" ? "user" : "agent",
|
||||||
|
parts: [{ kind: "text", text: m.content }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A2A parts: text part (if any) + file parts (per attachment). The
|
||||||
|
// agent sees both in a single turn, matching the A2A spec shape.
|
||||||
|
// Wire shape is v0 — see A2APart definition above.
|
||||||
|
const parts: A2APart[] = [];
|
||||||
|
if (text) parts.push({ kind: "text", text });
|
||||||
|
for (const att of uploaded) {
|
||||||
|
parts.push({
|
||||||
|
kind: "file",
|
||||||
|
file: {
|
||||||
|
name: att.name,
|
||||||
|
mimeType: att.mimeType,
|
||||||
|
uri: att.uri,
|
||||||
|
size: att.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A2A calls can legitimately take minutes — LLM latency +
|
||||||
|
// multi-turn tool use is common on slower providers (Hermes+minimax,
|
||||||
|
// Claude Code invoking bash/file tools, etc.). The 15s default
|
||||||
|
// would silently abort the fetch here, leaving the server to
|
||||||
|
// complete the reply and the user staring at
|
||||||
|
// "agent may be unreachable". Match the upload timeout (60s × 2)
|
||||||
|
// for the happy-path ceiling; anything longer is genuinely stuck.
|
||||||
|
api.post<A2AResponse>(`/workspaces/${workspaceId}/a2a`, {
|
||||||
|
method: "message/send",
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
messageId: crypto.randomUUID(),
|
||||||
|
parts,
|
||||||
|
},
|
||||||
|
metadata: { history },
|
||||||
|
},
|
||||||
|
}, { timeoutMs: 120_000 })
|
||||||
|
.then((resp) => {
|
||||||
|
// Bail without touching any flags if a newer sendMessage has
|
||||||
|
// already run — its myToken bumped sendTokenRef, so this is
|
||||||
|
// a stale callback for an earlier message. The newer send
|
||||||
|
// owns the in-flight guards now.
|
||||||
|
if (sendTokenRef.current !== myToken) return;
|
||||||
|
// Skip if the WS A2A_RESPONSE event already handled this response.
|
||||||
|
// Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears
|
||||||
|
// it first wins, the other becomes a no-op (no duplicate messages).
|
||||||
|
if (!sendingFromAPIRef.current) {
|
||||||
|
sendInFlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const replyText = extractReplyText(resp);
|
||||||
|
const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record<string, unknown>);
|
||||||
|
if (replyText || replyFiles.length > 0) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
releaseSendGuards();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Stale-callback guard — same rationale as .then().
|
||||||
|
if (sendTokenRef.current !== myToken) return;
|
||||||
|
// Same dedup guard as .then(): if a WS path (pendingAgentMsgs
|
||||||
|
// or ACTIVITY_LOGGED a2a_receive ok) already delivered the
|
||||||
|
// reply, sendingFromAPIRef is already false and there's
|
||||||
|
// nothing to roll back. Surfacing "Failed to send" here would
|
||||||
|
// contradict the agent reply the user is currently reading —
|
||||||
|
// exactly the false-positive observed when the HTTP request
|
||||||
|
// hung up (proxy idle / 502) after WS already won.
|
||||||
|
if (!sendingFromAPIRef.current) {
|
||||||
|
sendInFlightRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseSendGuards();
|
||||||
|
setError("Failed to send message — agent may be unreachable");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFilesPicked = (fileList: FileList | null) => {
|
const onFilesPicked = (fileList: FileList | null) => {
|
||||||
if (!fileList) return;
|
if (!fileList) return;
|
||||||
const picked = Array.from(fileList);
|
const picked = Array.from(fileList);
|
||||||
|
// Deduplicate against current pending set by name+size — user
|
||||||
|
// picking the same file twice shouldn't append it.
|
||||||
setPendingFiles((prev) => {
|
setPendingFiles((prev) => {
|
||||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||||
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
||||||
@ -271,7 +824,35 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
const removePendingFile = (index: number) =>
|
const removePendingFile = (index: number) =>
|
||||||
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
|
||||||
|
// Monotonic counter so two paste events within the same wall-clock
|
||||||
|
// second still produce distinct filenames. Without this, on
|
||||||
|
// Firefox (where pasted images have an empty `file.name`), two
|
||||||
|
// pastes ~100ms apart could yield identical synthetic names AND
|
||||||
|
// identical sizes, collapsing into one attachment via the
|
||||||
|
// `name:size` dedup in onFilesPicked.
|
||||||
|
const pasteCounterRef = useRef(0);
|
||||||
|
|
||||||
|
/** Paste-from-clipboard image attachment.
|
||||||
|
*
|
||||||
|
* Browser clipboard image items arrive as `File`s whose `name` is
|
||||||
|
* often a generic "image.png" (Chrome) or empty (Firefox/Safari),
|
||||||
|
* so two consecutive screenshot pastes collide on the name+size
|
||||||
|
* dedup the file-picker uses. Re-tag each pasted image with a
|
||||||
|
* per-paste unique name so dedup keeps them apart and the upload
|
||||||
|
* pipeline (which expects a non-empty filename) is happy.
|
||||||
|
*
|
||||||
|
* Falls through to onFilesPicked via direct File[] (NOT through
|
||||||
|
* the DataTransfer constructor — that throws on Safari < 14.1
|
||||||
|
* and old Edge, silently aborting the paste).
|
||||||
|
*
|
||||||
|
* Only intercepts the paste when the clipboard has at least one
|
||||||
|
* image; text-only pastes fall through to the textarea's default
|
||||||
|
* behaviour. */
|
||||||
const mimeToExt = (mime: string): string => {
|
const mimeToExt = (mime: string): string => {
|
||||||
|
// Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`,
|
||||||
|
// `"jpeg"`, `"webp"` etc. which produce ugly filenames and may
|
||||||
|
// trip server-side extension allowlists. Map known types
|
||||||
|
// explicitly; unknown falls back to a safe default.
|
||||||
if (mime === "image/svg+xml") return "svg";
|
if (mime === "image/svg+xml") return "svg";
|
||||||
if (mime === "image/jpeg") return "jpg";
|
if (mime === "image/jpeg") return "jpg";
|
||||||
if (mime === "image/png") return "png";
|
if (mime === "image/png") return "png";
|
||||||
@ -292,16 +873,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) continue;
|
if (!file) continue;
|
||||||
const ext = mimeToExt(file.type);
|
const ext = mimeToExt(file.type);
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
const stamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, "-")
|
||||||
|
.slice(0, 19);
|
||||||
const seq = pasteCounterRef.current++;
|
const seq = pasteCounterRef.current++;
|
||||||
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
|
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
|
||||||
imageFiles.push(new File([file], fname, { type: file.type }));
|
imageFiles.push(new File([file], fname, { type: file.type }));
|
||||||
}
|
}
|
||||||
if (imageFiles.length === 0) return;
|
if (imageFiles.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Reuse the picker path so file-size guards, dedup, and pending-
|
||||||
|
// list state all run through the same code. Build a synthetic
|
||||||
|
// FileList-like object to avoid the DataTransfer constructor —
|
||||||
|
// that's missing on Safari < 14.1 / old Edge and would silently
|
||||||
|
// throw, leaving the paste a no-op.
|
||||||
addPastedFiles(imageFiles);
|
addPastedFiles(imageFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Variant of onFilesPicked that accepts a File[] directly, sidestepping
|
||||||
|
// the DataTransfer-FileList round-trip. Same dedup + state shape.
|
||||||
const addPastedFiles = (files: File[]) => {
|
const addPastedFiles = (files: File[]) => {
|
||||||
setPendingFiles((prev) => {
|
setPendingFiles((prev) => {
|
||||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||||
@ -309,6 +900,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag-and-drop staging. dragDepthRef counts enter vs leave events so
|
||||||
|
// the overlay doesn't flicker when the cursor crosses nested children
|
||||||
|
// (textarea, buttons) — dragenter/dragleave fire for every boundary.
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const dragDepthRef = useRef(0);
|
||||||
const dropEnabled = agentReachable && !sending && !uploading;
|
const dropEnabled = agentReachable && !sending && !uploading;
|
||||||
const isFileDrag = (e: React.DragEvent) =>
|
const isFileDrag = (e: React.DragEvent) =>
|
||||||
Array.from(e.dataTransfer.types || []).includes("Files");
|
Array.from(e.dataTransfer.types || []).includes("Files");
|
||||||
@ -338,6 +934,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadAttachment = (att: ChatAttachment) => {
|
const downloadAttachment = (att: ChatAttachment) => {
|
||||||
|
// Errors here are rare but user-visible (401 on a revoked token,
|
||||||
|
// 404 if the agent deleted the file). Surface via the inline
|
||||||
|
// error banner — the message list itself stays untouched.
|
||||||
downloadChatFile(workspaceId, att).catch((e) => {
|
downloadChatFile(workspaceId, att).catch((e) => {
|
||||||
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
|
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
|
||||||
});
|
});
|
||||||
@ -363,54 +962,28 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* talk_to_user disabled banner — shown when the workspace has
|
|
||||||
talk_to_user_enabled=false. The agent cannot send canvas messages;
|
|
||||||
the user can re-enable the ability from here without opening settings. */}
|
|
||||||
{data.talkToUserEnabled === false && (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-sunken border-b border-line/40 shrink-0">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 text-ink-mid">
|
|
||||||
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM8 4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4A.75.75 0 0 1 8 4Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] text-ink-mid flex-1">
|
|
||||||
Agent is not enabled to chat with you.
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/workspaces/${workspaceId}/abilities`, { talk_to_user_enabled: true });
|
|
||||||
useCanvasStore.getState().updateNodeData(workspaceId, { talkToUserEnabled: true });
|
|
||||||
} catch {
|
|
||||||
// ignore — user will see no change and can retry
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
Enable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||||
{history.loading && (
|
{loading && (
|
||||||
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
|
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
|
||||||
)}
|
)}
|
||||||
{!history.loading && history.loadError !== null && history.messages.length === 0 && (
|
{!loading && loadError !== null && messages.length === 0 && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
|
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<p className="text-[11px] text-bad mb-1.5">
|
<p className="text-[11px] text-bad mb-1.5">
|
||||||
Failed to load chat history: {history.loadError}
|
Failed to load chat history: {loadError}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={history.loadInitial}
|
onClick={loadInitial}
|
||||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
|
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!history.loading && history.loadError === null && history.messages.length === 0 && (
|
{!loading && loadError === null && messages.length === 0 && (
|
||||||
<div className="text-xs text-ink-mid text-center py-8">
|
<div className="text-xs text-ink-mid text-center py-8">
|
||||||
No messages yet. Send a message to start chatting with this agent.
|
No messages yet. Send a message to start chatting with this agent.
|
||||||
</div>
|
</div>
|
||||||
@ -428,12 +1001,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
instead of showing a "no more messages" footer — the user's
|
instead of showing a "no more messages" footer — the user's
|
||||||
scroll resting against the top of the conversation IS the
|
scroll resting against the top of the conversation IS the
|
||||||
signal. */}
|
signal. */}
|
||||||
{history.hasMore && history.messages.length > 0 && (
|
{hasMore && messages.length > 0 && (
|
||||||
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
|
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
|
||||||
{history.loadingOlder ? "Loading older messages…" : " "}
|
{loadingOlder ? "Loading older messages…" : " "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{history.messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||||
@ -593,10 +1166,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{displayError && (
|
{error && (
|
||||||
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
|
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] text-red-300">{displayError}</span>
|
<span className="text-[10px] text-red-300">{error}</span>
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmRestart(true)}
|
onClick={() => setConfirmRestart(true)}
|
||||||
@ -664,7 +1237,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
e.keyCode !== 229
|
e.keyCode !== 229
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
sendMessage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={onPasteIntoComposer}
|
onPaste={onPasteIntoComposer}
|
||||||
@ -674,7 +1247,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
|||||||
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
|
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
onClick={sendMessage}
|
||||||
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
|
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
|
||||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
|
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
|||||||
// exactly the point of the platform adaptor. The deep `~/.hermes/
|
// exactly the point of the platform adaptor. The deep `~/.hermes/
|
||||||
// config.yaml` on the container is a separate runtime-internal file,
|
// config.yaml` on the container is a separate runtime-internal file,
|
||||||
// not this one.
|
// not this one.
|
||||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
|
||||||
|
|
||||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||||
|
|||||||
@ -194,7 +194,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { resetForm(); setShowForm(true); }}
|
onClick={() => { resetForm(); setShowForm(true); }}
|
||||||
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
|
||||||
>
|
>
|
||||||
+ Add Schedule
|
+ Add Schedule
|
||||||
</button>
|
</button>
|
||||||
@ -339,7 +339,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
? "Last run OK — click to disable"
|
? "Last run OK — click to disable"
|
||||||
: "Never run — click to enable"
|
: "Never run — click to enable"
|
||||||
}
|
}
|
||||||
className={`w-2 h-2 rounded-full flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900 ${
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||||
sched.last_status === "error"
|
sched.last_status === "error"
|
||||||
? "bg-red-400"
|
? "bg-red-400"
|
||||||
: sched.last_status === "ok"
|
: sched.last_status === "ok"
|
||||||
@ -367,7 +367,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
<span>Runs: {sched.run_count}</span>
|
<span>Runs: {sched.run_count}</span>
|
||||||
</div>
|
</div>
|
||||||
{sched.last_error && (
|
{sched.last_error && (
|
||||||
<div className="text-[8px] text-bad mt-0.5 truncate">
|
<div className="text-[8px] text-bad/70 mt-0.5 truncate">
|
||||||
Error: {sched.last_error}
|
Error: {sched.last_error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -376,7 +376,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleRunNow(sched)}
|
onClick={() => handleRunNow(sched)}
|
||||||
aria-label={`Run schedule ${sched.name} now`}
|
aria-label={`Run schedule ${sched.name} now`}
|
||||||
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
|
||||||
title="Run now"
|
title="Run now"
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
@ -384,7 +384,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(sched)}
|
onClick={() => handleEdit(sched)}
|
||||||
aria-label={`Edit schedule ${sched.name}`}
|
aria-label={`Edit schedule ${sched.name}`}
|
||||||
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
@ -392,7 +392,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
||||||
aria-label={`Delete schedule ${sched.name}`}
|
aria-label={`Delete schedule ${sched.name}`}
|
||||||
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@ -492,7 +492,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
|||||||
<div className="text-[10px] text-bad font-semibold mb-0.5">
|
<div className="text-[10px] text-bad font-semibold mb-0.5">
|
||||||
Couldn't load the plugin registry
|
Couldn't load the plugin registry
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-bad">{registryError}</div>
|
<div className="text-[10px] text-bad/80">{registryError}</div>
|
||||||
<div className="mt-1 text-[10px] text-ink-mid">
|
<div className="mt-1 text-[10px] text-ink-mid">
|
||||||
Check the platform server is reachable at /plugins. The Retry button is in the header above.
|
Check the platform server is reachable at /plugins. The Retry button is in the header above.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
export { useChatHistory } from "./useChatHistory";
|
|
||||||
export { useChatSend } from "./useChatSend";
|
|
||||||
export { useChatSocket } from "./useChatSocket";
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
|
||||||
|
|
||||||
/** Resolve a workspace ID to its human-readable name.
|
|
||||||
* Falls back to the first 8 chars of the ID. */
|
|
||||||
export function resolveWorkspaceName(id: string): string {
|
|
||||||
const nodes = useCanvasStore.getState().nodes;
|
|
||||||
const node = nodes.find((n) => n.id === id);
|
|
||||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
|
|
||||||
|
|
||||||
const INITIAL_HISTORY_LIMIT = 10;
|
|
||||||
const OLDER_HISTORY_BATCH = 20;
|
|
||||||
|
|
||||||
async function loadMessagesFromDB(
|
|
||||||
workspaceId: string,
|
|
||||||
limit: number,
|
|
||||||
beforeTs?: string,
|
|
||||||
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ limit: String(limit) });
|
|
||||||
if (beforeTs) params.set("before_ts", beforeTs);
|
|
||||||
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
|
|
||||||
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
messages: resp.messages ?? [],
|
|
||||||
error: null,
|
|
||||||
reachedEnd: resp.reached_end,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
messages: [],
|
|
||||||
error: err instanceof Error ? err.message : "Failed to load chat history",
|
|
||||||
reachedEnd: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScrollAnchor {
|
|
||||||
savedDistanceFromBottom: number;
|
|
||||||
expectFirstIdNotEqual: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatHistory(
|
|
||||||
workspaceId: string,
|
|
||||||
containerRef?: React.RefObject<HTMLDivElement | null>,
|
|
||||||
) {
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
|
|
||||||
const fetchTokenRef = useRef(0);
|
|
||||||
const oldestMessageRef = useRef<ChatMessage | null>(null);
|
|
||||||
const hasMoreRef = useRef(true);
|
|
||||||
const inflightRef = useRef(false);
|
|
||||||
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
oldestMessageRef.current = messages[0] ?? null;
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
hasMoreRef.current = hasMore;
|
|
||||||
}, [hasMore]);
|
|
||||||
|
|
||||||
const loadInitial = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
setLoadError(null);
|
|
||||||
setHasMore(true);
|
|
||||||
fetchTokenRef.current += 1;
|
|
||||||
const myToken = fetchTokenRef.current;
|
|
||||||
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
|
|
||||||
({ messages: msgs, error: fetchErr, reachedEnd }) => {
|
|
||||||
if (fetchTokenRef.current !== myToken) return;
|
|
||||||
setMessages(msgs);
|
|
||||||
setLoadError(fetchErr);
|
|
||||||
setHasMore(!reachedEnd);
|
|
||||||
setLoading(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadInitial();
|
|
||||||
}, [loadInitial]);
|
|
||||||
|
|
||||||
const loadOlder = useCallback(async () => {
|
|
||||||
if (inflightRef.current || !hasMoreRef.current) return;
|
|
||||||
const oldest = oldestMessageRef.current;
|
|
||||||
if (!oldest) return;
|
|
||||||
const container = containerRef?.current;
|
|
||||||
if (!container) return;
|
|
||||||
inflightRef.current = true;
|
|
||||||
scrollAnchorRef.current = {
|
|
||||||
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
|
|
||||||
expectFirstIdNotEqual: oldest.id,
|
|
||||||
};
|
|
||||||
fetchTokenRef.current += 1;
|
|
||||||
const myToken = fetchTokenRef.current;
|
|
||||||
setLoadingOlder(true);
|
|
||||||
try {
|
|
||||||
const { messages: older, reachedEnd } = await loadMessagesFromDB(
|
|
||||||
workspaceId,
|
|
||||||
OLDER_HISTORY_BATCH,
|
|
||||||
oldest.timestamp,
|
|
||||||
);
|
|
||||||
if (fetchTokenRef.current !== myToken) {
|
|
||||||
scrollAnchorRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (older.length > 0) {
|
|
||||||
setMessages((prev) => [...older, ...prev]);
|
|
||||||
} else {
|
|
||||||
scrollAnchorRef.current = null;
|
|
||||||
}
|
|
||||||
setHasMore(!reachedEnd);
|
|
||||||
} finally {
|
|
||||||
setLoadingOlder(false);
|
|
||||||
inflightRef.current = false;
|
|
||||||
}
|
|
||||||
}, [workspaceId, containerRef]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
loading,
|
|
||||||
loadError,
|
|
||||||
loadingOlder,
|
|
||||||
hasMore,
|
|
||||||
loadInitial,
|
|
||||||
loadOlder,
|
|
||||||
appendMessageDeduped: (msg: ChatMessage) =>
|
|
||||||
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
|
|
||||||
setMessages,
|
|
||||||
scrollAnchorRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { uploadChatFiles } from "../uploads";
|
|
||||||
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
|
|
||||||
import { extractFilesFromTask } from "../message-parser";
|
|
||||||
|
|
||||||
interface A2APart {
|
|
||||||
kind: string;
|
|
||||||
text?: string;
|
|
||||||
file?: {
|
|
||||||
name?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
uri?: string;
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface A2AResponse {
|
|
||||||
result?: {
|
|
||||||
parts?: A2APart[];
|
|
||||||
artifacts?: Array<{ parts: A2APart[] }>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractReplyText(resp: A2AResponse): string {
|
|
||||||
const collect = (parts: A2APart[] | undefined): string => {
|
|
||||||
if (!parts) return "";
|
|
||||||
return parts
|
|
||||||
.filter((p) => p.kind === "text")
|
|
||||||
.map((p) => p.text ?? "")
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
const result = resp?.result;
|
|
||||||
const collected: string[] = [];
|
|
||||||
const fromParts = collect(result?.parts);
|
|
||||||
if (fromParts) collected.push(fromParts);
|
|
||||||
if (result?.artifacts) {
|
|
||||||
for (const a of result.artifacts) {
|
|
||||||
const t = collect(a.parts);
|
|
||||||
if (t) collected.push(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return collected.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatSendOptions {
|
|
||||||
getHistoryMessages: () => ChatMessage[];
|
|
||||||
onUserMessage?: (msg: ChatMessage) => void;
|
|
||||||
onAgentMessage?: (msg: ChatMessage) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const sendInFlightRef = useRef(false);
|
|
||||||
const sendingFromAPIRef = useRef(false);
|
|
||||||
const sendTokenRef = useRef(0);
|
|
||||||
const optionsRef = useRef(options);
|
|
||||||
optionsRef.current = options;
|
|
||||||
|
|
||||||
const releaseSendGuards = useCallback(() => {
|
|
||||||
setSending(false);
|
|
||||||
sendingFromAPIRef.current = false;
|
|
||||||
sendInFlightRef.current = false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => setError(null), []);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
|
||||||
async (text: string, files: File[] = []) => {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if ((!trimmed && files.length === 0) || sending || uploading) return;
|
|
||||||
if (sendInFlightRef.current) return;
|
|
||||||
sendInFlightRef.current = true;
|
|
||||||
|
|
||||||
let uploaded: ChatAttachment[] = [];
|
|
||||||
if (files.length > 0) {
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
uploaded = await uploadChatFiles(workspaceId, files);
|
|
||||||
} catch (e) {
|
|
||||||
setUploading(false);
|
|
||||||
sendInFlightRef.current = false;
|
|
||||||
setError(
|
|
||||||
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMsg = createMessage("user", trimmed, uploaded);
|
|
||||||
optionsRef.current.onUserMessage?.(userMsg);
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
sendingFromAPIRef.current = true;
|
|
||||||
setError(null);
|
|
||||||
const myToken = ++sendTokenRef.current;
|
|
||||||
|
|
||||||
const history = optionsRef.current
|
|
||||||
.getHistoryMessages()
|
|
||||||
.filter((m) => m.role === "user" || m.role === "agent")
|
|
||||||
.slice(-20)
|
|
||||||
.map((m) => ({
|
|
||||||
role: m.role === "user" ? "user" : "agent",
|
|
||||||
parts: [{ kind: "text", text: m.content }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const parts: A2APart[] = [];
|
|
||||||
if (trimmed) parts.push({ kind: "text", text: trimmed });
|
|
||||||
for (const att of uploaded) {
|
|
||||||
parts.push({
|
|
||||||
kind: "file",
|
|
||||||
file: {
|
|
||||||
name: att.name,
|
|
||||||
mimeType: att.mimeType,
|
|
||||||
uri: att.uri,
|
|
||||||
size: att.size,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.post<A2AResponse>(
|
|
||||||
`/workspaces/${workspaceId}/a2a`,
|
|
||||||
{
|
|
||||||
method: "message/send",
|
|
||||||
params: {
|
|
||||||
message: {
|
|
||||||
role: "user",
|
|
||||||
messageId: crypto.randomUUID(),
|
|
||||||
parts,
|
|
||||||
},
|
|
||||||
metadata: { history },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 120_000 },
|
|
||||||
)
|
|
||||||
.then((resp) => {
|
|
||||||
if (sendTokenRef.current !== myToken) return;
|
|
||||||
if (!sendingFromAPIRef.current) {
|
|
||||||
sendInFlightRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const replyText = extractReplyText(resp);
|
|
||||||
const replyFiles = extractFilesFromTask(
|
|
||||||
(resp?.result ?? {}) as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
if (replyText || replyFiles.length > 0) {
|
|
||||||
optionsRef.current.onAgentMessage?.(
|
|
||||||
createMessage("agent", replyText, replyFiles),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
releaseSendGuards();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (sendTokenRef.current !== myToken) return;
|
|
||||||
if (!sendingFromAPIRef.current) {
|
|
||||||
sendInFlightRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
releaseSendGuards();
|
|
||||||
setError("Failed to send message — agent may be unreachable");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[workspaceId, sending, uploading],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sending,
|
|
||||||
uploading,
|
|
||||||
sendMessage,
|
|
||||||
error,
|
|
||||||
clearError,
|
|
||||||
releaseSendGuards,
|
|
||||||
sendingFromAPIRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
|
||||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
|
||||||
import { createMessage, type ChatMessage } from "../types";
|
|
||||||
|
|
||||||
export interface UseChatSocketCallbacks {
|
|
||||||
onAgentMessage?: (msg: ChatMessage) => void;
|
|
||||||
onActivityLog?: (entry: string) => void;
|
|
||||||
onSendComplete?: () => void;
|
|
||||||
onSendError?: (error: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatSocket(
|
|
||||||
workspaceId: string,
|
|
||||||
callbacks: UseChatSocketCallbacks,
|
|
||||||
): void {
|
|
||||||
const callbacksRef = useRef(callbacks);
|
|
||||||
callbacksRef.current = callbacks;
|
|
||||||
|
|
||||||
// Agent push messages from global store
|
|
||||||
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
|
|
||||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
|
||||||
const msgs = consume(workspaceId);
|
|
||||||
for (const m of msgs) {
|
|
||||||
callbacksRef.current.onAgentMessage?.(
|
|
||||||
createMessage("agent", m.content, m.attachments),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (msgs.length > 0) {
|
|
||||||
callbacksRef.current.onSendComplete?.();
|
|
||||||
}
|
|
||||||
}, [pendingAgentMsgs, workspaceId]);
|
|
||||||
|
|
||||||
const resolveWorkspaceName = useCallback((id: string) => {
|
|
||||||
const nodes = useCanvasStore.getState().nodes;
|
|
||||||
const node = nodes.find((n) => n.id === id);
|
|
||||||
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSocketEvent((msg) => {
|
|
||||||
try {
|
|
||||||
if (msg.event === "ACTIVITY_LOGGED") {
|
|
||||||
if (msg.workspace_id !== workspaceId) return;
|
|
||||||
|
|
||||||
const p = msg.payload || {};
|
|
||||||
const type = p.activity_type as string;
|
|
||||||
const method = (p.method as string) || "";
|
|
||||||
const status = (p.status as string) || "";
|
|
||||||
const targetId = (p.target_id as string) || "";
|
|
||||||
const durationMs = p.duration_ms as number | undefined;
|
|
||||||
const summary = (p.summary as string) || "";
|
|
||||||
|
|
||||||
let line = "";
|
|
||||||
if (type === "a2a_receive" && method === "message/send") {
|
|
||||||
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
|
|
||||||
if (status === "ok" && durationMs) {
|
|
||||||
const sec = Math.round(durationMs / 1000);
|
|
||||||
line = `← ${targetName} responded (${sec}s)`;
|
|
||||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
|
||||||
if (own) callbacksRef.current.onSendComplete?.();
|
|
||||||
} else if (status === "error") {
|
|
||||||
line = `⚠ ${targetName} error`;
|
|
||||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
|
||||||
if (own) {
|
|
||||||
callbacksRef.current.onSendComplete?.();
|
|
||||||
callbacksRef.current.onSendError?.(
|
|
||||||
"Agent error (Exception) — see workspace logs for details.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === "a2a_send") {
|
|
||||||
const targetName = resolveWorkspaceName(targetId);
|
|
||||||
line = `→ Delegating to ${targetName}...`;
|
|
||||||
} else if (type === "task_update") {
|
|
||||||
if (summary) line = `⟳ ${summary}`;
|
|
||||||
} else if (type === "agent_log") {
|
|
||||||
if (summary) line = summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line) {
|
|
||||||
callbacksRef.current.onActivityLog?.(line);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
msg.event === "TASK_UPDATED" &&
|
|
||||||
msg.workspace_id === workspaceId
|
|
||||||
) {
|
|
||||||
const task = (msg.payload?.current_task as string) || "";
|
|
||||||
if (task) {
|
|
||||||
callbacksRef.current.onActivityLog?.(`⟳ ${task}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,2 @@
|
|||||||
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
|
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
|
||||||
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
|
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
|
||||||
export { useChatHistory } from "./hooks/useChatHistory";
|
|
||||||
export { useChatSend } from "./hooks/useChatSend";
|
|
||||||
export { useChatSocket } from "./hooks/useChatSocket";
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
type PreflightResult,
|
type PreflightResult,
|
||||||
type Template,
|
type Template,
|
||||||
} from "@/lib/deploy-preflight";
|
} from "@/lib/deploy-preflight";
|
||||||
import { isSaaSTenant } from "@/lib/tenant";
|
|
||||||
import { MissingKeysModal } from "@/components/MissingKeysModal";
|
import { MissingKeysModal } from "@/components/MissingKeysModal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -106,7 +105,7 @@ export function useTemplateDeploy(
|
|||||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||||
name: template.name,
|
name: template.name,
|
||||||
template: template.id,
|
template: template.id,
|
||||||
tier: isSaaSTenant() ? 4 : template.tier,
|
tier: template.tier,
|
||||||
canvas: coords,
|
canvas: coords,
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,205 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
"use client";
|
|
||||||
/**
|
|
||||||
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
|
|
||||||
*
|
|
||||||
* Test coverage (9 cases):
|
|
||||||
* 1. MobileAccentProvider renders children
|
|
||||||
* 2. usePalette(false) without provider → MOL_LIGHT
|
|
||||||
* 3. usePalette(true) without provider → MOL_DARK
|
|
||||||
* 4. accent=null returns base palette unchanged
|
|
||||||
* 5. accent=base.accent returns base palette unchanged (identity guard)
|
|
||||||
* 6. accent="#custom" overrides both accent and online
|
|
||||||
* 7. MOL_LIGHT singleton never mutated
|
|
||||||
* 8. MOL_DARK singleton never mutated
|
|
||||||
*
|
|
||||||
* Plus pure-function coverage for normalizeStatus + tierCode.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import React from "react";
|
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
|
||||||
import {
|
|
||||||
MOL_LIGHT,
|
|
||||||
MOL_DARK,
|
|
||||||
getPalette,
|
|
||||||
normalizeStatus,
|
|
||||||
tierCode,
|
|
||||||
MobileAccentProvider,
|
|
||||||
usePalette,
|
|
||||||
} from "../palette-context";
|
|
||||||
|
|
||||||
// ─── usePalette test helper ───────────────────────────────────────────────────
|
|
||||||
// usePalette reads document.documentElement.dataset.theme internally.
|
|
||||||
// We set this before rendering so the hook sees the right value.
|
|
||||||
|
|
||||||
function setDataTheme(theme: "light" | "dark") {
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Pure function tests ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("normalizeStatus", () => {
|
|
||||||
it("returns emerald-400 for online status", () => {
|
|
||||||
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
|
|
||||||
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns emerald-400 for degraded status", () => {
|
|
||||||
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
|
|
||||||
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns red-400 for failed status", () => {
|
|
||||||
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
|
|
||||||
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns amber-400 for paused status", () => {
|
|
||||||
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
|
|
||||||
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns amber-400 for not_configured status", () => {
|
|
||||||
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns zinc-400 for unknown status", () => {
|
|
||||||
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
|
|
||||||
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tierCode", () => {
|
|
||||||
it("returns T1 for tier 1", () => {
|
|
||||||
expect(tierCode(1)).toBe("T1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns T2 for tier 2", () => {
|
|
||||||
expect(tierCode(2)).toBe("T2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns T4 for tier 4", () => {
|
|
||||||
expect(tierCode(4)).toBe("T4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns generic T{n} for non-standard tiers", () => {
|
|
||||||
expect(tierCode(99)).toBe("T99");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── getPalette tests ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("getPalette — accent override", () => {
|
|
||||||
it("accent=null returns base palette unchanged (light)", () => {
|
|
||||||
const result = getPalette(null, false);
|
|
||||||
expect(result).toEqual({ ...MOL_LIGHT });
|
|
||||||
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accent=null returns base palette unchanged (dark)", () => {
|
|
||||||
const result = getPalette(null, true);
|
|
||||||
expect(result).toEqual({ ...MOL_DARK });
|
|
||||||
expect(result).not.toBe(MOL_DARK);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
|
|
||||||
const result = getPalette(MOL_LIGHT.accent, false);
|
|
||||||
expect(result).toEqual({ ...MOL_LIGHT });
|
|
||||||
expect(result).not.toBe(MOL_LIGHT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
|
|
||||||
const result = getPalette(MOL_DARK.accent, true);
|
|
||||||
expect(result).toEqual({ ...MOL_DARK });
|
|
||||||
expect(result).not.toBe(MOL_DARK);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accent='#custom' overrides accent and online (light)", () => {
|
|
||||||
const result = getPalette("#ff0000", false);
|
|
||||||
expect(result.accent).toBe("#ff0000");
|
|
||||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accent='#custom' overrides accent and online (dark)", () => {
|
|
||||||
const result = getPalette("#00ff00", true);
|
|
||||||
expect(result.accent).toBe("#00ff00");
|
|
||||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("MOL_LIGHT singleton is never mutated", () => {
|
|
||||||
getPalette("#mutate", false);
|
|
||||||
// All fields must still match the original freeze definition
|
|
||||||
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
|
|
||||||
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
|
|
||||||
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
|
|
||||||
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
|
|
||||||
expect(MOL_LIGHT.line).toBe("border-zinc-700");
|
|
||||||
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("MOL_DARK singleton is never mutated", () => {
|
|
||||||
getPalette("#mutate", true);
|
|
||||||
expect(MOL_DARK.accent).toBe("bg-sky-400");
|
|
||||||
expect(MOL_DARK.online).toBe("bg-emerald-400");
|
|
||||||
expect(MOL_DARK.surface).toBe("bg-zinc-800");
|
|
||||||
expect(MOL_DARK.ink).toBe("text-zinc-100");
|
|
||||||
expect(MOL_DARK.line).toBe("border-zinc-700");
|
|
||||||
expect(MOL_DARK.bg).toBe("bg-zinc-950");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPalette always returns a new object (no shared mutation risk)", () => {
|
|
||||||
const a = getPalette("#a", false);
|
|
||||||
const b = getPalette("#b", false);
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
expect(a.accent).not.toBe(b.accent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("MobileAccentProvider", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setDataTheme("light");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.documentElement.dataset.theme = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders children", () => {
|
|
||||||
render(
|
|
||||||
<MobileAccentProvider accent={null}>
|
|
||||||
<span data-testid="child">Hello</span>
|
|
||||||
</MobileAccentProvider>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("child")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// usePalette hook reads data-theme from <html> to determine light/dark.
|
|
||||||
// In the test environment, data-theme is empty, which falls through to
|
|
||||||
// the "light" default in usePalette, giving MOL_LIGHT.
|
|
||||||
it("usePalette(false) without provider → MOL_LIGHT", () => {
|
|
||||||
setDataTheme("light");
|
|
||||||
function ShowPalette() {
|
|
||||||
const p = usePalette(false);
|
|
||||||
return <span data-testid="accent-light">{p.accent}</span>;
|
|
||||||
}
|
|
||||||
render(<ShowPalette />);
|
|
||||||
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
|
|
||||||
setDataTheme("dark");
|
|
||||||
function ShowPalette() {
|
|
||||||
const p = usePalette(true);
|
|
||||||
return <span data-testid="accent-dark">{p.accent}</span>;
|
|
||||||
}
|
|
||||||
render(<ShowPalette />);
|
|
||||||
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -8,18 +8,14 @@ import { getTenantSlug } from "./tenant";
|
|||||||
export const PLATFORM_URL =
|
export const PLATFORM_URL =
|
||||||
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
// 35s is long enough for the slowest server-side path (EIC SSH
|
// 15s is long enough for slow CP queries but short enough that a
|
||||||
// tunnel for tenant EC2 file operations, bounded server-side by
|
// hung backend doesn't leave the UI spinning forever. The abort
|
||||||
// `eicFileOpTimeout = 30 * time.Second` in
|
// propagates through AbortController so React components can observe
|
||||||
// workspace-server/internal/handlers/template_files_eic.go) so the
|
// the error and render a retry affordance. Callers that know the
|
||||||
// canvas surfaces the server's real error instead of aborting first
|
// endpoint is intentionally slow (org import walks a tree of
|
||||||
// with a generic timeout. Shorter values caused "Save & Restart" to
|
// workspaces with server-side pacing) can pass `timeoutMs` to
|
||||||
// time out at the client before the backend returned its 5xx. The
|
// override.
|
||||||
// abort still propagates through AbortController so React components
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
// can render a retry affordance. Callers that know an endpoint is
|
|
||||||
// intentionally slow (org import walks a tree of workspaces with
|
|
||||||
// server-side pacing) can pass `timeoutMs` to override.
|
|
||||||
const DEFAULT_TIMEOUT_MS = 35_000;
|
|
||||||
|
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
|||||||
@ -21,8 +21,8 @@ export function statusDotClass(status: string): string {
|
|||||||
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
|
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
|
||||||
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
|
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
|
||||||
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
|
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
|
||||||
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-white border-violet-500" },
|
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
|
||||||
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-white border-warm" },
|
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COMM_TYPE_LABELS: Record<string, string> = {
|
export const COMM_TYPE_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* palette-context.tsx
|
|
||||||
*
|
|
||||||
* Mobile canvas accent palette system.
|
|
||||||
*
|
|
||||||
* - MOL_LIGHT / MOL_DARK — immutable base singletons
|
|
||||||
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
|
|
||||||
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
|
|
||||||
* - tierCode(tier) — maps tier number → display label
|
|
||||||
* - MobileAccentProvider — React context that propagates accent override
|
|
||||||
* - usePalette(allowAccentOverride) — hook; returns the effective palette
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Palette {
|
|
||||||
/** Accent colour (CSS colour string). */
|
|
||||||
accent: string;
|
|
||||||
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
|
|
||||||
online: string;
|
|
||||||
/** Surface background colour class. */
|
|
||||||
surface: string;
|
|
||||||
/** Primary text colour class. */
|
|
||||||
ink: string;
|
|
||||||
/** Border/divider colour class. */
|
|
||||||
line: string;
|
|
||||||
/** Background colour class. */
|
|
||||||
bg: string;
|
|
||||||
/** Tier display code, e.g. "T1". */
|
|
||||||
tier: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Singleton base palettes ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Light-mode base palette — must never be mutated. */
|
|
||||||
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
|
|
||||||
accent: "bg-blue-500",
|
|
||||||
online: "bg-emerald-400",
|
|
||||||
surface: "bg-zinc-900",
|
|
||||||
ink: "text-zinc-100",
|
|
||||||
line: "border-zinc-700",
|
|
||||||
bg: "bg-zinc-950",
|
|
||||||
tier: "T1",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Dark-mode base palette — must never be mutated. */
|
|
||||||
export const MOL_DARK: Readonly<Palette> = Object.freeze({
|
|
||||||
accent: "bg-sky-400",
|
|
||||||
online: "bg-emerald-400",
|
|
||||||
surface: "bg-zinc-800",
|
|
||||||
ink: "text-zinc-100",
|
|
||||||
line: "border-zinc-700",
|
|
||||||
bg: "bg-zinc-950",
|
|
||||||
tier: "T1",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps workspace status string → online dot colour class.
|
|
||||||
* Returns the appropriate green for light/dark mode.
|
|
||||||
*/
|
|
||||||
export function normalizeStatus(
|
|
||||||
status: string,
|
|
||||||
_isDark: boolean,
|
|
||||||
): string {
|
|
||||||
if (status === "online" || status === "degraded") {
|
|
||||||
return "bg-emerald-400";
|
|
||||||
}
|
|
||||||
if (status === "failed") {
|
|
||||||
return "bg-red-400";
|
|
||||||
}
|
|
||||||
if (status === "paused" || status === "not_configured") {
|
|
||||||
return "bg-amber-400";
|
|
||||||
}
|
|
||||||
return "bg-zinc-400";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps tier number → display code.
|
|
||||||
*/
|
|
||||||
export function tierCode(tier: number): string {
|
|
||||||
return `T${tier}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective palette.
|
|
||||||
*
|
|
||||||
* - `accent = null` → base palette (light or dark) unchanged
|
|
||||||
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
|
|
||||||
* - `accent = "#custom"` → copy with `accent` and `online` overridden
|
|
||||||
*
|
|
||||||
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
|
|
||||||
*/
|
|
||||||
export function getPalette(
|
|
||||||
accent: string | null,
|
|
||||||
isDark: boolean,
|
|
||||||
): Palette {
|
|
||||||
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
|
|
||||||
|
|
||||||
// null accent → use base unchanged
|
|
||||||
if (accent === null) return { ...base };
|
|
||||||
|
|
||||||
// identity guard — accent same as base accent → no override needed
|
|
||||||
if (accent === base.accent) return { ...base };
|
|
||||||
|
|
||||||
// Custom accent: override accent + online to keep them in sync
|
|
||||||
return { ...base, accent, online: normalizeStatus("online", isDark) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type MobileAccentContextValue = {
|
|
||||||
/** Override accent colour (null = no override, use default). */
|
|
||||||
accent: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MobileAccentContext = createContext<MobileAccentContextValue>({
|
|
||||||
accent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { MobileAccentContext };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders children inside the accent override context.
|
|
||||||
*/
|
|
||||||
export function MobileAccentProvider({
|
|
||||||
accent,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
accent: string | null;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<MobileAccentContext.Provider value={{ accent }}>
|
|
||||||
{children}
|
|
||||||
</MobileAccentContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective `Palette` for the current context.
|
|
||||||
*
|
|
||||||
* @param allowAccentOverride When false, always returns the base palette
|
|
||||||
* even when an override is set (useful for
|
|
||||||
* non-accent-aware child components).
|
|
||||||
*/
|
|
||||||
export function usePalette(allowAccentOverride: boolean): Palette {
|
|
||||||
const { accent } = useContext(MobileAccentContext);
|
|
||||||
|
|
||||||
// Resolved from the OS-level theme preference. In a real app this would
|
|
||||||
// be derived from useTheme().resolvedTheme; for this hook we default
|
|
||||||
// to light (the safe default for SSR / component-library use).
|
|
||||||
// We read data-theme from <html> to stay in sync with the theme system.
|
|
||||||
const isDark =
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
document.documentElement.dataset.theme === "dark";
|
|
||||||
|
|
||||||
const effectiveAccent = allowAccentOverride ? accent : null;
|
|
||||||
return getPalette(effectiveAccent, isDark);
|
|
||||||
}
|
|
||||||
@ -519,10 +519,6 @@ export function buildNodesAndEdges(
|
|||||||
// #2054 — server-declared per-workspace provisioning timeout.
|
// #2054 — server-declared per-workspace provisioning timeout.
|
||||||
// Falls through to the runtime profile when null/absent.
|
// Falls through to the runtime profile when null/absent.
|
||||||
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
|
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
|
||||||
// Workspace abilities — defaults preserved for old platform versions
|
|
||||||
// that don't yet include these columns in the GET response.
|
|
||||||
broadcastEnabled: ws.broadcast_enabled ?? false,
|
|
||||||
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (hasParent) {
|
if (hasParent) {
|
||||||
|
|||||||
@ -99,13 +99,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
|||||||
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
|
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
|
||||||
* expectation without a canvas release. */
|
* expectation without a canvas release. */
|
||||||
provisionTimeoutMs?: number | null;
|
provisionTimeoutMs?: number | null;
|
||||||
/** When true the workspace may POST /broadcast to send org-wide messages.
|
|
||||||
* Default false. Toggled by user/admin via PATCH /workspaces/:id/abilities. */
|
|
||||||
broadcastEnabled?: boolean;
|
|
||||||
/** When false the workspace cannot deliver canvas chat messages.
|
|
||||||
* send_message_to_user / POST /notify return 403 and the canvas
|
|
||||||
* shows a "not enabled" state with a button to re-enable. Default true. */
|
|
||||||
talkToUserEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||||
|
|||||||
@ -299,9 +299,6 @@ export interface WorkspaceData {
|
|||||||
* `@/lib/runtimeProfiles` when absent (the default behavior for any
|
* `@/lib/runtimeProfiles` when absent (the default behavior for any
|
||||||
* template that hasn't yet declared the field). */
|
* template that hasn't yet declared the field). */
|
||||||
provision_timeout_ms?: number | null;
|
provision_timeout_ms?: number | null;
|
||||||
/** Workspace ability flags (migration 20260514). */
|
|
||||||
broadcast_enabled?: boolean;
|
|
||||||
talk_to_user_enabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket: ReconnectingSocket | null = null;
|
let socket: ReconnectingSocket | null = null;
|
||||||
|
|||||||
@ -30,7 +30,10 @@
|
|||||||
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
|
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
|
||||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
|
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
|
||||||
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
|
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
|
||||||
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
|
{"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"},
|
||||||
|
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"},
|
||||||
|
{"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"},
|
||||||
|
{"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"}
|
||||||
],
|
],
|
||||||
"org_templates": [
|
"org_templates": [
|
||||||
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
||||||
|
|||||||
@ -1,376 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Staging E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
|
|
||||||
#
|
|
||||||
# WHY THIS EXISTS
|
|
||||||
# ---------------
|
|
||||||
# Hermes and OpenClaw were repeatedly reported "fleet-verified / cascade-
|
|
||||||
# complete" because the *proxy* signals were green:
|
|
||||||
# - registry-registration + heartbeat (Hermes), and
|
|
||||||
# - model round-trip 200 (OpenClaw).
|
|
||||||
# But a freshly-provisioned workspace, asked on canvas "can you see your
|
|
||||||
# peers", actually FAILS:
|
|
||||||
# - Hermes: 401 on the molecule MCP `list_peers` call,
|
|
||||||
# - OpenClaw: falls back to native `sessions_list`, sees no platform peers.
|
|
||||||
# Tasks #142/#159 were even marked "completed" under this same proxy flaw.
|
|
||||||
#
|
|
||||||
# This script codifies the LITERAL user-facing path so it can never silently
|
|
||||||
# regress: it provisions a brand-new throwaway org + sibling workspaces via
|
|
||||||
# the real control-plane provisioning path, then for each runtime that should
|
|
||||||
# have platform peer-visibility it drives the EXACT MCP call the canvas agent
|
|
||||||
# makes — `POST /workspaces/:id/mcp` JSON-RPC tools/call name=list_peers,
|
|
||||||
# authenticated by that workspace's own bearer token through the real
|
|
||||||
# WorkspaceAuth + MCPRateLimiter middleware chain. It then asserts:
|
|
||||||
# (1) HTTP 200,
|
|
||||||
# (2) JSON-RPC `result` present (NOT an `error` object — a -32000
|
|
||||||
# "tool call failed" or a 401 from WorkspaceAuth fails here),
|
|
||||||
# (3) the returned peer set CONTAINS the other provisioned sibling
|
|
||||||
# workspace IDs — not an empty list, not a native-sessions fallback.
|
|
||||||
#
|
|
||||||
# This is NOT a proxy. It does not look at a registry row, /health, the
|
|
||||||
# heartbeat table, or `GET /registry/:id/peers`. It drives the byte-for-byte
|
|
||||||
# JSON-RPC envelope that mcp_molecule_list_peers issues from a real agent.
|
|
||||||
#
|
|
||||||
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
|
|
||||||
# green only when the in-flight root-cause fixes (Hermes-401, OpenClaw MCP
|
|
||||||
# wiring) actually land. That is the point: it is the objective proof gate.
|
|
||||||
#
|
|
||||||
# AUTH MODEL (mirrors tests/e2e/test_staging_full_saas.sh)
|
|
||||||
# --------------------------------------------------------
|
|
||||||
# Single MOLECULE_ADMIN_TOKEN (= CP_ADMIN_API_TOKEN on Railway staging)
|
|
||||||
# drives: POST /cp/admin/orgs (provision), GET
|
|
||||||
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
|
|
||||||
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
|
|
||||||
# tenant workspace creation; each workspace's OWN auth_token (returned by
|
|
||||||
# POST /workspaces) drives its MCP call.
|
|
||||||
#
|
|
||||||
# Required env:
|
|
||||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
|
||||||
# Optional env:
|
|
||||||
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
|
|
||||||
# E2E_RUN_ID slug suffix; CI passes ${GITHUB_RUN_ID}
|
|
||||||
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
|
|
||||||
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
|
|
||||||
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
|
|
||||||
# LLM provider key injected so the runtime can boot
|
|
||||||
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
|
|
||||||
#
|
|
||||||
# Exit codes:
|
|
||||||
# 0 every runtime saw its peers via the literal MCP call
|
|
||||||
# 1 generic failure
|
|
||||||
# 2 missing required env
|
|
||||||
# 3 provisioning timed out
|
|
||||||
# 4 teardown left orphan resources
|
|
||||||
# 10 peer-visibility regression reproduced (the gate firing as designed)
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
|
||||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
|
||||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
|
||||||
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
|
|
||||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-1800}"
|
|
||||||
|
|
||||||
# Slug MUST start with 'e2e-' so the sweep-stale-e2e-orgs safety net
|
|
||||||
# (EPHEMERAL_PREFIXES) catches any leak this run fails to tear down.
|
|
||||||
SLUG="e2e-pv-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
|
||||||
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
|
|
||||||
|
|
||||||
ORG_ID=""
|
|
||||||
TENANT_URL=""
|
|
||||||
TENANT_TOKEN=""
|
|
||||||
|
|
||||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
|
||||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
|
||||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
|
||||||
|
|
||||||
admin_call() {
|
|
||||||
local method="$1" path="$2"; shift 2
|
|
||||||
curl -sS -X "$method" "$CP_URL$path" \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" "$@"
|
|
||||||
}
|
|
||||||
tenant_call() {
|
|
||||||
local method="$1" path="$2"; shift 2
|
|
||||||
curl -sS -X "$method" "$TENANT_URL$path" \
|
|
||||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
|
||||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
|
||||||
-H "Content-Type: application/json" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── Scoped teardown ───────────────────────────────────────────────────
|
|
||||||
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
|
|
||||||
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
|
|
||||||
# sweep — honors feedback_cleanup_after_each_test and
|
|
||||||
# feedback_never_run_cluster_cleanup_tests_on_live_platform. The
|
|
||||||
# workflow's always() step + sweep-stale-e2e-orgs are the outer nets.
|
|
||||||
teardown() {
|
|
||||||
local rc=$?
|
|
||||||
set +e
|
|
||||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
|
||||||
echo ""
|
|
||||||
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
|
|
||||||
exit $rc
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
|
|
||||||
admin_call DELETE "/cp/admin/tenants/$SLUG" --max-time 120 \
|
|
||||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
|
|
||||||
for j in $(seq 1 24); do
|
|
||||||
LIST=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null)
|
|
||||||
LEAK=$(echo "$LIST" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
try: d = json.load(sys.stdin)
|
|
||||||
except Exception: print(1); sys.exit(0)
|
|
||||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
|
||||||
print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status') not in ('purged',) and o.get('status') != 'purged'))
|
|
||||||
" 2>/dev/null || echo 1)
|
|
||||||
if [ "$LEAK" = "0" ]; then
|
|
||||||
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
|
|
||||||
exit $rc
|
|
||||||
fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
|
|
||||||
[ $rc -eq 0 ] && rc=4
|
|
||||||
exit $rc
|
|
||||||
}
|
|
||||||
trap teardown EXIT INT TERM
|
|
||||||
|
|
||||||
# ─── 1. Provision the throwaway org ────────────────────────────────────
|
|
||||||
log "1/6 POST /cp/admin/orgs — slug=$SLUG"
|
|
||||||
CREATE=$(admin_call POST /cp/admin/orgs \
|
|
||||||
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E peer-visibility $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
|
|
||||||
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
|
||||||
[ -n "$ORG_ID" ] || fail "org creation failed: $(echo "$CREATE" | head -c 300)"
|
|
||||||
log " ORG_ID=$ORG_ID"
|
|
||||||
|
|
||||||
# ─── 2. Wait for tenant EC2 + DNS ──────────────────────────────────────
|
|
||||||
log "2/6 waiting for tenant instance_status=running (cold EC2 + cloudflared)..."
|
|
||||||
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
|
||||||
while true; do
|
|
||||||
[ "$(date +%s)" -gt "$DEADLINE" ] && fail "tenant never came up within ${PROVISION_TIMEOUT_SECS}s"
|
|
||||||
STATUS=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
try: d = json.load(sys.stdin)
|
|
||||||
except Exception: sys.exit(0)
|
|
||||||
orgs = d if isinstance(d, list) else d.get('orgs', [])
|
|
||||||
for o in orgs:
|
|
||||||
if o.get('slug') == '$SLUG':
|
|
||||||
print(o.get('instance_status') or o.get('status') or 'unknown'); break
|
|
||||||
" 2>/dev/null)
|
|
||||||
case "$STATUS" in running|online|ready) break ;; esac
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
log " tenant status=$STATUS"
|
|
||||||
|
|
||||||
# ─── 3. Per-tenant admin token + tenant URL ────────────────────────────
|
|
||||||
log "3/6 fetching per-tenant admin token..."
|
|
||||||
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
|
|
||||||
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
|
|
||||||
[ -n "$TENANT_TOKEN" ] || fail "tenant token fetch failed: $(echo "$TT_RESP" | head -c 200)"
|
|
||||||
|
|
||||||
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
|
|
||||||
case "$CP_HOST" in
|
|
||||||
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
|
|
||||||
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
|
|
||||||
*) DERIVED_DOMAIN="$CP_HOST" ;;
|
|
||||||
esac
|
|
||||||
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
|
|
||||||
log " tenant url: $TENANT_URL"
|
|
||||||
|
|
||||||
log "3b. waiting for tenant /health (TLS/DNS, up to 10min)..."
|
|
||||||
for i in $(seq 1 120); do
|
|
||||||
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
# ─── 4. Provision the parent + one sibling per runtime under test ──────
|
|
||||||
# Inject the LLM provider key so each runtime can authenticate at boot.
|
|
||||||
# Priority: MiniMax → direct-Anthropic → OpenAI (mirrors
|
|
||||||
# test_staging_full_saas.sh's secrets-injection chain).
|
|
||||||
SECRETS_JSON='{}'
|
|
||||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
|
||||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))")
|
|
||||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
|
||||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))")
|
|
||||||
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
|
||||||
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))")
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "4/6 provisioning parent (claude-code) + one sibling per runtime under test..."
|
|
||||||
P_RESP=$(tenant_call POST /workspaces \
|
|
||||||
-d "{\"name\":\"pv-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$SECRETS_JSON}")
|
|
||||||
PARENT_ID=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
|
||||||
[ -n "$PARENT_ID" ] || fail "parent create failed: $(echo "$P_RESP" | head -c 300)"
|
|
||||||
log " PARENT_ID=$PARENT_ID"
|
|
||||||
|
|
||||||
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
|
|
||||||
declare -A WS_IDS WS_TOKENS
|
|
||||||
ALL_WS_IDS="$PARENT_ID"
|
|
||||||
for rt in $PV_RUNTIMES; do
|
|
||||||
R=$(tenant_call POST /workspaces \
|
|
||||||
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
|
|
||||||
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
|
||||||
# auth_token is top-level for container runtimes; external-like nest it
|
|
||||||
# under connection.auth_token (verified vs staging response shape).
|
|
||||||
WTOK=$(echo "$R" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
try: d = json.load(sys.stdin)
|
|
||||||
except Exception: print(''); sys.exit(0)
|
|
||||||
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
|
|
||||||
" 2>/dev/null)
|
|
||||||
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
|
|
||||||
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
|
|
||||||
WS_IDS[$rt]="$WID"
|
|
||||||
WS_TOKENS[$rt]="$WTOK"
|
|
||||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
|
||||||
log " $rt → $WID"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ─── 5. Wait for every sibling online ──────────────────────────────────
|
|
||||||
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
|
|
||||||
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
|
||||||
for rt in $PV_RUNTIMES; do
|
|
||||||
wid="${WS_IDS[$rt]}"
|
|
||||||
LAST=""
|
|
||||||
while true; do
|
|
||||||
[ "$(date +%s)" -gt "$WS_DEADLINE" ] && fail "$rt ($wid) never reached online (last=$LAST)"
|
|
||||||
S=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
try: d = json.load(sys.stdin)
|
|
||||||
except Exception: sys.exit(0)
|
|
||||||
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
|
|
||||||
print(w.get('status') or '')
|
|
||||||
" 2>/dev/null)
|
|
||||||
[ "$S" != "$LAST" ] && { log " $rt → $S"; LAST="$S"; }
|
|
||||||
case "$S" in
|
|
||||||
online) break ;;
|
|
||||||
failed) sleep 10 ;; # transient: bootstrap-watcher 5-min deadline, heartbeat recovers
|
|
||||||
*) sleep 10 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
ok " $rt online"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
|
|
||||||
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
|
|
||||||
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
|
|
||||||
# name=list_peers, authenticated by the workspace's OWN bearer token
|
|
||||||
# through WorkspaceAuth + MCPRateLimiter.
|
|
||||||
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
|
|
||||||
echo ""
|
|
||||||
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
|
|
||||||
REGRESSED=0
|
|
||||||
declare -A VERDICT
|
|
||||||
|
|
||||||
for rt in $PV_RUNTIMES; do
|
|
||||||
wid="${WS_IDS[$rt]}"
|
|
||||||
wtok="${WS_TOKENS[$rt]}"
|
|
||||||
# The expected peer set = every OTHER provisioned workspace (parent +
|
|
||||||
# the sibling runtimes), excluding the caller itself.
|
|
||||||
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
|
|
||||||
|
|
||||||
set +e
|
|
||||||
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
|
|
||||||
-H "Authorization: Bearer $wtok" \
|
|
||||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$RPC_BODY" \
|
|
||||||
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
|
|
||||||
set -e
|
|
||||||
HTTP_CODE="$RESP"
|
|
||||||
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
|
|
||||||
|
|
||||||
echo "--- $rt (ws=$wid) ---"
|
|
||||||
echo " HTTP $HTTP_CODE"
|
|
||||||
echo " body: $(echo "$BODY" | head -c 600)"
|
|
||||||
|
|
||||||
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
|
|
||||||
if [ "$HTTP_CODE" != "200" ]; then
|
|
||||||
echo " ✗ $rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
|
|
||||||
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
|
|
||||||
REGRESSED=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# (2) JSON-RPC result present, not an error object.
|
|
||||||
PARSE=$(echo "$BODY" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
|
|
||||||
try:
|
|
||||||
d = json.load(sys.stdin)
|
|
||||||
except Exception as e:
|
|
||||||
print('PARSE_ERROR:' + str(e)); sys.exit(0)
|
|
||||||
if isinstance(d, dict) and d.get('error') is not None:
|
|
||||||
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
|
|
||||||
res = d.get('result') if isinstance(d, dict) else None
|
|
||||||
if res is None:
|
|
||||||
print('NO_RESULT'); sys.exit(0)
|
|
||||||
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
|
|
||||||
text = ''
|
|
||||||
if isinstance(res, dict):
|
|
||||||
for c in res.get('content', []):
|
|
||||||
if c.get('type') == 'text':
|
|
||||||
text += c.get('text', '')
|
|
||||||
text_l = text.lower()
|
|
||||||
# Native-sessions fallback signature (the OpenClaw symptom): the agent
|
|
||||||
# answered from its own runtime session list, not the platform peer set.
|
|
||||||
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
|
|
||||||
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
|
|
||||||
# The expected sibling IDs must literally appear in the returned peer text.
|
|
||||||
found = sorted(i for i in expect if i in text)
|
|
||||||
missing = sorted(expect - set(found))
|
|
||||||
if not expect:
|
|
||||||
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
|
|
||||||
if missing:
|
|
||||||
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
|
|
||||||
sys.exit(0)
|
|
||||||
print('OK:found=%d/%d' % (len(found), len(expect)))
|
|
||||||
" 2>/dev/null)
|
|
||||||
|
|
||||||
case "$PARSE" in
|
|
||||||
OK:*)
|
|
||||||
echo " ✓ $rt: list_peers returned 200 and contains all expected peers ($PARSE)"
|
|
||||||
VERDICT[$rt]="OK"
|
|
||||||
;;
|
|
||||||
NATIVE_FALLBACK:*)
|
|
||||||
echo " ✗ $rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
|
|
||||||
VERDICT[$rt]="FAIL(native-fallback)"
|
|
||||||
REGRESSED=1
|
|
||||||
;;
|
|
||||||
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
|
|
||||||
echo " ✗ $rt: list_peers MCP call did not return a usable result ($PARSE)"
|
|
||||||
VERDICT[$rt]="FAIL(rpc=$PARSE)"
|
|
||||||
REGRESSED=1
|
|
||||||
;;
|
|
||||||
MISSING_PEERS:*)
|
|
||||||
echo " ✗ $rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
|
|
||||||
VERDICT[$rt]="FAIL(peers=$PARSE)"
|
|
||||||
REGRESSED=1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo " ✗ $rt: unexpected verdict '$PARSE'"
|
|
||||||
VERDICT[$rt]="FAIL(unknown)"
|
|
||||||
REGRESSED=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
echo ""
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "=== SUMMARY — fresh-provision peer-visibility (literal MCP list_peers) ==="
|
|
||||||
for rt in $PV_RUNTIMES; do
|
|
||||||
printf ' %-14s %s\n' "$rt" "${VERDICT[$rt]:-NO_RUN}"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$REGRESSED" -ne 0 ]; then
|
|
||||||
echo "✗ GATE FAILED — at least one runtime cannot see its peers via the"
|
|
||||||
echo " literal mcp_molecule_list_peers call. This is the real user-facing"
|
|
||||||
echo " failure the proxy signals (registry row / heartbeat / model 200)"
|
|
||||||
echo " were hiding. Expected RED until the Hermes-401 + OpenClaw-MCP-wiring"
|
|
||||||
echo " root-cause fixes land; goes green only when they actually do."
|
|
||||||
exit 10
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "GATE PASSED — every runtime under test sees its platform peers via the literal MCP call."
|
|
||||||
exit 0
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# E2E test: workspace broadcast and talk-to-user platform abilities.
|
|
||||||
#
|
|
||||||
# What this proves:
|
|
||||||
# 1. talk_to_user_enabled (default true) — POST /notify works out-of-the-box.
|
|
||||||
# 2. PATCH /workspaces/:id/abilities { talk_to_user_enabled: false } disables
|
|
||||||
# delivery: /notify → 403 with error="talk_to_user_disabled" + delegate hint.
|
|
||||||
# 3. Re-enabling talk_to_user_enabled restores delivery.
|
|
||||||
# 4. broadcast_enabled (default false) — POST /broadcast → 403 when disabled.
|
|
||||||
# 5. PATCH { broadcast_enabled: true } enables fan-out.
|
|
||||||
# 6. POST /broadcast delivers to all non-sender, non-removed workspaces:
|
|
||||||
# - Returns {"status":"sent","delivered":N}
|
|
||||||
# - Receiver's activity log has a broadcast_receive entry with the message.
|
|
||||||
# - Sender's activity log has a broadcast_sent entry.
|
|
||||||
# 7. The sender itself does NOT receive a broadcast_receive entry.
|
|
||||||
#
|
|
||||||
# Usage: tests/e2e/test_workspace_abilities_e2e.sh
|
|
||||||
# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(dirname "$0")/_lib.sh"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
SENDER_ID=""
|
|
||||||
RECEIVER_ID=""
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
|
|
||||||
if [ -n "$wid" ]; then
|
|
||||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
assert() {
|
|
||||||
local label="$1" actual="$2" expected="$3"
|
|
||||||
if [ "$actual" = "$expected" ]; then
|
|
||||||
echo " PASS — $label"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo " FAIL — $label"
|
|
||||||
echo " expected: $expected"
|
|
||||||
echo " actual: $actual"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_contains() {
|
|
||||||
local label="$1" haystack="$2" needle="$3"
|
|
||||||
if echo "$haystack" | grep -qF "$needle"; then
|
|
||||||
echo " PASS — $label"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo " FAIL — $label"
|
|
||||||
echo " needle: $needle"
|
|
||||||
echo " haystack: $haystack"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_not_contains() {
|
|
||||||
local label="$1" haystack="$2" needle="$3"
|
|
||||||
if ! echo "$haystack" | grep -qF "$needle"; then
|
|
||||||
echo " PASS — $label"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo " FAIL — $label (unexpected match)"
|
|
||||||
echo " needle: $needle"
|
|
||||||
echo " haystack: $haystack"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Pre-sweep: remove any stale leftover workspaces from a prior aborted run ──
|
|
||||||
echo "=== Setup ==="
|
|
||||||
for NAME in "Abilities Sender" "Abilities Receiver"; do
|
|
||||||
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c "
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
print(' '.join(w['id'] for w in json.load(sys.stdin) if w.get('name') == '$NAME'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
")
|
|
||||||
for _wid in $PRIOR; do
|
|
||||||
echo "Sweeping leftover '$NAME' workspace: $_wid"
|
|
||||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"Abilities Sender","tier":1}')
|
|
||||||
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
|
||||||
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
|
|
||||||
echo "Created sender workspace: $SENDER_ID"
|
|
||||||
|
|
||||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
|
||||||
-d '{"name":"Abilities Receiver","tier":1}')
|
|
||||||
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
|
||||||
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
|
|
||||||
echo "Created receiver workspace: $RECEIVER_ID"
|
|
||||||
|
|
||||||
# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod).
|
|
||||||
SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID")
|
|
||||||
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
|
|
||||||
SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN"
|
|
||||||
|
|
||||||
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
|
|
||||||
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
|
|
||||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
|
|
||||||
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "=== Part 1: talk_to_user ability ==="
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 1a: /notify works with default talk_to_user_enabled=true ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Hello from sender"}')
|
|
||||||
assert "POST /notify returns 200 when talk_to_user_enabled=true (default)" "$CODE" "200"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 1b: Disable talk_to_user ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
|
||||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
|
||||||
-d '{"talk_to_user_enabled": false}')
|
|
||||||
assert "PATCH /abilities talk_to_user_enabled=false returns 200" "$CODE" "200"
|
|
||||||
|
|
||||||
# Verify the flag is reflected in the workspace GET response.
|
|
||||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
|
||||||
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
|
|
||||||
assert "GET /workspaces/:id reflects talk_to_user_enabled=false" "$FLAG" "False"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 1c: /notify blocked when talk_to_user disabled ---"
|
|
||||||
BODY=$(curl -s -w "" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Should be blocked"}')
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Should be blocked"}')
|
|
||||||
assert "POST /notify returns 403 when talk_to_user_enabled=false" "$CODE" "403"
|
|
||||||
|
|
||||||
ERR=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))' 2>/dev/null || echo "")
|
|
||||||
assert_contains "403 body contains talk_to_user_disabled error code" "$ERR" "talk_to_user_disabled"
|
|
||||||
|
|
||||||
HINT=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("hint",""))' 2>/dev/null || echo "")
|
|
||||||
assert_contains "403 body contains delegate_task hint" "$HINT" "delegate_task"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 1d: Re-enable talk_to_user and verify /notify works again ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
|
||||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
|
||||||
-d '{"talk_to_user_enabled": true}')
|
|
||||||
assert "PATCH /abilities talk_to_user_enabled=true returns 200" "$CODE" "200"
|
|
||||||
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Re-enabled, should work"}')
|
|
||||||
assert "POST /notify returns 200 after re-enabling talk_to_user" "$CODE" "200"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "=== Part 2: broadcast ability ==="
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2a: Broadcast blocked by default (broadcast_enabled=false) ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Should be blocked"}')
|
|
||||||
assert "POST /broadcast returns 403 when broadcast_enabled=false (default)" "$CODE" "403"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2b: Enable broadcast ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
|
||||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
|
||||||
-d '{"broadcast_enabled": true}')
|
|
||||||
assert "PATCH /abilities broadcast_enabled=true returns 200" "$CODE" "200"
|
|
||||||
|
|
||||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
|
||||||
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
|
|
||||||
assert "GET /workspaces/:id reflects broadcast_enabled=true" "$FLAG" "True"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2c: Successful broadcast fan-out ---"
|
|
||||||
BCAST=$(curl -s -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":"Org-wide notice: scheduled maintenance in 5 minutes."}')
|
|
||||||
BSTATUS=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null || echo "")
|
|
||||||
BDELIVERED=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("delivered","-1"))' 2>/dev/null || echo "-1")
|
|
||||||
assert "POST /broadcast returns status=sent" "$BSTATUS" "sent"
|
|
||||||
|
|
||||||
# delivered count must be >= 1 (the receiver workspace).
|
|
||||||
echo " INFO — broadcast delivered=$BDELIVERED"
|
|
||||||
if python3 -c "import sys; sys.exit(0 if int('$BDELIVERED') >= 1 else 1)" 2>/dev/null; then
|
|
||||||
echo " PASS — delivered count >= 1"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
else
|
|
||||||
echo " FAIL — expected delivered >= 1, got $BDELIVERED"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2d: Receiver activity log has broadcast_receive entry ---"
|
|
||||||
RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID")
|
|
||||||
[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; }
|
|
||||||
RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN"
|
|
||||||
|
|
||||||
ACT=$(curl -s -H "$RECEIVER_AUTH" "$BASE/workspaces/$RECEIVER_ID/activity?source=agent&limit=20")
|
|
||||||
ROW=$(echo "$ACT" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
rows = json.load(sys.stdin) or []
|
|
||||||
for r in rows:
|
|
||||||
if r.get("activity_type") == "broadcast_receive":
|
|
||||||
print(json.dumps(r))
|
|
||||||
break
|
|
||||||
')
|
|
||||||
[ -n "$ROW" ] || {
|
|
||||||
echo " FAIL — could not find broadcast_receive row in receiver activity"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -n "$ROW" ]; then
|
|
||||||
# Message is stored in summary field.
|
|
||||||
MSG=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("summary",""))')
|
|
||||||
assert_contains "broadcast_receive row summary has original message" "$MSG" "scheduled maintenance"
|
|
||||||
# Sender ID is stored in source_id field.
|
|
||||||
SRC=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("source_id",""))')
|
|
||||||
assert "broadcast_receive row source_id is sender workspace" "$SRC" "$SENDER_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2e: Sender activity log has broadcast_sent entry ---"
|
|
||||||
ACT_SENDER=$(curl -s -H "$SENDER_AUTH" "$BASE/workspaces/$SENDER_ID/activity?limit=20")
|
|
||||||
SENT_ROW=$(echo "$ACT_SENDER" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
rows = json.load(sys.stdin) or []
|
|
||||||
for r in rows:
|
|
||||||
if r.get("activity_type") == "broadcast_sent":
|
|
||||||
print(json.dumps(r))
|
|
||||||
break
|
|
||||||
')
|
|
||||||
[ -n "$SENT_ROW" ] || {
|
|
||||||
echo " FAIL — could not find broadcast_sent row in sender activity"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -n "$SENT_ROW" ]; then
|
|
||||||
# Delivered count is baked into the summary field (no response_body for sender row).
|
|
||||||
SUMMARY=$(echo "$SENT_ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("summary",""))')
|
|
||||||
assert_contains "broadcast_sent summary mentions workspace count" "$SUMMARY" "workspace"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2f: Sender does NOT receive a broadcast_receive entry ---"
|
|
||||||
SELF_RECV=$(echo "$ACT_SENDER" | python3 -c '
|
|
||||||
import json, sys
|
|
||||||
rows = json.load(sys.stdin) or []
|
|
||||||
for r in rows:
|
|
||||||
if r.get("activity_type") == "broadcast_receive":
|
|
||||||
print("found")
|
|
||||||
break
|
|
||||||
')
|
|
||||||
assert_not_contains "sender has no broadcast_receive in own activity log" "${SELF_RECV:-}" "found"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "--- 2g: Empty message is rejected ---"
|
|
||||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
|
|
||||||
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
|
|
||||||
-d '{"message":""}')
|
|
||||||
assert "POST /broadcast with empty message returns 400" "$CODE" "400"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 2h: Partial PATCH does not clobber other flags ---"
|
|
||||||
# Set talk_to_user=false, then patch only broadcast — talk_to_user must stay false.
|
|
||||||
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
|
||||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
|
||||||
-d '{"talk_to_user_enabled": false}'
|
|
||||||
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
|
|
||||||
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
|
|
||||||
-d '{"broadcast_enabled": false}'
|
|
||||||
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
|
|
||||||
TUF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
|
|
||||||
BEF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
|
|
||||||
assert "partial PATCH preserves talk_to_user_enabled=false" "$TUF" "False"
|
|
||||||
assert "partial PATCH sets broadcast_enabled=false" "$BEF" "False"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
||||||
[ "$FAIL" -eq 0 ]
|
|
||||||
@ -545,70 +545,6 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
|
|||||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rule 10 — docker info piped to head under pipefail
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DOCKER_INFO_HEAD_BAD = """
|
|
||||||
name: docker-info-head-bad
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker info 2>&1 | head -5 || exit 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
DOCKER_INFO_CAPTURE_OK = """
|
|
||||||
name: docker-info-capture-ok
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker_info="$(docker info 2>&1)" || exit 1
|
|
||||||
printf '%s\\n' "${docker_info}" | sed -n '1,5p'
|
|
||||||
"""
|
|
||||||
|
|
||||||
DOCKER_INFO_SEPARATE_STEP_OK = """
|
|
||||||
name: docker-info-separate-step-ok
|
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo setup
|
|
||||||
- run: |
|
|
||||||
docker info 2>&1 | head -5 || true
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule10_docker_info_head_under_pipefail_detects_violation(tmp_path):
|
|
||||||
_write(tmp_path, "bad.yml", DOCKER_INFO_HEAD_BAD)
|
|
||||||
r = _run_lint(tmp_path)
|
|
||||||
assert r.returncode == 1
|
|
||||||
assert "docker info" in r.stdout.lower()
|
|
||||||
assert "pipefail" in r.stdout.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule10_docker_info_capture_passes(tmp_path):
|
|
||||||
_write(tmp_path, "ok.yml", DOCKER_INFO_CAPTURE_OK)
|
|
||||||
r = _run_lint(tmp_path)
|
|
||||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_path):
|
|
||||||
_write(tmp_path, "ok.yml", DOCKER_INFO_SEPARATE_STEP_OK)
|
|
||||||
r = _run_lint(tmp_path)
|
|
||||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CI change detector fanout — workflow-only PRs keep required contexts without
|
# CI change detector fanout — workflow-only PRs keep required contexts without
|
||||||
# running Go/Canvas/Python/shellcheck heavy steps.
|
# running Go/Canvas/Python/shellcheck heavy steps.
|
||||||
|
|||||||
@ -495,7 +495,7 @@ def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkey
|
|||||||
}
|
}
|
||||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||||
assert counters["compensated"] == 0
|
assert counters["compensated"] == 0
|
||||||
assert counters["preserved_pr_without_push_success"] == 1
|
assert counters["preserved_non_push_suffix"] == 1
|
||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
@ -1009,64 +1009,3 @@ def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
|
|||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "::warning::" in captured.out or "::notice::" in captured.out
|
assert "::warning::" in captured.out or "::notice::" in captured.out
|
||||||
assert SHA_A[:10] in captured.out
|
assert SHA_A[:10] in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_main_soft_skips_when_commit_listing_times_out(sr_module, monkeypatch, capsys):
|
|
||||||
"""A transient outage while listing recent commits should not paint main red.
|
|
||||||
|
|
||||||
Per-SHA status read failures are already isolated inside `reap_branch`.
|
|
||||||
The real 2026-05-14 failure was earlier: `/commits?sha=main&limit=30`
|
|
||||||
timed out after all retries, aborting the tick. The next 5-minute tick can
|
|
||||||
retry safely, so `main()` should emit an observable warning and return 0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
|
|
||||||
|
|
||||||
def fake_list_recent_commit_shas(*args, **kwargs):
|
|
||||||
raise sr_module.ApiError(
|
|
||||||
"GET /repos/owner/repo/commits failed after 4 attempts: timed out"
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(sr_module, "list_recent_commit_shas", fake_list_recent_commit_shas)
|
|
||||||
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
|
|
||||||
|
|
||||||
assert sr_module.main() == 0
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "::warning::status-reaper skipped this tick" in captured.out
|
|
||||||
assert '"skipped": true' in captured.out
|
|
||||||
assert '"skip_reason": "commit-list-api-error"' in captured.out
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_does_not_soft_skip_status_write_failures(sr_module, monkeypatch):
|
|
||||||
"""Only commit-list read failures are soft-skipped.
|
|
||||||
|
|
||||||
A compensation write failure means the reaper could not repair a red
|
|
||||||
status. That must still fail the job loudly instead of being mislabeled as
|
|
||||||
a transient commit-list outage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
|
|
||||||
monkeypatch.setattr(sr_module, "list_recent_commit_shas", lambda *_args, **_kwargs: [SHA_A])
|
|
||||||
monkeypatch.setattr(
|
|
||||||
sr_module,
|
|
||||||
"get_combined_status",
|
|
||||||
lambda _sha: {
|
|
||||||
"state": "failure",
|
|
||||||
"statuses": [
|
|
||||||
{
|
|
||||||
"context": "workflow-without-push / job (push)",
|
|
||||||
"status": "failure",
|
|
||||||
"description": "stranded class-O red",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_post_compensating_status(*args, **kwargs):
|
|
||||||
raise sr_module.ApiError("POST /statuses failed: 403")
|
|
||||||
|
|
||||||
monkeypatch.setattr(sr_module, "post_compensating_status", fake_post_compensating_status)
|
|
||||||
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
|
|
||||||
|
|
||||||
with pytest.raises(sr_module.ApiError, match="POST /statuses failed"):
|
|
||||||
sr_module.main()
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ require (
|
|||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/redis/go-redis/v9 v9.19.0
|
github.com/redis/go-redis/v9 v9.19.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/stretchr/testify v1.11.1
|
|
||||||
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@ -34,7 +33,6 @@ require (
|
|||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@ -60,7 +58,6 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
|||||||
@ -1,261 +0,0 @@
|
|||||||
package bundle
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// extractDescription
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestExtractDescription_WithFrontmatter(t *testing.T) {
|
|
||||||
// YAML frontmatter is skipped; first non-comment, non-empty line after
|
|
||||||
// the closing `---` is the description.
|
|
||||||
content := `---
|
|
||||||
title: My Workspace
|
|
||||||
---
|
|
||||||
# This is a comment
|
|
||||||
This is the description line.
|
|
||||||
Another line.`
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "This is the description line." {
|
|
||||||
t.Errorf("got %q, want %q", got, "This is the description line.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_NoFrontmatter(t *testing.T) {
|
|
||||||
// No frontmatter: first non-comment, non-empty line is returned.
|
|
||||||
content := `# Copyright header
|
|
||||||
My workspace description
|
|
||||||
Another line.`
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "My workspace description" {
|
|
||||||
t.Errorf("got %q, want %q", got, "My workspace description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_CommentOnly(t *testing.T) {
|
|
||||||
// All content is comments or empty → empty string.
|
|
||||||
content := `# comment only
|
|
||||||
# another comment
|
|
||||||
`
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_EmptyInput(t *testing.T) {
|
|
||||||
got := extractDescription("")
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
|
|
||||||
// With no closing `---`, inFrontmatter stays true after the opening
|
|
||||||
// delimiter, so all subsequent lines are skipped and "" is returned.
|
|
||||||
// This is the documented behaviour: without a closing delimiter,
|
|
||||||
// all lines are considered frontmatter.
|
|
||||||
content := `---
|
|
||||||
title: No closing delimiter
|
|
||||||
This is the description.`
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
|
|
||||||
content := `---
|
|
||||||
tags: [test]
|
|
||||||
---
|
|
||||||
# internal comment
|
|
||||||
Real description here.
|
|
||||||
`
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "Real description here." {
|
|
||||||
t.Errorf("got %q, want %q", got, "Real description here.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
|
|
||||||
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
|
|
||||||
// skipped because len(line)>0. First non-comment, non-empty line is returned.
|
|
||||||
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
|
|
||||||
got := extractDescription(content)
|
|
||||||
if got != "A. Description" {
|
|
||||||
t.Errorf("got %q, want %q", got, "A. Description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// splitLines
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestSplitLines_Basic(t *testing.T) {
|
|
||||||
got := splitLines("a\nb\nc")
|
|
||||||
want := []string{"a", "b", "c"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("len=%d, want %d", len(got), len(want))
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLines_TrailingNewline(t *testing.T) {
|
|
||||||
got := splitLines("line1\nline2\n")
|
|
||||||
want := []string{"line1", "line2"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Errorf("trailing newline: got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLines_NoNewline(t *testing.T) {
|
|
||||||
got := splitLines("no newline")
|
|
||||||
want := []string{"no newline"}
|
|
||||||
if len(got) != 1 || got[0] != want[0] {
|
|
||||||
t.Errorf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLines_EmptyString(t *testing.T) {
|
|
||||||
got := splitLines("")
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("empty string: got %v, want []", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLines_OnlyNewlines(t *testing.T) {
|
|
||||||
got := splitLines("\n\n\n")
|
|
||||||
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
|
|
||||||
// the empty string between newlines → 3 empty segments.
|
|
||||||
// (No trailing segment because start == len(s) at the end.)
|
|
||||||
if len(got) != 3 {
|
|
||||||
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
|
|
||||||
}
|
|
||||||
for i, s := range got {
|
|
||||||
if s != "" {
|
|
||||||
t.Errorf("got[%d]=%q, want empty string", i, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
|
|
||||||
got := splitLines("a\n\n\nb")
|
|
||||||
// a\n\n\nb → ["a", "", "", "b"]
|
|
||||||
if len(got) != 4 {
|
|
||||||
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
|
|
||||||
}
|
|
||||||
if got[0] != "a" || got[3] != "b" {
|
|
||||||
t.Errorf("first/last: got %v, want [a, ..., b]", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// findConfigDir
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestFindConfigDir_NameMatch(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
|
|
||||||
// Create two sub-dirs; only the one with matching name should be found.
|
|
||||||
mustMkdir(filepath.Join(tmp, "workspace-a"))
|
|
||||||
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
|
|
||||||
"name: other-workspace\ntier: 1\n")
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "workspace-b"))
|
|
||||||
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
|
|
||||||
"name: target-workspace\nruntime: claude-code\n")
|
|
||||||
|
|
||||||
got := findConfigDir(tmp, "target-workspace")
|
|
||||||
want := filepath.Join(tmp, "workspace-b")
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("got %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "first"))
|
|
||||||
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "second"))
|
|
||||||
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
|
|
||||||
|
|
||||||
// No exact name match → fallback to the first directory with a config.yaml.
|
|
||||||
got := findConfigDir(tmp, "nonexistent")
|
|
||||||
want := filepath.Join(tmp, "first")
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("no match: got %q, want fallback %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindConfigDir_MissingDir(t *testing.T) {
|
|
||||||
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("missing dir: got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindConfigDir_NoSubdirs(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
// Empty directory → no matches, no fallback.
|
|
||||||
got := findConfigDir(tmp, "any")
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("empty dir: got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func mustMkdir(path string) {
|
|
||||||
os.MkdirAll(path, 0o755)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustWrite(path, content string) {
|
|
||||||
os.WriteFile(path, []byte(content), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// findConfigDir
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
mustMkdir(filepath.Join(tmp, "empty-skill"))
|
|
||||||
// Sub-dir without config.yaml → skipped.
|
|
||||||
got := findConfigDir(tmp, "any")
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("no config.yaml: got %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
|
|
||||||
// When name doesn't match, fallback is the FIRST dir with config.yaml,
|
|
||||||
// not the last. Confirm ordering by creating three dirs.
|
|
||||||
tmp := t.TempDir()
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "a"))
|
|
||||||
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "b"))
|
|
||||||
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
|
|
||||||
|
|
||||||
mustMkdir(filepath.Join(tmp, "c"))
|
|
||||||
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
|
|
||||||
|
|
||||||
got := findConfigDir(tmp, "nonexistent")
|
|
||||||
want := filepath.Join(tmp, "a") // first dir with config.yaml
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,317 +0,0 @@
|
|||||||
package bundle
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) {
|
|
||||||
b := &Bundle{}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 0 {
|
|
||||||
t.Errorf("empty bundle: want 0 files, got %d", len(files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
SystemPrompt: "You are a helpful assistant.",
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if n := len(files); n != 1 {
|
|
||||||
t.Fatalf("system-prompt only: want 1 file, got %d", n)
|
|
||||||
}
|
|
||||||
if content, ok := files["system-prompt.md"]; !ok {
|
|
||||||
t.Fatal("missing system-prompt.md")
|
|
||||||
} else if string(content) != "You are a helpful assistant." {
|
|
||||||
t.Errorf("system-prompt content: got %q", string(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Prompts: map[string]string{
|
|
||||||
"config.yaml": "runtime: langgraph\ntier: 2\n",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if n := len(files); n != 1 {
|
|
||||||
t.Fatalf("config.yaml only: want 1 file, got %d", n)
|
|
||||||
}
|
|
||||||
if content, ok := files["config.yaml"]; !ok {
|
|
||||||
t.Fatal("missing config.yaml")
|
|
||||||
} else if string(content) != "runtime: langgraph\ntier: 2\n" {
|
|
||||||
t.Errorf("config.yaml content: got %q", string(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
SystemPrompt: "Be concise.",
|
|
||||||
Prompts: map[string]string{
|
|
||||||
"config.yaml": "runtime: langgraph\n",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if n := len(files); n != 2 {
|
|
||||||
t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n)
|
|
||||||
}
|
|
||||||
if _, ok := files["system-prompt.md"]; !ok {
|
|
||||||
t.Error("missing system-prompt.md")
|
|
||||||
}
|
|
||||||
if _, ok := files["config.yaml"]; !ok {
|
|
||||||
t.Error("missing config.yaml")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_Skills(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Skills: []BundleSkill{
|
|
||||||
{
|
|
||||||
ID: "web-search",
|
|
||||||
Files: map[string]string{"readme.md": "# Web Search\n"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "code-interpreter",
|
|
||||||
Files: map[string]string{"readme.md": "# Code Interpreter\n"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
// 2 skills × 1 file each = 2 files
|
|
||||||
if n := len(files); n != 2 {
|
|
||||||
t.Fatalf("skills: want 2 files, got %d", n)
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/web-search/readme.md"]; !ok {
|
|
||||||
t.Error("missing skills/web-search/readme.md")
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/code-interpreter/readme.md"]; !ok {
|
|
||||||
t.Error("missing skills/code-interpreter/readme.md")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Skills: []BundleSkill{
|
|
||||||
{
|
|
||||||
ID: "multi-file",
|
|
||||||
Files: map[string]string{
|
|
||||||
"readme.md": "# Multi",
|
|
||||||
"instructions.txt": "Step 1, Step 2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if n := len(files); n != 2 {
|
|
||||||
t.Fatalf("skill with sub-paths: want 2 files, got %d", n)
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/multi-file/readme.md"]; !ok {
|
|
||||||
t.Error("missing skills/multi-file/readme.md")
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/multi-file/instructions.txt"]; !ok {
|
|
||||||
t.Error("missing skills/multi-file/instructions.txt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
SystemPrompt: "",
|
|
||||||
Prompts: map[string]string{
|
|
||||||
"config.yaml": "runtime: langgraph\n",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
// Empty system-prompt should not produce a file
|
|
||||||
if n := len(files); n != 1 {
|
|
||||||
t.Errorf("empty system-prompt: want 1 file, got %d", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Prompts: map[string]string{},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if n := len(files); n != 0 {
|
|
||||||
t.Errorf("empty prompts map: want 0 files, got %d", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_emptyBundle(t *testing.T) {
|
|
||||||
b := &Bundle{}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 0 {
|
|
||||||
t.Errorf("expected empty map for empty bundle, got %d entries", len(files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_systemPrompt(t *testing.T) {
|
|
||||||
b := &Bundle{SystemPrompt: "You are a helpful assistant."}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Fatalf("expected 1 file, got %d", len(files))
|
|
||||||
}
|
|
||||||
if string(files["system-prompt.md"]) != "You are a helpful assistant." {
|
|
||||||
t.Errorf("unexpected system prompt content: %q", files["system-prompt.md"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_configYaml(t *testing.T) {
|
|
||||||
b := &Bundle{Prompts: map[string]string{
|
|
||||||
"config.yaml": "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n",
|
|
||||||
}}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Fatalf("expected 1 file, got %d", len(files))
|
|
||||||
}
|
|
||||||
if string(files["config.yaml"]) != "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n" {
|
|
||||||
t.Errorf("unexpected config.yaml content: %q", files["config.yaml"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_systemPromptAndConfigYaml(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
SystemPrompt: "# System",
|
|
||||||
Prompts: map[string]string{"config.yaml": "runtime: langgraph"},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 2 {
|
|
||||||
t.Fatalf("expected 2 files, got %d", len(files))
|
|
||||||
}
|
|
||||||
if _, ok := files["system-prompt.md"]; !ok {
|
|
||||||
t.Error("missing system-prompt.md")
|
|
||||||
}
|
|
||||||
if _, ok := files["config.yaml"]; !ok {
|
|
||||||
t.Error("missing config.yaml")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_skills(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Skills: []BundleSkill{
|
|
||||||
{
|
|
||||||
ID: "web-search",
|
|
||||||
Name: "Web Search",
|
|
||||||
Description: "Search the web",
|
|
||||||
Files: map[string]string{"readme.md": "# Web Search"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "code-runner",
|
|
||||||
Name: "Code Runner",
|
|
||||||
Description: "Execute code",
|
|
||||||
Files: map[string]string{"handler.py": "print('hello')"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 2 {
|
|
||||||
t.Fatalf("expected 2 skill files, got %d", len(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
if content, ok := files["skills/web-search/readme.md"]; !ok {
|
|
||||||
t.Error("missing skills/web-search/readme.md")
|
|
||||||
} else if string(content) != "# Web Search" {
|
|
||||||
t.Errorf("unexpected readme.md: %q", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := files["skills/code-runner/handler.py"]; !ok {
|
|
||||||
t.Error("missing skills/code-runner/handler.py")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_skillsWithSubPaths(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
Skills: []BundleSkill{
|
|
||||||
{
|
|
||||||
ID: "nested-skill",
|
|
||||||
Files: map[string]string{"src/main.py": "def main(): pass", "pyproject.toml": "[tool.foo]"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 2 {
|
|
||||||
t.Fatalf("expected 2 files, got %d", len(files))
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/nested-skill/src/main.py"]; !ok {
|
|
||||||
t.Error("missing skills/nested-skill/src/main.py")
|
|
||||||
}
|
|
||||||
if _, ok := files["skills/nested-skill/pyproject.toml"]; !ok {
|
|
||||||
t.Error("missing skills/nested-skill/pyproject.toml")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_skipsEmptyPrompts(t *testing.T) {
|
|
||||||
b := &Bundle{Prompts: map[string]string{}}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 0 {
|
|
||||||
t.Errorf("expected 0 files for empty prompts map, got %d", len(files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildBundleConfigFiles_skipsMissingConfigYaml(t *testing.T) {
|
|
||||||
b := &Bundle{
|
|
||||||
SystemPrompt: "# My Prompt",
|
|
||||||
Prompts: map[string]string{"other.yaml": "something: else"},
|
|
||||||
}
|
|
||||||
files := buildBundleConfigFiles(b)
|
|
||||||
if len(files) != 1 {
|
|
||||||
t.Fatalf("expected 1 file (system-prompt only), got %d", len(files))
|
|
||||||
}
|
|
||||||
if _, ok := files["config.yaml"]; ok {
|
|
||||||
t.Error("config.yaml should not be written when not in Prompts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_emptyString(t *testing.T) {
|
|
||||||
result := nilIfEmpty("")
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("expected nil for empty string, got %v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_nonEmptyString(t *testing.T) {
|
|
||||||
result := nilIfEmpty("hello")
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected non-nil result for non-empty string")
|
|
||||||
}
|
|
||||||
if result != "hello" {
|
|
||||||
t.Errorf("expected hello, got %q", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_whitespaceString(t *testing.T) {
|
|
||||||
// Whitespace is not empty — nilIfEmpty only checks for zero-length
|
|
||||||
result := nilIfEmpty(" ")
|
|
||||||
if result == nil {
|
|
||||||
t.Error("expected non-nil for whitespace string")
|
|
||||||
} else if result != " " {
|
|
||||||
t.Errorf("expected ' ', got %q", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_EmptyString(t *testing.T) {
|
|
||||||
got := nilIfEmpty("")
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_NonEmptyString(t *testing.T) {
|
|
||||||
got := nilIfEmpty("hello")
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil")
|
|
||||||
}
|
|
||||||
if s, ok := got.(string); !ok || s != "hello" {
|
|
||||||
t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNilIfEmpty_Whitespace(t *testing.T) {
|
|
||||||
got := nilIfEmpty(" ")
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)")
|
|
||||||
}
|
|
||||||
if s, ok := got.(string); !ok || s != " " {
|
|
||||||
t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -402,7 +402,7 @@ func (m *Manager) SendOutbound(ctx context.Context, channelID string, text strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter, ok := GetSendAdapter(ch.ChannelType)
|
adapter, ok := GetAdapter(ch.ChannelType)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("no adapter for %s", ch.ChannelType)
|
return fmt.Errorf("no adapter for %s", ch.ChannelType)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package channels
|
package channels
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Registry of all available channel adapters.
|
// Registry of all available channel adapters.
|
||||||
// To add a new platform: implement ChannelAdapter, register here.
|
// To add a new platform: implement ChannelAdapter, register here.
|
||||||
var adapters = map[string]ChannelAdapter{
|
var adapters = map[string]ChannelAdapter{
|
||||||
@ -11,27 +9,6 @@ var adapters = map[string]ChannelAdapter{
|
|||||||
"discord": &DiscordAdapter{},
|
"discord": &DiscordAdapter{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAdapter is the subset of ChannelAdapter needed by SendOutbound.
|
|
||||||
// Extracted so tests can inject a no-op/mock adapter without hitting real
|
|
||||||
// platform APIs (Telegram Bot API, Slack API, etc.).
|
|
||||||
type SendAdapter interface {
|
|
||||||
SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSendAdapter is the production implementation of GetSendAdapter —
|
|
||||||
// returns the real registered adapter's SendMessage method.
|
|
||||||
func getSendAdapter(channelType string) (SendAdapter, bool) {
|
|
||||||
a, ok := adapters[channelType]
|
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return a, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSendAdapter returns the SendAdapter for a channel type.
|
|
||||||
// Defaults to the real adapter; overridden by SetTestSendAdapter in tests.
|
|
||||||
var GetSendAdapter = getSendAdapter
|
|
||||||
|
|
||||||
// GetAdapter returns the adapter for a channel type.
|
// GetAdapter returns the adapter for a channel type.
|
||||||
func GetAdapter(channelType string) (ChannelAdapter, bool) {
|
func GetAdapter(channelType string) (ChannelAdapter, bool) {
|
||||||
a, ok := adapters[channelType]
|
a, ok := adapters[channelType]
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// MockSendAdapter implements SendAdapter for handler tests. It records every
|
|
||||||
// call and returns a configurable error (nil = success, non-nil = failure).
|
|
||||||
type MockSendAdapter struct {
|
|
||||||
Calls int
|
|
||||||
Err error
|
|
||||||
SentText string
|
|
||||||
SentChat string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockSendAdapter) SendMessage(_ context.Context, _ map[string]interface{}, chatID string, text string) error {
|
|
||||||
m.Calls++
|
|
||||||
m.SentText = text
|
|
||||||
m.SentChat = chatID
|
|
||||||
return m.Err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetGetSendAdapter replaces the package-level GetSendAdapter variable.
|
|
||||||
// Tests MUST call ResetSendAdapters() in their t.Cleanup.
|
|
||||||
func SetGetSendAdapter(fn func(string) (SendAdapter, bool)) {
|
|
||||||
GetSendAdapter = fn
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetSendAdapters restores GetSendAdapter to the production implementation.
|
|
||||||
func ResetSendAdapters() {
|
|
||||||
GetSendAdapter = getSendAdapter
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
// Regression coverage for the POLL-mode arm of the canvas user-message
|
|
||||||
// data-loss bug (internal#470 sibling — tracked on internal#471).
|
|
||||||
//
|
|
||||||
// Bug (reported 2026-05-16 by CTO Hongming): "in canvas i sometimes lose
|
|
||||||
// my own message when i exit chat". The push-mode arm was fixed by
|
|
||||||
// #1347 (persistUserMessageAtIngest — a SYNCHRONOUS, before-dispatch,
|
|
||||||
// context.WithoutCancel INSERT). #1347's framing asserted "poll-mode
|
|
||||||
// workspaces were never affected — logA2AReceiveQueued already persists
|
|
||||||
// at ingest". That assertion is OVERSTATED.
|
|
||||||
//
|
|
||||||
// Hongming's tenant (slug `hongming`, org 2c940477-...) has 4 workspaces,
|
|
||||||
// ALL runtime=external with empty URL → ALL delivery_mode=poll (proven
|
|
||||||
// empirically: a benign A2A probe returns the synthetic
|
|
||||||
// {"delivery_mode":"poll","status":"queued"} envelope for every one).
|
|
||||||
// So his reported loss is the POLL path, NOT the push path #1347 fixes.
|
|
||||||
//
|
|
||||||
// Root cause (poll arm): the poll-mode short-circuit (a2a_proxy.go ~402)
|
|
||||||
// calls logA2AReceiveQueued and then IMMEDIATELY returns the synthetic
|
|
||||||
// 200 {status:"queued"} to the canvas. But logA2AReceiveQueued's durable
|
|
||||||
// INSERT runs inside h.goAsync(...) — a DETACHED goroutine with NO
|
|
||||||
// happens-before barrier against the HTTP response. The canvas sees 200
|
|
||||||
// ("message accepted") while the activity_logs row may not yet be — and,
|
|
||||||
// on a workspace-server restart / deploy / OOM / EC2 hibernation between
|
|
||||||
// the 200 and the goroutine's commit, NEVER will be — durable. There is
|
|
||||||
// also no fallback (unlike push-mode's legacy-INSERT fallback): a
|
|
||||||
// swallowed LogActivity error loses the message with only a log line.
|
|
||||||
// Chat-history reads activity_logs (postgres_store.go:165-187); a missing
|
|
||||||
// row = message gone on reopen. That is exactly Hongming's symptom.
|
|
||||||
//
|
|
||||||
// Fix (parity with push-mode): the poll-mode ingest persist of the
|
|
||||||
// canvas user message must be SYNCHRONOUS — committed before the queued
|
|
||||||
// 200 is returned — on a context.WithoutCancel derived context, so a
|
|
||||||
// client disconnect on chat-exit and a post-response restart cannot lose
|
|
||||||
// it. Behavior is never worse than today (best-effort; a persist error
|
|
||||||
// still returns queued).
|
|
||||||
//
|
|
||||||
// TEST DESIGN NOTE: sqlmock.ExpectationsWereMet() hangs indefinitely if
|
|
||||||
// the expected query never fires. We use a select+default+time.After
|
|
||||||
// pattern so the test FAILS fast (not hangs) when the production code
|
|
||||||
// regresses to async (the INSERT never fires before handler returns),
|
|
||||||
// while still returning promptly when all expectations are met. The
|
|
||||||
// insertDelay is kept small (50ms) to minimise suite-level timing
|
|
||||||
// impact under -race detection, where mock delays are amplified by
|
|
||||||
// the instrumenter's goroutine overhead.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse
|
|
||||||
// is the defining contract: for a poll-mode workspace, the canvas user
|
|
||||||
// message MUST be durably INSERTed into activity_logs BEFORE the synthetic
|
|
||||||
// queued 200 is returned to the client — with NO reliance on a detached
|
|
||||||
// async goroutine completing later.
|
|
||||||
//
|
|
||||||
// The test proves the ordering by making the INSERT block briefly and
|
|
||||||
// asserting the handler does NOT return until the INSERT has completed.
|
|
||||||
// Pre-fix (INSERT in h.goAsync, response returned immediately) the
|
|
||||||
// handler returns ~instantly while the INSERT is still pending in the
|
|
||||||
// goroutine → the elapsed time is far below the injected INSERT delay and
|
|
||||||
// ExpectationsWereMet() is racy/unmet at return. Post-fix (synchronous
|
|
||||||
// persist before the queued response) the handler return is gated on the
|
|
||||||
// INSERT, so elapsed >= the injected delay and the expectation is met
|
|
||||||
// deterministically at return WITHOUT any waitAsyncForTest()/sleep.
|
|
||||||
func TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse(t *testing.T) {
|
|
||||||
mock := setupTestDB(t)
|
|
||||||
setupTestRedis(t)
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
|
|
||||||
const wsID = "ws-poll-sync-persist"
|
|
||||||
// Keep delay small: -race detection amplifies mock delays significantly.
|
|
||||||
// A 50ms delay is sufficient to prove synchronous blocking (~50× the
|
|
||||||
// normal INSERT latency) without bloating the full ./... suite runtime.
|
|
||||||
const insertDelay = 50 * time.Millisecond
|
|
||||||
|
|
||||||
expectBudgetCheck(mock, wsID)
|
|
||||||
|
|
||||||
// lookupDeliveryMode → poll, triggering the short-circuit.
|
|
||||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
|
||||||
WithArgs(wsID).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("poll"))
|
|
||||||
|
|
||||||
// workspace-name lookup inside logA2AReceiveQueued.
|
|
||||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id`).
|
|
||||||
WithArgs(wsID).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Poll WS"))
|
|
||||||
|
|
||||||
// The durable user-message write. We delay it so a synchronous
|
|
||||||
// persist visibly gates the handler return; a detached-goroutine
|
|
||||||
// persist (pre-fix) does not. The fix must keep using
|
|
||||||
// context.WithoutCancel so this write survives a chat-exit cancel.
|
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
|
||||||
WillDelayFor(insertDelay).
|
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
|
||||||
|
|
||||||
// callerID == "" (no X-Workspace-ID) → this is a canvas_user message,
|
|
||||||
// exactly Hongming's case.
|
|
||||||
body := `{"jsonrpc":"2.0","id":"poll-canvas-1","method":"message/send","params":{"message":{"role":"user","parts":[{"text":"my own message"}]}}}`
|
|
||||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/a2a", bytes.NewBufferString(body))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
handler.ProxyA2A(c)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
// Defining assertion #1: the handler must not have returned the
|
|
||||||
// queued response before the durable INSERT committed. Pre-fix this
|
|
||||||
// fails (elapsed ≈ 0, INSERT still racing in goAsync).
|
|
||||||
if elapsed < insertDelay {
|
|
||||||
t.Fatalf("poll-mode queued response returned in %v, before the %v user-message INSERT — "+
|
|
||||||
"the message is not durable when the client/process goes away (DATA LOSS). "+
|
|
||||||
"Persist must be synchronous before the queued 200.", elapsed, insertDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defining assertion #2: the durable write actually happened by the
|
|
||||||
// time the handler returned. ExpectionsWereMet() hangs indefinitely if
|
|
||||||
// the mock never fires (e.g. production code regressed to async),
|
|
||||||
// so we check it in a goroutine with a hard 2s timeout — fails fast
|
|
||||||
// (no CI hang) on regression while returning promptly on success.
|
|
||||||
expectDone := make(chan error, 1)
|
|
||||||
go func() { expectDone <- mock.ExpectationsWereMet() }()
|
|
||||||
select {
|
|
||||||
case err := <-expectDone:
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("user-message INSERT was not durable at handler return (unmet sqlmock expectations): %v", err)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("ExpectationsWereMet() hung for >2s — INSERT mock never fired. " +
|
|
||||||
"Likely cause: production code regressed logA2AReceiveQueued to goAsync " +
|
|
||||||
"(INSERT fires after handler returns, not before).")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity: still the correct poll-mode envelope + status.
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 (queued), got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]interface{}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("response is not valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
if resp["status"] != "queued" || resp["delivery_mode"] != "poll" {
|
|
||||||
t.Errorf("poll envelope changed: got status=%v delivery_mode=%v, want queued/poll",
|
|
||||||
resp["status"], resp["delivery_mode"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -97,28 +97,28 @@ const maxProxyResponseBody = 10 << 20
|
|||||||
//
|
//
|
||||||
// Timeout model — three independent budgets, none of which gets in each other's way:
|
// Timeout model — three independent budgets, none of which gets in each other's way:
|
||||||
//
|
//
|
||||||
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
|
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
|
||||||
// the entire request including streamed body reads, and would pre-empt
|
// the entire request including streamed body reads, and would pre-empt
|
||||||
// legitimate slow cold-start flows (Claude Code first-token over OAuth
|
// legitimate slow cold-start flows (Claude Code first-token over OAuth
|
||||||
// can take 30-60s on boot; long-running agent synthesis can stream
|
// can take 30-60s on boot; long-running agent synthesis can stream
|
||||||
// tokens for minutes). Total-request budget is enforced per-request
|
// tokens for minutes). Total-request budget is enforced per-request
|
||||||
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
|
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
|
||||||
//
|
//
|
||||||
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
|
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
|
||||||
// black-holes TCP connects (instance terminated mid-flight, security group
|
// black-holes TCP connects (instance terminated mid-flight, security group
|
||||||
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
|
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
|
||||||
// enough that Cloudflare's ~100s edge timeout can fire first and surface
|
// enough that Cloudflare's ~100s edge timeout can fire first and surface
|
||||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||||
// latencies and well below CF's edge timeout.
|
// latencies and well below CF's edge timeout.
|
||||||
//
|
//
|
||||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||||
// to response-headers-start. Configurable via
|
// to response-headers-start. Configurable via
|
||||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||||
// responses still work fine.
|
// responses still work fine.
|
||||||
//
|
//
|
||||||
// The point of (2) and (3) is to surface a *structured* 503 from
|
// The point of (2) and (3) is to surface a *structured* 503 from
|
||||||
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
|
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
|
||||||
@ -645,7 +645,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
|
|||||||
// the caller can retry once the workspace is back online (~10s).
|
// the caller can retry once the workspace is back online (~10s).
|
||||||
if status == "hibernated" {
|
if status == "hibernated" {
|
||||||
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
|
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
|
||||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
go h.RestartByID(workspaceID)
|
||||||
return "", &proxyA2AError{
|
return "", &proxyA2AError{
|
||||||
Status: http.StatusServiceUnavailable,
|
Status: http.StatusServiceUnavailable,
|
||||||
Headers: map[string]string{"Retry-After": "15"},
|
Headers: map[string]string{"Retry-After": "15"},
|
||||||
|
|||||||
@ -194,7 +194,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
|||||||
}
|
}
|
||||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
go h.RestartByID(workspaceID)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,7 +241,7 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
|
|||||||
}
|
}
|
||||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
go h.RestartByID(workspaceID)
|
||||||
return &proxyA2AError{
|
return &proxyA2AError{
|
||||||
Status: http.StatusServiceUnavailable,
|
Status: http.StatusServiceUnavailable,
|
||||||
Response: gin.H{
|
Response: gin.H{
|
||||||
@ -262,8 +262,8 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
|||||||
errWsName = workspaceID
|
errWsName = workspaceID
|
||||||
}
|
}
|
||||||
summary := "A2A request to " + errWsName + " failed: " + errMsg
|
summary := "A2A request to " + errWsName + " failed: " + errMsg
|
||||||
h.goAsync(func() {
|
go func(parent context.Context) {
|
||||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
@ -277,7 +277,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
|||||||
Status: "error",
|
Status: "error",
|
||||||
ErrorDetail: &errMsg,
|
ErrorDetail: &errMsg,
|
||||||
})
|
})
|
||||||
})
|
}(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
||||||
@ -298,19 +298,19 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
|||||||
// silent workspaces. Only update when callerID is a real workspace (not
|
// silent workspaces. Only update when callerID is a real workspace (not
|
||||||
// canvas, not a system caller) and the target returned 2xx/3xx.
|
// canvas, not a system caller) and the target returned 2xx/3xx.
|
||||||
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
|
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
|
||||||
h.goAsync(func() {
|
go func() {
|
||||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := db.DB.ExecContext(bgCtx,
|
if _, err := db.DB.ExecContext(bgCtx,
|
||||||
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
|
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
|
||||||
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
|
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
|
||||||
}
|
}
|
||||||
})
|
}()
|
||||||
}
|
}
|
||||||
summary := a2aMethod + " → " + wsNameForLog
|
summary := a2aMethod + " → " + wsNameForLog
|
||||||
toolTrace := extractToolTrace(respBody)
|
toolTrace := extractToolTrace(respBody)
|
||||||
h.goAsync(func() {
|
go func(parent context.Context) {
|
||||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||||
WorkspaceID: workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
@ -325,7 +325,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
|||||||
DurationMs: &durationMs,
|
DurationMs: &durationMs,
|
||||||
Status: logStatus,
|
Status: logStatus,
|
||||||
})
|
})
|
||||||
})
|
}(ctx)
|
||||||
|
|
||||||
if callerID == "" && statusCode < 400 {
|
if callerID == "" && statusCode < 400 {
|
||||||
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
|
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
|
||||||
@ -504,50 +504,26 @@ func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
|
|||||||
// reads in PR 3 — that's how a poll-mode workspace receives inbound A2A
|
// reads in PR 3 — that's how a poll-mode workspace receives inbound A2A
|
||||||
// without a public URL.
|
// without a public URL.
|
||||||
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
|
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
|
||||||
// DATA-LOSS FIX (internal#471 — poll-mode sibling of #1347/internal#470):
|
|
||||||
// this is the ONLY durable write of a poll-mode inbound message,
|
|
||||||
// including a canvas_user message (callerID == "") typed in the canvas
|
|
||||||
// chat. It MUST be SYNCHRONOUS and complete BEFORE the caller returns
|
|
||||||
// the synthetic {status:"queued"} 200 — otherwise the canvas sees the
|
|
||||||
// send acknowledged while the activity_logs row is still racing in a
|
|
||||||
// detached goroutine, and a workspace-server restart / deploy / OOM /
|
|
||||||
// EC2 hibernation between the 200 and the goroutine's commit loses the
|
|
||||||
// user's message permanently (chat-history reads activity_logs, so a
|
|
||||||
// missing row = message gone on reopen). Hongming's tenant is entirely
|
|
||||||
// poll-mode (4 external workspaces, no URL — verified empirically), so
|
|
||||||
// his reported loss is THIS path; #1347 (push-mode, persists AFTER the
|
|
||||||
// poll short-circuit) structurally cannot cover it.
|
|
||||||
//
|
|
||||||
// Mirrors persistUserMessageAtIngest's discipline:
|
|
||||||
// - context.WithoutCancel: a client disconnect on chat-exit (which
|
|
||||||
// cancels the inbound request ctx) MUST NOT abort this write.
|
|
||||||
// - SYNCHRONOUS (no goAsync): the row must be durable before the
|
|
||||||
// queued 200 is returned to the caller.
|
|
||||||
// - Best-effort: LogActivity already logs+swallows INSERT errors, so
|
|
||||||
// a hiccup never blocks or fails the user's send (behavior for
|
|
||||||
// that one request is never worse than the pre-fix async path).
|
|
||||||
// The post-commit broadcast still fires inside LogActivity; a missed
|
|
||||||
// WebSocket event is not data loss (the durable row is the truth the
|
|
||||||
// canvas re-reads on reopen).
|
|
||||||
insCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var wsName string
|
var wsName string
|
||||||
db.DB.QueryRowContext(insCtx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||||
if wsName == "" {
|
if wsName == "" {
|
||||||
wsName = workspaceID
|
wsName = workspaceID
|
||||||
}
|
}
|
||||||
summary := a2aMethod + " → " + wsName + " (queued for poll)"
|
summary := a2aMethod + " → " + wsName + " (queued for poll)"
|
||||||
LogActivity(insCtx, h.broadcaster, ActivityParams{
|
go func(parent context.Context) {
|
||||||
WorkspaceID: workspaceID,
|
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||||
ActivityType: "a2a_receive",
|
defer cancel()
|
||||||
SourceID: nilIfEmpty(callerID),
|
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||||
TargetID: &workspaceID,
|
WorkspaceID: workspaceID,
|
||||||
Method: &a2aMethod,
|
ActivityType: "a2a_receive",
|
||||||
Summary: &summary,
|
SourceID: nilIfEmpty(callerID),
|
||||||
RequestBody: json.RawMessage(body),
|
TargetID: &workspaceID,
|
||||||
Status: "ok",
|
Method: &a2aMethod,
|
||||||
})
|
Summary: &summary,
|
||||||
|
RequestBody: json.RawMessage(body),
|
||||||
|
Status: "ok",
|
||||||
|
})
|
||||||
|
}(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
|
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
|
||||||
|
|||||||
@ -54,7 +54,6 @@ func TestPreflight_ContainerRunning_ReturnsNil(t *testing.T) {
|
|||||||
_ = setupTestDB(t)
|
_ = setupTestDB(t)
|
||||||
stub := &preflightLocalProv{running: true, err: nil}
|
stub := &preflightLocalProv{running: true, err: nil}
|
||||||
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, h)
|
|
||||||
h.provisioner = stub
|
h.provisioner = stub
|
||||||
|
|
||||||
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
|
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
|
||||||
@ -187,8 +186,8 @@ func TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
callsIsRunning bool
|
callsIsRunning bool
|
||||||
callsContainerInspectRaw bool
|
callsContainerInspectRaw bool
|
||||||
callsRunningContainerNameDirect bool
|
callsRunningContainerNameDirect bool
|
||||||
)
|
)
|
||||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
|||||||
@ -262,7 +262,6 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
|
|||||||
allowLoopbackForTest(t)
|
allowLoopbackForTest(t)
|
||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
cp := &fakeCPProv{running: false}
|
cp := &fakeCPProv{running: false}
|
||||||
handler.SetCPProvisioner(cp)
|
handler.SetCPProvisioner(cp)
|
||||||
|
|
||||||
@ -325,7 +324,6 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
|
|||||||
allowLoopbackForTest(t)
|
allowLoopbackForTest(t)
|
||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
cp := &fakeCPProv{running: true}
|
cp := &fakeCPProv{running: true}
|
||||||
handler.SetCPProvisioner(cp)
|
handler.SetCPProvisioner(cp)
|
||||||
|
|
||||||
@ -515,7 +513,6 @@ func TestProxyA2A_AllowedSelf_SkipsAccessCheck(t *testing.T) {
|
|||||||
allowLoopbackForTest(t)
|
allowLoopbackForTest(t)
|
||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
|
|
||||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -664,18 +661,18 @@ func TestProxyA2A_CallerIDDerivedFromBearer(t *testing.T) {
|
|||||||
// (column order: workspace_id, activity_type, source_id, target_id, ...)
|
// (column order: workspace_id, activity_type, source_id, target_id, ...)
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
mock.ExpectExec("INSERT INTO activity_logs").
|
||||||
WithArgs(
|
WithArgs(
|
||||||
"ws-target", // $1 workspace_id
|
"ws-target", // $1 workspace_id
|
||||||
"a2a_receive", // $2 activity_type
|
"a2a_receive", // $2 activity_type
|
||||||
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
|
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
|
||||||
sqlmock.AnyArg(), // $4 target_id
|
sqlmock.AnyArg(), // $4 target_id
|
||||||
sqlmock.AnyArg(), // $5 method
|
sqlmock.AnyArg(), // $5 method
|
||||||
sqlmock.AnyArg(), // $6 summary
|
sqlmock.AnyArg(), // $6 summary
|
||||||
sqlmock.AnyArg(), // $7 request_body
|
sqlmock.AnyArg(), // $7 request_body
|
||||||
sqlmock.AnyArg(), // $8 response_body
|
sqlmock.AnyArg(), // $8 response_body
|
||||||
sqlmock.AnyArg(), // $9 tool_trace
|
sqlmock.AnyArg(), // $9 tool_trace
|
||||||
sqlmock.AnyArg(), // $10 duration_ms
|
sqlmock.AnyArg(), // $10 duration_ms
|
||||||
sqlmock.AnyArg(), // $11 status
|
sqlmock.AnyArg(), // $11 status
|
||||||
sqlmock.AnyArg(), // $12 error_detail
|
sqlmock.AnyArg(), // $12 error_detail
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
@ -1719,6 +1716,7 @@ func TestDispatchA2A_RejectsUnsafeURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- handleA2ADispatchError ---
|
// --- handleA2ADispatchError ---
|
||||||
|
|
||||||
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||||
@ -1805,7 +1803,6 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
cp := &fakeCPProv{running: false}
|
cp := &fakeCPProv{running: false}
|
||||||
handler.SetCPProvisioner(cp)
|
handler.SetCPProvisioner(cp)
|
||||||
|
|
||||||
@ -1958,7 +1955,6 @@ func TestLogA2AFailure_Smoke(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
|
|
||||||
// Sync workspace-name lookup (called in the caller goroutine).
|
// Sync workspace-name lookup (called in the caller goroutine).
|
||||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||||
@ -1977,7 +1973,6 @@ func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
|
|
||||||
// Empty name from DB → summary uses the workspaceID as the name.
|
// Empty name from DB → summary uses the workspaceID as the name.
|
||||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||||
@ -1994,7 +1989,6 @@ func TestLogA2ASuccess_Smoke(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||||
WithArgs("ws-ok").
|
WithArgs("ws-ok").
|
||||||
@ -2011,7 +2005,6 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||||
WithArgs("ws-err").
|
WithArgs("ws-err").
|
||||||
|
|||||||
@ -26,19 +26,14 @@ import (
|
|||||||
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
|
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
|
||||||
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
|
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
|
||||||
// Uses the same global db.DB as setupTestDB so the handler can use it.
|
// Uses the same global db.DB as setupTestDB so the handler can use it.
|
||||||
//
|
|
||||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
|
||||||
// that tests running after this one are not polluted by a closed mock.
|
|
||||||
// Same fix as setupTestDB (handlers_test.go); same root cause as mc#975.
|
|
||||||
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
t.Fatalf("failed to create sqlmock: %v", err)
|
||||||
}
|
}
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
t.Cleanup(func() { mockDB.Close() })
|
||||||
return mock
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,54 +80,6 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
// extractExpiresInSeconds
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestExtractExpiresInSeconds_valid(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
|
|
||||||
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
|
|
||||||
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
|
|
||||||
{"nested message — not affected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
|
||||||
t.Errorf("extractExpiresInSeconds = %d, want %d", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
|
|
||||||
{"missing expires_in_seconds", `{"params":{"message":{"role":"user"}}}`, 0},
|
|
||||||
{"no params at all", `{"method":"message/send"}`, 0},
|
|
||||||
{"malformed JSON", `not json`, 0},
|
|
||||||
{"empty body", ``, 0},
|
|
||||||
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
|
|
||||||
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
|
||||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 30},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
|
||||||
t.Errorf("extractExpiresInSeconds(%q) = %d, want %d", tc.body, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDelegationIDFromBody(t *testing.T) {
|
func TestExtractDelegationIDFromBody(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -482,13 +482,6 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrTalkToUserDisabled) {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
|
||||||
"error": "talk_to_user_disabled",
|
|
||||||
"hint": "This workspace is not allowed to send messages directly to the user. Forward your update to a parent workspace using delegate_task — they may be able to reach the user.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -388,13 +388,9 @@ func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
|
|||||||
// ---------- Activity type allowlist (#125: memory_write added) ----------
|
// ---------- Activity type allowlist (#125: memory_write added) ----------
|
||||||
|
|
||||||
func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
||||||
mockDB, mock, err := sqlmock.New()
|
mockDB, mock, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
@ -417,13 +413,9 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
||||||
mockDB, _, err := sqlmock.New()
|
mockDB, _, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewActivityHandler(broadcaster)
|
handler := NewActivityHandler(broadcaster)
|
||||||
@ -455,18 +447,14 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
|
|||||||
// - Have source_id NULL (canvas-source filter)
|
// - Have source_id NULL (canvas-source filter)
|
||||||
// - Carry the message text in response_body so extractResponseText
|
// - Carry the message text in response_body so extractResponseText
|
||||||
// can reconstruct the agent reply on reload
|
// can reconstruct the agent reply on reload
|
||||||
mockDB, mock, err := sqlmock.New()
|
mockDB, mock, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
// Workspace existence check
|
// Workspace existence check
|
||||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||||
WithArgs("ws-notify").
|
WithArgs("ws-notify").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||||
|
|
||||||
// Persistence INSERT — verify shape
|
// Persistence INSERT — verify shape
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
@ -503,17 +491,13 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
|
|||||||
// download chips after a page reload. Without `parts`, the bubble
|
// download chips after a page reload. Without `parts`, the bubble
|
||||||
// shows up but the attachment chip is silently dropped on every
|
// shows up but the attachment chip is silently dropped on every
|
||||||
// refresh.
|
// refresh.
|
||||||
mockDB, mock, err := sqlmock.New()
|
mockDB, mock, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||||
WithArgs("ws-attach").
|
WithArgs("ws-attach").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||||
|
|
||||||
// Capture the JSONB arg so we can assert on the persisted shape
|
// Capture the JSONB arg so we can assert on the persisted shape
|
||||||
// AFTER the call (must include parts[].kind=file so reload
|
// AFTER the call (must include parts[].kind=file so reload
|
||||||
@ -581,13 +565,9 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
mockDB, _, err := sqlmock.New()
|
mockDB, _, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
// No DB expectations — handler must reject with 400 BEFORE
|
// No DB expectations — handler must reject with 400 BEFORE
|
||||||
// reaching SELECT/INSERT. sqlmock will fail "expectations not met"
|
// reaching SELECT/INSERT. sqlmock will fail "expectations not met"
|
||||||
// only if the handler unexpectedly queries.
|
// only if the handler unexpectedly queries.
|
||||||
@ -632,17 +612,13 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
|
|||||||
// WebSocket push (which the user is already seeing in their open
|
// WebSocket push (which the user is already seeing in their open
|
||||||
// canvas). Pre-fix the WS push always succeeded; we don't want
|
// canvas). Pre-fix the WS push always succeeded; we don't want
|
||||||
// the new persistence step to regress that path.
|
// the new persistence step to regress that path.
|
||||||
mockDB, mock, err := sqlmock.New()
|
mockDB, mock, _ := sqlmock.New()
|
||||||
if err != nil {
|
defer mockDB.Close()
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT name, talk_to_user_enabled FROM workspaces`).
|
mock.ExpectQuery(`SELECT name FROM workspaces`).
|
||||||
WithArgs("ws-x").
|
WithArgs("ws-x").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("DD", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("DD"))
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WillReturnError(fmt.Errorf("simulated db hiccup"))
|
WillReturnError(fmt.Errorf("simulated db hiccup"))
|
||||||
|
|
||||||
|
|||||||
@ -44,8 +44,8 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
|
|||||||
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
|
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
|
||||||
// Update both when a new template is added.
|
// Update both when a new template is added.
|
||||||
var AllRuntimes = []string{
|
var AllRuntimes = []string{
|
||||||
"claude-code", "langgraph", "autogen",
|
"claude-code", "langgraph", "crewai", "autogen",
|
||||||
"hermes", "openclaw",
|
"deepagents", "hermes", "gemini-cli", "openclaw",
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
|
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
|
||||||
|
|||||||
@ -54,11 +54,6 @@ import (
|
|||||||
// timeout) surface as wrapped errors and should be treated as 503.
|
// timeout) surface as wrapped errors and should be treated as 503.
|
||||||
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
|
var ErrWorkspaceNotFound = errors.New("agent_message: workspace not found")
|
||||||
|
|
||||||
// ErrTalkToUserDisabled is returned when the workspace has
|
|
||||||
// talk_to_user_enabled=false. Callers surface HTTP 403 so the Python tool
|
|
||||||
// can detect it and suggest forwarding to a parent workspace.
|
|
||||||
var ErrTalkToUserDisabled = errors.New("agent_message: talk_to_user disabled")
|
|
||||||
|
|
||||||
// AgentMessageAttachment is one file attached to an agent → user
|
// AgentMessageAttachment is one file attached to an agent → user
|
||||||
// message. Identical to handlers.NotifyAttachment in field set; kept
|
// message. Identical to handlers.NotifyAttachment in field set; kept
|
||||||
// distinct so the writer's API doesn't import a handler type with HTTP
|
// distinct so the writer's API doesn't import a handler type with HTTP
|
||||||
@ -112,20 +107,16 @@ func (w *AgentMessageWriter) Send(
|
|||||||
// notify call surfaced as "workspace not found" and masked real
|
// notify call surfaced as "workspace not found" and masked real
|
||||||
// incidents in the alert path.
|
// incidents in the alert path.
|
||||||
var wsName string
|
var wsName string
|
||||||
var talkToUserEnabled bool
|
|
||||||
err := w.db.QueryRowContext(ctx,
|
err := w.db.QueryRowContext(ctx,
|
||||||
`SELECT name, talk_to_user_enabled FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`,
|
||||||
workspaceID,
|
workspaceID,
|
||||||
).Scan(&wsName, &talkToUserEnabled)
|
).Scan(&wsName)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ErrWorkspaceNotFound
|
return ErrWorkspaceNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("agent_message: workspace lookup: %w", err)
|
return fmt.Errorf("agent_message: workspace lookup: %w", err)
|
||||||
}
|
}
|
||||||
if !talkToUserEnabled {
|
|
||||||
return ErrTalkToUserDisabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Build broadcast payload + WS-emit. Same shape that ChatTab's
|
// 2. Build broadcast payload + WS-emit. Same shape that ChatTab's
|
||||||
// AGENT_MESSAGE handler in canvas/src/store/canvas-events.ts has
|
// AGENT_MESSAGE handler in canvas/src/store/canvas-events.ts has
|
||||||
|
|||||||
@ -88,9 +88,9 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-1").
|
WithArgs("ws-1").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||||
|
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
@ -116,9 +116,9 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-att").
|
WithArgs("ws-att").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||||
|
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
mock.ExpectExec(`INSERT INTO activity_logs.*'a2a_receive'.*'notify'`).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
@ -173,9 +173,9 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
|||||||
emitter := &capturingEmitter{}
|
emitter := &capturingEmitter{}
|
||||||
w := NewAgentMessageWriter(db.DB, emitter)
|
w := NewAgentMessageWriter(db.DB, emitter)
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-missing").
|
WithArgs("ws-missing").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}))
|
||||||
|
|
||||||
err := w.Send(context.Background(), "ws-missing", "lost in the void", nil)
|
err := w.Send(context.Background(), "ws-missing", "lost in the void", nil)
|
||||||
if !errors.Is(err, ErrWorkspaceNotFound) {
|
if !errors.Is(err, ErrWorkspaceNotFound) {
|
||||||
@ -202,9 +202,9 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-dbfail").
|
WithArgs("ws-dbfail").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||||
|
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WillReturnError(errors.New("transient db error"))
|
WillReturnError(errors.New("transient db error"))
|
||||||
@ -223,9 +223,9 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
|||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-trunc").
|
WithArgs("ws-trunc").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Ryan", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ryan"))
|
||||||
|
|
||||||
longMsg := strings.Repeat("x", 200)
|
longMsg := strings.Repeat("x", 200)
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
@ -263,9 +263,9 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
|||||||
emitter := &capturingEmitter{}
|
emitter := &capturingEmitter{}
|
||||||
w := NewAgentMessageWriter(db.DB, emitter)
|
w := NewAgentMessageWriter(db.DB, emitter)
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-bc").
|
WithArgs("ws-bc").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("Workspace Name", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Workspace Name"))
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
|
||||||
@ -315,7 +315,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
|||||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||||
|
|
||||||
transientErr := errors.New("connection refused")
|
transientErr := errors.New("connection refused")
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-dbdown").
|
WithArgs("ws-dbdown").
|
||||||
WillReturnError(transientErr)
|
WillReturnError(transientErr)
|
||||||
|
|
||||||
@ -350,9 +350,9 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
|||||||
// the byte-slice bug.
|
// the byte-slice bug.
|
||||||
msg := strings.Repeat("你", 200)
|
msg := strings.Repeat("你", 200)
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-cjk").
|
WithArgs("ws-cjk").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("CEO Ryan PC", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("CEO Ryan PC"))
|
||||||
|
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
@ -395,9 +395,9 @@ func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
|
|||||||
emitter := &capturingEmitter{}
|
emitter := &capturingEmitter{}
|
||||||
w := NewAgentMessageWriter(db.DB, emitter)
|
w := NewAgentMessageWriter(db.DB, emitter)
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
mock.ExpectQuery("SELECT name FROM workspaces").
|
||||||
WithArgs("ws-noatt").
|
WithArgs("ws-noatt").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"name", "talk_to_user_enabled"}).AddRow("X", true))
|
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("X"))
|
||||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
|
||||||
|
|||||||
@ -116,9 +116,6 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
|||||||
"created_at": createdAt,
|
"created_at": createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
log.Printf("ListPendingApprovals rows.Err: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, approvals)
|
c.JSON(http.StatusOK, approvals)
|
||||||
}
|
}
|
||||||
@ -158,9 +155,6 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
|||||||
"created_at": createdAt,
|
"created_at": createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
log.Printf("ListApprovals rows.Err workspace=%s: %v", workspaceID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, approvals)
|
c.JSON(http.StatusOK, approvals)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -328,207 +327,6 @@ func TestChannelHandler_Send_EmptyText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Test (send outbound) ====================
|
|
||||||
|
|
||||||
// TestChannelHandler_Test_Success exercises the /channels/:channelId/test endpoint
|
|
||||||
// with a mock SendAdapter so the full success path is covered without hitting real
|
|
||||||
// Telegram/Slack/etc. APIs.
|
|
||||||
func TestChannelHandler_Test_Success(t *testing.T) {
|
|
||||||
mock := setupTestDB(t)
|
|
||||||
setupTestRedis(t)
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
|
||||||
|
|
||||||
mockAdapter := &channels.MockSendAdapter{Err: nil}
|
|
||||||
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
|
|
||||||
if ct == "telegram" {
|
|
||||||
return mockAdapter, true
|
|
||||||
}
|
|
||||||
return channels.GetSendAdapter(ct)
|
|
||||||
})
|
|
||||||
t.Cleanup(channels.ResetSendAdapters)
|
|
||||||
|
|
||||||
// loadChannel → valid row
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-test-ok").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{
|
|
||||||
"id", "workspace_id", "channel_type", "channel_config",
|
|
||||||
"enabled", "allowed_users",
|
|
||||||
}).AddRow("ch-test-ok", "ws-1", "telegram",
|
|
||||||
`{"bot_token":"123:AAA","chat_id":"-100"}`,
|
|
||||||
true, `[]`))
|
|
||||||
|
|
||||||
// UPDATE message_count + last_message_at
|
|
||||||
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
|
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-test-ok/test", nil)
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-test-ok"}}
|
|
||||||
|
|
||||||
handler.Test(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
if resp["status"] != "ok" {
|
|
||||||
t.Errorf("expected status 'ok', got %v", resp["status"])
|
|
||||||
}
|
|
||||||
if mockAdapter.Calls != 1 {
|
|
||||||
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
|
|
||||||
}
|
|
||||||
if mockAdapter.SentChat != "-100" {
|
|
||||||
t.Errorf("expected chat_id '-100', got %q", mockAdapter.SentChat)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations not met: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChannelHandler_Test_ChannelNotFound verifies that when loadChannel returns
|
|
||||||
// no rows, the Test handler returns 500 with a "test message failed" error.
|
|
||||||
func TestChannelHandler_Test_ChannelNotFound(t *testing.T) {
|
|
||||||
mock := setupTestDB(t)
|
|
||||||
setupTestRedis(t)
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
|
||||||
|
|
||||||
// loadChannel → no rows
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-missing").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{
|
|
||||||
"id", "workspace_id", "channel_type", "channel_config",
|
|
||||||
"enabled", "allowed_users",
|
|
||||||
}))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-missing/test", nil)
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-missing"}}
|
|
||||||
|
|
||||||
handler.Test(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
if resp["error"] != "test message failed" {
|
|
||||||
t.Errorf("expected error 'test message failed', got %v", resp["error"])
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations not met: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChannelHandler_Send_Success covers the full outbound send success path:
|
|
||||||
// budget check passes → loadChannel → mock SendMessage succeeds → UPDATE count → 200.
|
|
||||||
func TestChannelHandler_Send_Success(t *testing.T) {
|
|
||||||
mock := setupTestDB(t)
|
|
||||||
setupTestRedis(t)
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
|
||||||
|
|
||||||
mockAdapter := &channels.MockSendAdapter{Err: nil}
|
|
||||||
channels.SetGetSendAdapter(func(ct string) (channels.SendAdapter, bool) {
|
|
||||||
if ct == "telegram" {
|
|
||||||
return mockAdapter, true
|
|
||||||
}
|
|
||||||
return channels.GetSendAdapter(ct)
|
|
||||||
})
|
|
||||||
t.Cleanup(channels.ResetSendAdapters)
|
|
||||||
|
|
||||||
// Budget check: count=0, no budget limit
|
|
||||||
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-send-ok").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
|
|
||||||
AddRow(0, nil))
|
|
||||||
|
|
||||||
// loadChannel → valid row
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-send-ok").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{
|
|
||||||
"id", "workspace_id", "channel_type", "channel_config",
|
|
||||||
"enabled", "allowed_users",
|
|
||||||
}).AddRow("ch-send-ok", "ws-1", "telegram",
|
|
||||||
`{"bot_token":"123:AAA","chat_id":"-100"}`,
|
|
||||||
true, `[]`))
|
|
||||||
|
|
||||||
// UPDATE message_count
|
|
||||||
mock.ExpectExec("UPDATE workspace_channels SET last_message_at").
|
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"text": "hello from test"})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-ok/send", bytes.NewReader(body))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-ok"}}
|
|
||||||
|
|
||||||
handler.Send(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
if resp["status"] != "sent" {
|
|
||||||
t.Errorf("expected status 'sent', got %v", resp["status"])
|
|
||||||
}
|
|
||||||
if mockAdapter.Calls != 1 {
|
|
||||||
t.Errorf("expected SendMessage called once, got %d", mockAdapter.Calls)
|
|
||||||
}
|
|
||||||
if mockAdapter.SentText != "hello from test" {
|
|
||||||
t.Errorf("expected 'hello from test', got %q", mockAdapter.SentText)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations not met: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChannelHandler_Send_ChannelNotFound verifies that after the budget check
|
|
||||||
// passes, a missing channel returns 500 (not 404) with "send failed".
|
|
||||||
func TestChannelHandler_Send_ChannelNotFound(t *testing.T) {
|
|
||||||
mock := setupTestDB(t)
|
|
||||||
setupTestRedis(t)
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
|
||||||
|
|
||||||
// Budget check passes (NULL budget → no limit)
|
|
||||||
mock.ExpectQuery("SELECT message_count, channel_budget FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-send-missing").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"message_count", "channel_budget"}).
|
|
||||||
AddRow(0, nil))
|
|
||||||
|
|
||||||
// loadChannel → no rows
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM workspace_channels WHERE id").
|
|
||||||
WithArgs("ch-send-missing").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{
|
|
||||||
"id", "workspace_id", "channel_type", "channel_config",
|
|
||||||
"enabled", "allowed_users",
|
|
||||||
}))
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"text": "hello"})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(w)
|
|
||||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-1/channels/ch-send-missing/send", bytes.NewReader(body))
|
|
||||||
c.Request.Header.Set("Content-Type", "application/json")
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "channelId", Value: "ch-send-missing"}}
|
|
||||||
|
|
||||||
handler.Send(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("expected 500 for missing channel, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
||||||
if resp["error"] != "send failed" {
|
|
||||||
t.Errorf("expected error 'send failed', got %v", resp["error"])
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations not met: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Webhook ====================
|
// ==================== Webhook ====================
|
||||||
|
|
||||||
func TestChannelHandler_Webhook_UnknownType(t *testing.T) {
|
func TestChannelHandler_Webhook_UnknownType(t *testing.T) {
|
||||||
@ -566,20 +364,6 @@ func TestChannelHandler_Discover_MissingToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
|
func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
|
||||||
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { mockDB.Close() })
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB })
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
|
|
||||||
WithArgs("ws-test").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
|
|
||||||
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
handler := NewChannelHandler(newTestChannelManager())
|
||||||
|
|
||||||
// #329: workspace_id required — include so we actually reach the
|
// #329: workspace_id required — include so we actually reach the
|
||||||
@ -603,20 +387,6 @@ func TestChannelHandler_Discover_UnsupportedType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) {
|
func TestChannelHandler_Discover_InvalidBotToken(t *testing.T) {
|
||||||
// Set up db.DB so PausePollersForToken (called inside Discover) doesn't panic.
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { mockDB.Close() })
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB })
|
|
||||||
|
|
||||||
mock.ExpectQuery(`SELECT id, channel_config FROM workspace_channels WHERE enabled = true AND workspace_id`).
|
|
||||||
WithArgs("ws-test").
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "channel_config"}))
|
|
||||||
|
|
||||||
handler := NewChannelHandler(newTestChannelManager())
|
handler := NewChannelHandler(newTestChannelManager())
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -263,20 +262,14 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
|
|||||||
"task": body.Task,
|
"task": body.Task,
|
||||||
"delegation_id": delegationID,
|
"delegation_id": delegationID,
|
||||||
})
|
})
|
||||||
// Store delegation_id in response_body so agent check_delegation_status
|
|
||||||
// (which reads response_body->>delegation_id) can locate this row even
|
|
||||||
// when request_body hasn't propagated yet. Fixes mc#984.
|
|
||||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"delegation_id": delegationID,
|
|
||||||
})
|
|
||||||
var idemArg interface{}
|
var idemArg interface{}
|
||||||
if body.IdempotencyKey != "" {
|
if body.IdempotencyKey != "" {
|
||||||
idemArg = body.IdempotencyKey
|
idemArg = body.IdempotencyKey
|
||||||
}
|
}
|
||||||
_, err := db.DB.ExecContext(ctx, `
|
_, err := db.DB.ExecContext(ctx, `
|
||||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status, idempotency_key)
|
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status, idempotency_key)
|
||||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'pending', $7)
|
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending', $6)
|
||||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON), idemArg)
|
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), idemArg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// RFC #2829 #318 — mirror to the durable delegations ledger
|
// RFC #2829 #318 — mirror to the durable delegations ledger
|
||||||
// (gated by DELEGATION_LEDGER_WRITE; default off → no-op).
|
// (gated by DELEGATION_LEDGER_WRITE; default off → no-op).
|
||||||
@ -551,15 +544,10 @@ func (h *DelegationHandler) Record(c *gin.Context) {
|
|||||||
"task": body.Task,
|
"task": body.Task,
|
||||||
"delegation_id": body.DelegationID,
|
"delegation_id": body.DelegationID,
|
||||||
})
|
})
|
||||||
// Store delegation_id in response_body so agent check_delegation_status
|
|
||||||
// can locate this row. Fixes mc#984.
|
|
||||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"delegation_id": body.DelegationID,
|
|
||||||
})
|
|
||||||
if _, err := db.DB.ExecContext(ctx, `
|
if _, err := db.DB.ExecContext(ctx, `
|
||||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, response_body, status)
|
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
|
||||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6::jsonb, 'dispatched')
|
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'dispatched')
|
||||||
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON), string(respJSON)); err != nil {
|
`, sourceID, sourceID, body.TargetID, "Delegating to "+body.TargetID, string(taskJSON)); err != nil {
|
||||||
log.Printf("Delegation Record: insert failed for %s: %v", body.DelegationID, err)
|
log.Printf("Delegation Record: insert failed for %s: %v", body.DelegationID, err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record delegation"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record delegation"})
|
||||||
return
|
return
|
||||||
@ -699,8 +687,7 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
|||||||
|
|
||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var delegationID, callerID, calleeID, taskPreview, status string
|
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
|
||||||
var resultPreview, errorDetail sql.NullString
|
|
||||||
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&delegationID, &callerID, &calleeID, &taskPreview,
|
&delegationID, &callerID, &calleeID, &taskPreview,
|
||||||
@ -719,11 +706,11 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
|
|||||||
"updated_at": updatedAt,
|
"updated_at": updatedAt,
|
||||||
"_ledger": true, // marker so callers know this row is from the ledger
|
"_ledger": true, // marker so callers know this row is from the ledger
|
||||||
}
|
}
|
||||||
if resultPreview.Valid && resultPreview.String != "" {
|
if resultPreview != "" {
|
||||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview.String, 300)
|
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
|
||||||
}
|
}
|
||||||
if errorDetail.Valid && errorDetail.String != "" {
|
if errorDetail != "" {
|
||||||
entry["error"] = errorDetail.String
|
entry["error"] = errorDetail
|
||||||
}
|
}
|
||||||
if lastHeartbeat != nil {
|
if lastHeartbeat != nil {
|
||||||
entry["last_heartbeat"] = lastHeartbeat
|
entry["last_heartbeat"] = lastHeartbeat
|
||||||
|
|||||||
@ -1,224 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// extractResponseText tests — walks A2A JSON-RPC response bodies and
|
|
||||||
// returns the first text part, falling back to raw body on parse failures.
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartsWithTextKind(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "text", "text": "hello world"},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "second part"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "hello world", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartNotTextKind(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "image", "data": "base64..."},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "visible"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "visible", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartsEmpty(t *testing.T) {
|
|
||||||
// Empty parts array — falls through to artifacts, then raw body
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
// Falls through to raw body (which is the JSON string)
|
|
||||||
result := extractResponseText(body)
|
|
||||||
assert.NotEmpty(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ArtifactPartsWithText(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"kind": "file",
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "text", "text": "artifact text"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "artifact text", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ArtifactPartNotTextKind(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"kind": "code",
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "image", "data": "..."},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "code comment"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "code comment", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ArtifactsEmpty(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
result := extractResponseText(body)
|
|
||||||
// Falls back to raw body
|
|
||||||
assert.Equal(t, string(body), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_NoResult(t *testing.T) {
|
|
||||||
// No "result" key at all — falls back to raw body
|
|
||||||
body := []byte(`{"error": {"code": -32600, "message": "Invalid Request"}}`)
|
|
||||||
result := extractResponseText(body)
|
|
||||||
assert.Equal(t, string(body), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ResultNotMap(t *testing.T) {
|
|
||||||
// result is a string, not a map — falls back to raw body
|
|
||||||
body := []byte(`{"result": "just a string"}`)
|
|
||||||
result := extractResponseText(body)
|
|
||||||
assert.Equal(t, string(body), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_NonJSONBody(t *testing.T) {
|
|
||||||
// Non-JSON bytes — returns the raw string
|
|
||||||
body := []byte("plain text response, not JSON at all")
|
|
||||||
result := extractResponseText(body)
|
|
||||||
assert.Equal(t, "plain text response, not JSON at all", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartWithNilText(t *testing.T) {
|
|
||||||
// Text field is nil — kind is "text" but text is nil, should skip
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "text", "text": nil},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "found"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "found", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ArtifactPartWithNilText(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": "text", "text": nil},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "artifact-found"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "artifact-found", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartsWithNonMapElement(t *testing.T) {
|
|
||||||
// parts contains a non-map element — should be skipped gracefully
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
"not a map",
|
|
||||||
123,
|
|
||||||
nil,
|
|
||||||
map[string]interface{}{"kind": "text", "text": "parsed"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "parsed", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_ArtifactWithNonMapElement(t *testing.T) {
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{},
|
|
||||||
"artifacts": []interface{}{
|
|
||||||
"not a map",
|
|
||||||
nil,
|
|
||||||
map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
"not a map",
|
|
||||||
map[string]interface{}{"kind": "text", "text": "safe"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "safe", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_PartKindNotString(t *testing.T) {
|
|
||||||
// kind is an integer, not a string — should be skipped
|
|
||||||
resp := map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"parts": []interface{}{
|
|
||||||
map[string]interface{}{"kind": 123, "text": "ignored"},
|
|
||||||
map[string]interface{}{"kind": "text", "text": "found"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(resp)
|
|
||||||
assert.Equal(t, "found", extractResponseText(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_EmptyResponse(t *testing.T) {
|
|
||||||
body := []byte("{}")
|
|
||||||
result := extractResponseText(body)
|
|
||||||
// Falls back to raw "{}"
|
|
||||||
assert.Equal(t, "{}", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_NilBody(t *testing.T) {
|
|
||||||
// nil byte slice — string(nil) = ""
|
|
||||||
result := extractResponseText(nil)
|
|
||||||
assert.Equal(t, "", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractResponseText_WhitespaceBody(t *testing.T) {
|
|
||||||
body := []byte(" \n\t ")
|
|
||||||
result := extractResponseText(body)
|
|
||||||
// Unmarshals to empty map, no result, returns raw string
|
|
||||||
assert.Equal(t, " \n\t ", result)
|
|
||||||
}
|
|
||||||
@ -1,495 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
// delegation_list_test.go — unit tests for listDelegationsFromLedger and
|
|
||||||
// listDelegationsFromActivityLogs. Both methods are the data-backend of the
|
|
||||||
// ListDelegations handler; coverage was missing (cf. infra-sre review of PR #942).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------- listDelegationsFromLedger ----------
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_EmptyResult(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
|
||||||
"status", "result_preview", "error_detail",
|
|
||||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
|
||||||
})
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("empty result: expected nil, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_SingleRow(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// Use time.Time{} for nullable *time.Time columns — sqlmock passes the
|
|
||||||
// zero value to the handler's scan destination. The handler checks Valid
|
|
||||||
// before using each nullable field, so zero values are safe.
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
|
||||||
"status", "result_preview", "error_detail",
|
|
||||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
|
||||||
}).AddRow(
|
|
||||||
"del-1", "ws-1", "ws-2", "summarise the report",
|
|
||||||
"completed", "the report is about Q1",
|
|
||||||
"", now, now, now, now,
|
|
||||||
)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
|
||||||
}
|
|
||||||
e := got[0]
|
|
||||||
if e["delegation_id"] != "del-1" {
|
|
||||||
t.Errorf("delegation_id: got %v, want del-1", e["delegation_id"])
|
|
||||||
}
|
|
||||||
if e["source_id"] != "ws-1" {
|
|
||||||
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
|
|
||||||
}
|
|
||||||
if e["target_id"] != "ws-2" {
|
|
||||||
t.Errorf("target_id: got %v, want ws-2", e["target_id"])
|
|
||||||
}
|
|
||||||
if e["status"] != "completed" {
|
|
||||||
t.Errorf("status: got %v, want completed", e["status"])
|
|
||||||
}
|
|
||||||
if e["response_preview"] != "the report is about Q1" {
|
|
||||||
t.Errorf("response_preview: got %v", e["response_preview"])
|
|
||||||
}
|
|
||||||
if _, ok := e["error"]; ok {
|
|
||||||
t.Errorf("error should be absent when empty, got %v", e["error"])
|
|
||||||
}
|
|
||||||
if e["_ledger"] != true {
|
|
||||||
t.Errorf("_ledger marker: got %v, want true", e["_ledger"])
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
|
||||||
"status", "result_preview", "error_detail",
|
|
||||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
|
||||||
}).
|
|
||||||
AddRow("del-a", "ws-1", "ws-2", "task a", "in_progress", "", "", now, now, now, now).
|
|
||||||
AddRow("del-b", "ws-1", "ws-3", "task b", "failed", "", "timeout", now, now, now, now).
|
|
||||||
AddRow("del-c", "ws-1", "ws-4", "task c", "completed", "result c", "", now, now, now, now)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
if len(got) != 3 {
|
|
||||||
t.Fatalf("expected 3 entries, got %d", len(got))
|
|
||||||
}
|
|
||||||
if got[0]["delegation_id"] != "del-a" || got[1]["delegation_id"] != "del-b" || got[2]["delegation_id"] != "del-c" {
|
|
||||||
t.Errorf("unexpected order: %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_NullsOmitted(t *testing.T) {
|
|
||||||
// last_heartbeat, deadline, result_preview, error_detail are all NULL.
|
|
||||||
// Handler must not panic and must omit those keys from the map.
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
|
||||||
"status", "result_preview", "error_detail",
|
|
||||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
|
||||||
}).
|
|
||||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", nil, nil, nil, nil, now, now)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
|
||||||
}
|
|
||||||
e := got[0]
|
|
||||||
if _, ok := e["last_heartbeat"]; ok {
|
|
||||||
t.Error("last_heartbeat should be absent when NULL")
|
|
||||||
}
|
|
||||||
if _, ok := e["deadline"]; ok {
|
|
||||||
t.Error("deadline should be absent when NULL")
|
|
||||||
}
|
|
||||||
if _, ok := e["response_preview"]; ok {
|
|
||||||
t.Error("response_preview should be absent when NULL result_preview")
|
|
||||||
}
|
|
||||||
if _, ok := e["error"]; ok {
|
|
||||||
t.Error("error should be absent when NULL error_detail")
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_QueryError(t *testing.T) {
|
|
||||||
// Query failure returns nil — graceful fallback, no panic.
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnError(context.DeadlineExceeded)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("query error: expected nil, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromLedger_RowsErr(t *testing.T) {
|
|
||||||
// rows.Err() mid-stream: handler collects partial results and returns them.
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
|
|
||||||
// on first call — the row never scans, result stays nil. To get partial
|
|
||||||
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
|
|
||||||
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
|
|
||||||
// rows.Err() is error, handler returns partial result.
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
|
||||||
"status", "result_preview", "error_detail",
|
|
||||||
"last_heartbeat", "deadline", "created_at", "updated_at",
|
|
||||||
}).
|
|
||||||
AddRow("del-1", "ws-1", "ws-2", "task", "queued", "", "", now, now, now, now).
|
|
||||||
AddRow("del-2", "ws-1", "ws-3", "another task", "queued", "", "", now, now, now, now).
|
|
||||||
RowError(1, context.DeadlineExceeded)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM delegations").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
|
|
||||||
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
|
|
||||||
// Handler logs the error but returns result (partial results because result != nil).
|
|
||||||
if got == nil || len(got) != 1 {
|
|
||||||
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListDelegationsFromLedger_ScanError is removed.
|
|
||||||
//
|
|
||||||
// In Go 1.25 sqlmock.NewRows validates column count at AddRow() time and
|
|
||||||
// panics when len(values) != len(columns). The old pattern
|
|
||||||
// sqlmock.NewRows([]string{}).AddRow("only-one-col")
|
|
||||||
// therefore panics in test SETUP, not inside the handler. The handler has no
|
|
||||||
// recover(), so a scan panic would propagate out of listDelegationsFromLedger
|
|
||||||
// and crash the process — this is the correct behaviour (not silently skipping
|
|
||||||
// a row). The correct way to cover this path is a real-DB integration test.
|
|
||||||
//
|
|
||||||
// ---------- listDelegationsFromActivityLogs ----------
|
|
||||||
|
|
||||||
func TestListDelegationsFromActivityLogs_EmptyResult(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"id", "activity_type", "source_id", "target_id",
|
|
||||||
"summary", "status", "error_detail",
|
|
||||||
"response_preview", "delegation_id", "created_at",
|
|
||||||
})
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("empty result: expected empty slice, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromActivityLogs_SingleDelegateRow(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"id", "activity_type", "source_id", "target_id",
|
|
||||||
"summary", "status", "error_detail",
|
|
||||||
"response_preview", "delegation_id", "created_at",
|
|
||||||
}).AddRow(
|
|
||||||
"act-1", "delegate",
|
|
||||||
"ws-1", "ws-2",
|
|
||||||
"analyse Q1 numbers",
|
|
||||||
"in_progress",
|
|
||||||
"", "", "",
|
|
||||||
now,
|
|
||||||
)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
|
||||||
}
|
|
||||||
e := got[0]
|
|
||||||
if e["id"] != "act-1" {
|
|
||||||
t.Errorf("id: got %v, want act-1", e["id"])
|
|
||||||
}
|
|
||||||
if e["type"] != "delegate" {
|
|
||||||
t.Errorf("type: got %v, want delegate", e["type"])
|
|
||||||
}
|
|
||||||
if e["source_id"] != "ws-1" {
|
|
||||||
t.Errorf("source_id: got %v, want ws-1", e["source_id"])
|
|
||||||
}
|
|
||||||
if e["target_id"] != "ws-2" {
|
|
||||||
t.Errorf("target_id: got %v, want ws-2", e["target_id"])
|
|
||||||
}
|
|
||||||
if e["summary"] != "analyse Q1 numbers" {
|
|
||||||
t.Errorf("summary: got %v", e["summary"])
|
|
||||||
}
|
|
||||||
if e["status"] != "in_progress" {
|
|
||||||
t.Errorf("status: got %v", e["status"])
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromActivityLogs_DelegateResultWithError(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"id", "activity_type", "source_id", "target_id",
|
|
||||||
"summary", "status", "error_detail",
|
|
||||||
"response_preview", "delegation_id", "created_at",
|
|
||||||
}).AddRow(
|
|
||||||
"act-2", "delegate_result",
|
|
||||||
"ws-1", "ws-2",
|
|
||||||
"result summary",
|
|
||||||
"failed",
|
|
||||||
"Callee workspace not reachable",
|
|
||||||
`{"text":"the result body text"}`,
|
|
||||||
"del-abc",
|
|
||||||
now,
|
|
||||||
)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
|
||||||
}
|
|
||||||
e := got[0]
|
|
||||||
if e["type"] != "delegate_result" {
|
|
||||||
t.Errorf("type: got %v", e["type"])
|
|
||||||
}
|
|
||||||
if e["error"] != "Callee workspace not reachable" {
|
|
||||||
t.Errorf("error: got %v", e["error"])
|
|
||||||
}
|
|
||||||
if e["response_preview"] != `{"text":"the result body text"}` {
|
|
||||||
t.Errorf("response_preview: got %v", e["response_preview"])
|
|
||||||
}
|
|
||||||
if e["delegation_id"] != "del-abc" {
|
|
||||||
t.Errorf("delegation_id: got %v", e["delegation_id"])
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromActivityLogs_QueryError(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnError(context.DeadlineExceeded)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
|
||||||
// Error → returns empty slice, not nil.
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("query error: expected empty slice, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListDelegationsFromActivityLogs_RowsErr(t *testing.T) {
|
|
||||||
mockDB, mock, err := sqlmock.New()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
|
||||||
}
|
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// RowError(0) before AddRow(0): row 0 is "bad", rows.Next() returns false
|
|
||||||
// on first call — the row never scans, result stays nil. To get partial
|
|
||||||
// results (row 0 scanned) with rows.Err() non-nil, we use 2 rows and put
|
|
||||||
// RowError(1) after AddRow(1): row 0 scans normally, row 1 is bad,
|
|
||||||
// rows.Err() is error, handler returns partial result.
|
|
||||||
rows := sqlmock.NewRows([]string{
|
|
||||||
"id", "activity_type", "source_id", "target_id",
|
|
||||||
"summary", "status", "error_detail",
|
|
||||||
"response_preview", "delegation_id", "created_at",
|
|
||||||
}).
|
|
||||||
AddRow("act-1", "delegate", "ws-1", "ws-2", "task", "queued", "", "", "", now).
|
|
||||||
AddRow("act-2", "delegate", "ws-1", "ws-3", "another task", "queued", "", "", "", now).
|
|
||||||
RowError(1, context.DeadlineExceeded)
|
|
||||||
mock.ExpectQuery("SELECT .+ FROM activity_logs").
|
|
||||||
WithArgs("ws-1").
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
|
||||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
|
||||||
dh := NewDelegationHandler(wh, broadcaster)
|
|
||||||
|
|
||||||
got := dh.listDelegationsFromActivityLogs(context.Background(), "ws-1")
|
|
||||||
// Row 0 scanned and appended; row 1 is bad; rows.Err() is non-nil.
|
|
||||||
// Handler logs the error but returns result (partial results because result != nil).
|
|
||||||
if got == nil || len(got) != 1 {
|
|
||||||
t.Errorf("rows.Err path: expected 1 partial result, got %v", got)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Errorf("sqlmock expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListDelegationsFromActivityLogs_ScanErrorSkipped is removed.
|
|
||||||
//
|
|
||||||
// Same reason as TestListDelegationsFromLedger_ScanError: Go 1.25 causes
|
|
||||||
// sqlmock.NewRows([]string{}).AddRow(...) to panic in test SETUP. The handler
|
|
||||||
// has no recover(), so a scan panic would crash the process — the correct
|
|
||||||
// behaviour. Real-DB integration tests cover this path.
|
|
||||||
@ -133,9 +133,9 @@ func TestDelegate_Success(t *testing.T) {
|
|||||||
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
|
||||||
// Expect INSERT into activity_logs for delegation tracking
|
// Expect INSERT into activity_logs for delegation tracking
|
||||||
// (6th arg is response_body, 7th is idempotency_key — nil here since the request omits it)
|
// (6th arg is idempotency_key — nil here since the request omits it)
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
mock.ExpectExec("INSERT INTO activity_logs").
|
||||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
|
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
// Expect RecordAndBroadcast INSERT into structure_events
|
// Expect RecordAndBroadcast INSERT into structure_events
|
||||||
@ -189,9 +189,9 @@ func TestDelegate_DBInsertFails_Still202WithWarning(t *testing.T) {
|
|||||||
|
|
||||||
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
targetID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
|
||||||
// DB insert fails (6th arg = response_body, 7th = idempotency_key, nil for this test)
|
// DB insert fails (6th arg = idempotency_key, nil for this test)
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
mock.ExpectExec("INSERT INTO activity_logs").
|
||||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), nil).
|
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), nil).
|
||||||
WillReturnError(fmt.Errorf("database connection lost"))
|
WillReturnError(fmt.Errorf("database connection lost"))
|
||||||
|
|
||||||
// RecordAndBroadcast still fires
|
// RecordAndBroadcast still fires
|
||||||
@ -491,7 +491,6 @@ func TestDelegationRecord_InsertsActivityLogRow(t *testing.T) {
|
|||||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||||
sqlmock.AnyArg(), // request_body (jsonb)
|
sqlmock.AnyArg(), // request_body (jsonb)
|
||||||
sqlmock.AnyArg(), // response_body (jsonb) — mc#984 fix
|
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
// RecordAndBroadcast INSERT for DELEGATION_SENT
|
// RecordAndBroadcast INSERT for DELEGATION_SENT
|
||||||
@ -700,9 +699,9 @@ func TestDelegate_IdempotentFailedRowIsReleasedAndReplaced(t *testing.T) {
|
|||||||
mock.ExpectExec("DELETE FROM activity_logs").
|
mock.ExpectExec("DELETE FROM activity_logs").
|
||||||
WithArgs("ws-source", "retry-key").
|
WithArgs("ws-source", "retry-key").
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
// Fresh insert with the same idempotency key (response_body added as mc#984 fix).
|
// Fresh insert with the same idempotency key.
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
mock.ExpectExec("INSERT INTO activity_logs").
|
||||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "retry-key").
|
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "retry-key").
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("INSERT INTO structure_events").
|
mock.ExpectExec("INSERT INTO structure_events").
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
@ -746,9 +745,9 @@ func TestDelegate_IdempotentRaceUniqueViolationReturnsExisting(t *testing.T) {
|
|||||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
mock.ExpectQuery("SELECT request_body->>'delegation_id', status, target_id").
|
||||||
WithArgs("ws-source", "race-key").
|
WithArgs("ws-source", "race-key").
|
||||||
WillReturnError(fmt.Errorf("sql: no rows in result set"))
|
WillReturnError(fmt.Errorf("sql: no rows in result set"))
|
||||||
// Insert loses the race against a concurrent caller (response_body added as mc#984 fix).
|
// Insert loses the race against a concurrent caller.
|
||||||
mock.ExpectExec("INSERT INTO activity_logs").
|
mock.ExpectExec("INSERT INTO activity_logs").
|
||||||
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), sqlmock.AnyArg(), "race-key").
|
WithArgs("ws-source", "ws-source", targetID, "Delegating to "+targetID, sqlmock.AnyArg(), "race-key").
|
||||||
WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint \"activity_logs_idempotency_uniq\""))
|
WillReturnError(fmt.Errorf("pq: duplicate key value violates unique constraint \"activity_logs_idempotency_uniq\""))
|
||||||
// Re-query returns the winner.
|
// Re-query returns the winner.
|
||||||
mock.ExpectQuery("SELECT request_body->>'delegation_id', status").
|
mock.ExpectQuery("SELECT request_body->>'delegation_id', status").
|
||||||
|
|||||||
@ -1,160 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// filterPeersByQuery tests — nil-safe role/name filtering for peer discovery.
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_EmptyQueryNoOp(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "foo", "role": "bar"},
|
|
||||||
{"name": "baz", "role": "qux"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "")
|
|
||||||
if len(result) != 2 {
|
|
||||||
t.Errorf("empty query: expected 2, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_WhitespaceQueryNoOp(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "foo", "role": "bar"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, " ")
|
|
||||||
if len(result) != 1 {
|
|
||||||
t.Errorf("whitespace-only query: expected 1, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_MatchName(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "backend-agent", "role": "sre"},
|
|
||||||
{"name": "frontend-agent", "role": "ui"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "backend")
|
|
||||||
if len(result) != 1 || result[0]["name"] != "backend-agent" {
|
|
||||||
t.Errorf("expected backend-agent, got %v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_MatchRole(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "agent-alpha", "role": "security engineer"},
|
|
||||||
{"name": "agent-beta", "role": "devops"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "engineer")
|
|
||||||
if len(result) != 1 || result[0]["name"] != "agent-alpha" {
|
|
||||||
t.Errorf("expected agent-alpha, got %v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_CaseInsensitive(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "AgentX", "role": "SRE"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "AGENTx")
|
|
||||||
if len(result) != 1 {
|
|
||||||
t.Errorf("expected 1 match (case-insensitive), got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_NilRoleNoPanic(t *testing.T) {
|
|
||||||
// This is the regression case for #730: queryPeerMaps explicitly sets
|
|
||||||
// peer["role"] = nil when the DB role is empty string. Before the fix,
|
|
||||||
// p["role"].(string) panics on nil. After the fix, it returns "" and
|
|
||||||
// no match occurs — which is the correct behaviour.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "some-agent", "role": nil},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "some-agent")
|
|
||||||
if len(result) != 1 {
|
|
||||||
t.Errorf("expected 1 match by name, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_NilRoleQueryNoMatch(t *testing.T) {
|
|
||||||
// When role is nil and query does not match name, nothing matches.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "agent-alpha", "role": nil},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "no-match")
|
|
||||||
if len(result) != 0 {
|
|
||||||
t.Errorf("expected 0 matches, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_NilNameNoPanic(t *testing.T) {
|
|
||||||
// Defensive check: name could also theoretically be nil.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Errorf("filterPeersByQuery panicked on nil name: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": nil, "role": "sre"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "sre")
|
|
||||||
if len(result) != 1 {
|
|
||||||
t.Errorf("expected 1 match by role, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_BothNilNoPanic(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Errorf("filterPeersByQuery panicked on nil name+role: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": nil, "role": nil},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "")
|
|
||||||
if len(result) != 1 {
|
|
||||||
t.Errorf("empty query with nil name/role: expected 1, got %d", len(result))
|
|
||||||
}
|
|
||||||
result = filterPeersByQuery(peers, "anything")
|
|
||||||
if len(result) != 0 {
|
|
||||||
t.Errorf("non-empty query with nil name/role: expected 0, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_NoMatches(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "alpha", "role": "beta"},
|
|
||||||
{"name": "gamma", "role": "delta"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "zzz")
|
|
||||||
if len(result) != 0 {
|
|
||||||
t.Errorf("expected 0, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_EmptyPeers(t *testing.T) {
|
|
||||||
result := filterPeersByQuery([]map[string]interface{}{}, "query")
|
|
||||||
if len(result) != 0 {
|
|
||||||
t.Errorf("empty peers: expected 0, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterPeersByQuery_MultipleMatches(t *testing.T) {
|
|
||||||
peers := []map[string]interface{}{
|
|
||||||
{"name": "backend-alpha", "role": "eng"},
|
|
||||||
{"name": "backend-beta", "role": "eng"},
|
|
||||||
{"name": "frontend", "role": "ui"},
|
|
||||||
}
|
|
||||||
result := filterPeersByQuery(peers, "backend")
|
|
||||||
if len(result) != 2 {
|
|
||||||
t.Errorf("expected 2 backend matches, got %d", len(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -646,12 +646,8 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
|
|||||||
# external machine today, pair with the Python SDK tab.
|
# external machine today, pair with the Python SDK tab.
|
||||||
|
|
||||||
# 1. Install openclaw CLI + the workspace runtime wheel:
|
# 1. Install openclaw CLI + the workspace runtime wheel:
|
||||||
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
|
|
||||||
# script is present — it is what keeps the workspace ALIVE on canvas
|
|
||||||
# (register-on-startup + 20s heartbeat). Older versions only ship
|
|
||||||
# a2a_mcp_server which does not heartbeat.
|
|
||||||
npm install -g openclaw@latest
|
npm install -g openclaw@latest
|
||||||
pip install "molecule-ai-workspace-runtime>=0.1.999"
|
pip install molecule-ai-workspace-runtime
|
||||||
|
|
||||||
# 2. Onboard openclaw against your model provider (one-time setup).
|
# 2. Onboard openclaw against your model provider (one-time setup).
|
||||||
# --non-interactive needs an explicit --provider + --model so it
|
# --non-interactive needs an explicit --provider + --model so it
|
||||||
|
|||||||
@ -230,21 +230,20 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
|||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||||
|
|
||||||
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
|
// 21 cols — see scanWorkspaceRow for order (max_concurrent_tasks
|
||||||
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
|
// lands between active_tasks and last_error_rate).
|
||||||
columns := []string{
|
columns := []string{
|
||||||
"id", "name", "role", "tier", "status", "agent_card", "url",
|
"id", "name", "role", "tier", "status", "agent_card", "url",
|
||||||
"parent_id", "active_tasks", "max_concurrent_tasks",
|
"parent_id", "active_tasks", "max_concurrent_tasks",
|
||||||
"last_error_rate", "last_sample_error",
|
"last_error_rate", "last_sample_error",
|
||||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||||
"budget_limit", "monthly_spend",
|
"budget_limit", "monthly_spend",
|
||||||
"broadcast_enabled", "talk_to_user_enabled",
|
|
||||||
}
|
}
|
||||||
rows := sqlmock.NewRows(columns).
|
rows := sqlmock.NewRows(columns).
|
||||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
|
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
|
||||||
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
|
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0)).
|
||||||
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
|
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
|
||||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
|
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0))
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT w.id, w.name").
|
mock.ExpectQuery("SELECT w.id, w.name").
|
||||||
WillReturnRows(rows)
|
WillReturnRows(rows)
|
||||||
|
|||||||
@ -29,20 +29,14 @@ func init() {
|
|||||||
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
|
||||||
// It also disables the SSRF URL check so that httptest.NewServer loopback
|
// It also disables the SSRF URL check so that httptest.NewServer loopback
|
||||||
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
|
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
|
||||||
//
|
|
||||||
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
|
|
||||||
// that tests running after this one are not polluted by a closed mock.
|
|
||||||
// This is the single root cause of the systemic CI/Platform (Go) failures on
|
|
||||||
// main HEAD 8026f020 (mc#975).
|
|
||||||
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
mockDB, mock, err := sqlmock.New()
|
mockDB, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create sqlmock: %v", err)
|
t.Fatalf("failed to create sqlmock: %v", err)
|
||||||
}
|
}
|
||||||
prevDB := db.DB
|
|
||||||
db.DB = mockDB
|
db.DB = mockDB
|
||||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
t.Cleanup(func() { mockDB.Close() })
|
||||||
|
|
||||||
// Disable SSRF checks for the duration of this test only. Restore
|
// Disable SSRF checks for the duration of this test only. Restore
|
||||||
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
// the previous state via t.Cleanup so that TestIsSafeURL_* tests
|
||||||
@ -62,11 +56,6 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
|
|||||||
return mock
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHandlerAsyncBeforeDBCleanup(t *testing.T, h *WorkspaceHandler) {
|
|
||||||
t.Helper()
|
|
||||||
t.Cleanup(h.waitAsyncForTest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupTestRedis creates a miniredis instance and assigns it to the global db.RDB.
|
// setupTestRedis creates a miniredis instance and assigns it to the global db.RDB.
|
||||||
func setupTestRedis(t *testing.T) *miniredis.Miniredis {
|
func setupTestRedis(t *testing.T) *miniredis.Miniredis {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@ -366,11 +355,6 @@ func TestWorkspaceCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||||
mock := setupTestDB(t)
|
|
||||||
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
|
|
||||||
WithArgs("claude-code").
|
|
||||||
WillReturnError(sql.ErrNoRows)
|
|
||||||
|
|
||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||||
|
|
||||||
@ -382,7 +366,7 @@ func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
|||||||
"ws-123",
|
"ws-123",
|
||||||
"/tmp/configs/template",
|
"/tmp/configs/template",
|
||||||
map[string][]byte{"config.yaml": []byte("name: test")},
|
map[string][]byte{"config.yaml": []byte("name: test")},
|
||||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
|
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
|
||||||
map[string]string{"OPENAI_API_KEY": "sk-test"},
|
map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||||
"/tmp/plugins",
|
"/tmp/plugins",
|
||||||
"workspace:ws-123",
|
"workspace:ws-123",
|
||||||
@ -407,21 +391,21 @@ func TestWorkspaceList(t *testing.T) {
|
|||||||
broadcaster := newTestBroadcaster()
|
broadcaster := newTestBroadcaster()
|
||||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||||
|
|
||||||
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
|
// 21 cols: `max_concurrent_tasks` added between active_tasks and
|
||||||
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
|
// last_error_rate (see scanWorkspaceRow + COALESCE(w.max_concurrent_tasks, 1)
|
||||||
|
// in workspace.go). Column order must match that scan exactly.
|
||||||
columns := []string{
|
columns := []string{
|
||||||
"id", "name", "role", "tier", "status", "agent_card", "url",
|
"id", "name", "role", "tier", "status", "agent_card", "url",
|
||||||
"parent_id", "active_tasks", "max_concurrent_tasks",
|
"parent_id", "active_tasks", "max_concurrent_tasks",
|
||||||
"last_error_rate", "last_sample_error",
|
"last_error_rate", "last_sample_error",
|
||||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||||
"budget_limit", "monthly_spend",
|
"budget_limit", "monthly_spend",
|
||||||
"broadcast_enabled", "talk_to_user_enabled",
|
|
||||||
}
|
}
|
||||||
rows := sqlmock.NewRows(columns).
|
rows := sqlmock.NewRows(columns).
|
||||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
|
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
|
||||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
|
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0)).
|
||||||
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
|
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
|
||||||
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
|
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0))
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT w.id, w.name").
|
mock.ExpectQuery("SELECT w.id, w.name").
|
||||||
WillReturnRows(rows)
|
WillReturnRows(rows)
|
||||||
@ -1135,14 +1119,13 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
|
|||||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||||
"budget_limit", "monthly_spend",
|
"budget_limit", "monthly_spend",
|
||||||
"broadcast_enabled", "talk_to_user_enabled",
|
|
||||||
}
|
}
|
||||||
mock.ExpectQuery("SELECT w.id, w.name").
|
mock.ExpectQuery("SELECT w.id, w.name").
|
||||||
WithArgs("dddddddd-0004-0000-0000-000000000000").
|
WithArgs("dddddddd-0004-0000-0000-000000000000").
|
||||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||||
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
|
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
|
||||||
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
|
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
|
||||||
nil, int64(0), false, true,
|
nil, int64(0),
|
||||||
))
|
))
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@ -248,9 +248,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
|||||||
b.WriteString(content)
|
b.WriteString(content)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
log.Printf("ResolveInstructions rows.Err workspace=%s: %v", workspaceID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"workspace_id": workspaceID,
|
"workspace_id": workspaceID,
|
||||||
@ -261,7 +258,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
|||||||
func scanInstructions(rows interface {
|
func scanInstructions(rows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
Scan(dest ...interface{}) error
|
Scan(dest ...interface{}) error
|
||||||
Err() error
|
|
||||||
}) []Instruction {
|
}) []Instruction {
|
||||||
var instructions []Instruction
|
var instructions []Instruction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@ -273,9 +269,6 @@ func scanInstructions(rows interface {
|
|||||||
}
|
}
|
||||||
instructions = append(instructions, inst)
|
instructions = append(instructions, inst)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
log.Printf("scanInstructions rows.Err: %v", err)
|
|
||||||
}
|
|
||||||
if instructions == nil {
|
if instructions == nil {
|
||||||
instructions = []Instruction{}
|
instructions = []Instruction{}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user