Skip to content

Commit c2427f2

Browse files
committed
Merge current main before Ego reader PR delivery
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
2 parents b924616 + df82626 commit c2427f2

71 files changed

Lines changed: 3167 additions & 145 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ try {
1919
));
2020
const notesLabel = locale === "en" ? "Notes" : "备注";
2121
const evidenceLabel = locale === "en" ? "Evidence" : "证据";
22+
for (const invalid of [null, "dependency_cycle"]) {
23+
const source = todoItemSchema.parse({todo_id: "todo_cycle", text: "Review dependent work", done: false,
24+
status: "open", resume_when: "todo_done:todo_peer", resume_ready: false,
25+
resume_condition: {satisfied: false, ...(invalid ? {invalid_state: invalid} : {})}});
26+
const todo = workspaceAgentTodoFromItem(source, "fallback-id");
27+
const drawer = render(ContextDrawer, {agents: [], callbacks: {}, onClose() {}, selection: {
28+
kind: "todo", item: {...todo, goalId: "test-goal", goalTitle: "Test Goal"},
29+
}});
30+
const cycleLabel = locale === "en" ? "Circular dependency" : "循环依赖";
31+
const pendingLabel = locale === "en" ? "Waiting for condition" : "等待条件满足";
32+
assert.equal(drawer.includes(cycleLabel), Boolean(invalid));
33+
assert.equal(drawer.includes(pendingLabel), !invalid);
34+
}
2235
const longNote = "Preserve separate notes. ".repeat(800) + "NOTE_END_SENTINEL";
2336
for (const fields of [{}, {note: null, evidence: null}, {note: "", evidence: ""},
2437
{note: longNote}, {evidence: "RECORDED_EVIDENCE"}, {note: longNote, evidence: "RECORDED_EVIDENCE"}]) {

‎apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -649,14 +649,14 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
649649
{selection.item.completedAt ? <div><dt>{t("drawer.completedAt")}</dt><dd><time dateTime={selection.item.completedAt}>{selection.item.completedAt}</time></dd></div> : null}
650650
{selection.item.dependencies ? <div><dt>{t("drawer.dependencies")}</dt><dd>{selection.item.dependencies.join(" · ") || t("common.none")}</dd></div> : null}
651651
{selection.item.status === "deferred" || selection.item.resumeWhen ? <div><dt>{t("drawer.resumeWhen")}</dt><dd>{selection.item.resumeWhen || t("drawer.notSet")}</dd></div> : null}
652-
{selection.item.resumeWhen ? <div><dt>{t("drawer.resumeState")}</dt><dd>{selection.item.resumeReady == null ? t("drawer.notSet") : selection.item.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")}</dd></div> : null}
652+
{selection.item.resumeWhen ? <div><dt>{t("drawer.resumeState")}</dt><dd>{selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.resumeCycle") : selection.item.resumeReady == null ? t("drawer.notSet") : selection.item.resumeReady ? t("drawer.resumeReady") : t("drawer.resumePending")}</dd></div> : null}
653653
{selection.item.resumeReceiptId ? <div><dt>{t("drawer.resumeReceipt")}</dt><dd>{selection.item.resumeReceiptId}</dd></div> : null}
654654
{selection.item.validationDigest ? <>
655655
<div><dt>{t("drawer.validationRevision")}</dt><dd>{selection.item.validationRevision ?? t("drawer.notSet")}</dd></div>
656656
<div><dt>{t("drawer.validationDigest")}</dt><dd><code>{selection.item.validationDigest}</code></dd></div>
657657
{selection.item.validationRevisionActor ? <div><dt>{t("drawer.validationRevisionActor")}</dt><dd>{selection.item.validationRevisionActor}</dd></div> : null}
658658
</> : null}
659-
<div><dt>{t("drawer.nextTransition")}</dt><dd>{selection.item.nextTransition ?? (selection.item.done ? t("drawer.taskNextCompleted") : selection.item.resumeReady ? t("drawer.taskNextResumeReady") : selection.item.status === "deferred" ? t("drawer.taskNextDeferred") : selection.item.status === "blocked" ? t("drawer.taskNextBlocked") : t("drawer.taskNextOpen"))}</dd></div>
659+
<div><dt>{t("drawer.nextTransition")}</dt><dd>{selection.item.nextTransition ?? (selection.item.done ? t("drawer.taskNextCompleted") : selection.item.resumeInvalidReason === "dependency_cycle" ? t("drawer.taskNextCycle") : selection.item.resumeReady ? t("drawer.taskNextResumeReady") : selection.item.status === "deferred" ? t("drawer.taskNextDeferred") : selection.item.status === "blocked" ? t("drawer.taskNextBlocked") : t("drawer.taskNextOpen"))}</dd></div>
660660
</dl>
661661
</section>
662662
{!readOnly && !selection.item.done ? <div className="personal-task-inspector-actions" aria-label={t("drawer.taskActions")}>

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,8 @@ const en = {
470470
"drawer.resumeWhen": "Resume condition",
471471
"drawer.resumeState": "Resume state",
472472
"drawer.resumePending": "Waiting for condition",
473+
"drawer.resumeCycle": "Circular dependency",
474+
"drawer.taskNextCycle": "Review dependencies with the task owners",
473475
"drawer.resumeReady": "Ready to resume",
474476
"drawer.resumeReceipt": "Resume receipt",
475477
"drawer.taskNextDeferred": "Wait for the resume condition, then reassess",
@@ -1868,6 +1870,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
18681870
"drawer.resumeWhen": "恢复条件",
18691871
"drawer.resumeState": "恢复状态",
18701872
"drawer.resumePending": "等待条件满足",
1873+
"drawer.resumeCycle": "循环依赖",
1874+
"drawer.taskNextCycle": "与任务负责人核查并修复依赖关系",
18711875
"drawer.resumeReady": "可恢复",
18721876
"drawer.resumeReceipt": "恢复回执",
18731877
"drawer.taskNextDeferred": "等待恢复条件满足后重新评估",

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type WorkspaceAgentTodo = {
3737
completedAt?: string | null;
3838
resumeWhen?: string | null;
3939
resumeReady?: boolean | null;
40+
resumeInvalidReason?: string | null;
4041
resumeReceiptId?: string | null;
4142
blockedReason?: string | null;
4243
claimedBy?: string | null;
@@ -89,6 +90,8 @@ export function workspaceAgentTodoFromItem(todo: Pick<TodoItem,
8990
completedAt: todo.completed_at ?? null,
9091
resumeWhen: todo.resume_when ?? null,
9192
resumeReady: todo.resume_ready ?? null,
93+
resumeInvalidReason: typeof todo.resume_condition?.invalid_state === "string"
94+
? todo.resume_condition.invalid_state : null,
9295
resumeReceiptId: typeof receiptId === "string" && receiptId.trim() ? receiptId.trim() : null,
9396
validationDigest: todo.completion_validation_sha256 ?? null,
9497
validationRevision: todo.completion_validation_revision ?? null,

‎benchmark/runtime/source.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
from __future__ import annotations
44

5+
import hashlib
6+
import io
7+
import json
58
import subprocess
9+
import tarfile
610
from pathlib import Path
711

812

@@ -25,6 +29,40 @@ def archive_source(source: Path, revision: str, output: Path, paths=()) -> None:
2529
["git", "-C", str(source), "archive", "--format=tar", revision, *paths],
2630
stdout=stream, check=True, timeout=120,
2731
)
32+
# A complete source install needs the packaged frontend. Carry a prepared
33+
# delivery through the same source-freshness contract as the installer,
34+
# rather than making each trial download and build it again. Partial runner
35+
# overlays and plain workers must remain independent of product assets.
36+
bundle = source / "loopx/web/chat"
37+
if paths or not (bundle.exists() or bundle.is_symlink()):
38+
return
39+
from loopx.presentation.chat_bundle import MANIFEST, validate_bundle
40+
41+
if bundle.is_symlink():
42+
raise RuntimeError("Prepared Chat bundle must not be a symlink")
43+
manifest = validate_bundle(bundle, source_root=source)
44+
tracked = set(subprocess.check_output(
45+
["git", "-C", str(source), "ls-tree", "-r", "--name-only", revision],
46+
text=True,
47+
).splitlines())
48+
if not set(manifest["source_files"]) <= tracked:
49+
raise RuntimeError("Prepared Chat bundle includes uncommitted source inputs")
50+
contents = {}
51+
for name, digest in manifest["files"].items():
52+
data = (bundle / name).read_bytes()
53+
if hashlib.sha256(data).hexdigest() != digest:
54+
raise RuntimeError("Prepared Chat bundle changed during archive creation")
55+
contents[name] = data
56+
contents[MANIFEST] = (json.dumps(manifest, sort_keys=True) + "\n").encode()
57+
# Generated files are outside Git; ensure their validated source inputs
58+
# still refer to the revision archived above before admitting them.
59+
checked_revision(source, revision)
60+
with tarfile.open(output, "a") as archive:
61+
for name, data in sorted(contents.items()):
62+
member = tarfile.TarInfo(f"loopx/web/chat/{name}")
63+
member.size = len(data)
64+
member.mode = 0o644
65+
archive.addfile(member, io.BytesIO(data))
2866

2967

3068
def source_pins(source: Path, runner: Path, expected: str | None,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Pinned archives admit prepared assets, never arbitrary ignored host files."""
2+
from __future__ import annotations
3+
4+
import hashlib
5+
import json
6+
import subprocess
7+
import tarfile
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from benchmark.runtime.source import archive_source, checked_revision
13+
from loopx.presentation.chat_bundle import MANIFEST, source_inputs, validate_bundle
14+
15+
16+
@pytest.fixture
17+
def prepared_source(tmp_path):
18+
source = tmp_path / "source"
19+
source.mkdir()
20+
for args in [("init",), ("config", "user.name", "Fixture"),
21+
("config", "user.email", "fixture@example.invalid")]:
22+
subprocess.run(["git", "-C", str(source), *args], check=True,
23+
capture_output=True)
24+
package = source / "apps/presentation/dashboard/package.json"
25+
package.parent.mkdir(parents=True)
26+
package.write_text("{}")
27+
(source / ".gitignore").write_text("loopx/web/chat/\nprivate/\n")
28+
subprocess.run(["git", "-C", str(source), "add", "apps", ".gitignore"], check=True)
29+
subprocess.run(["git", "-C", str(source), "commit", "-m", "source"], check=True,
30+
capture_output=True)
31+
revision = checked_revision(source)
32+
bundle = source / "loopx/web/chat"
33+
(bundle / "assets").mkdir(parents=True)
34+
files = {"index.html": '<script src="/chat/assets/main.js"></script>',
35+
"manifest.webmanifest": "{}", "assets/main.js": "export const ready = true;",
36+
"asset-retention.json": json.dumps({"schema_version": "loopx_chat_asset_retention_v1",
37+
"generations": [["assets/main.js"]]})}
38+
for name, data in files.items():
39+
(bundle / name).write_text(data)
40+
(bundle / MANIFEST).write_text(json.dumps({
41+
"schema_version": "loopx_chat_bundle_v1", "source_revision": revision,
42+
"source_files": source_inputs(source), "current_assets": ["assets/main.js"],
43+
"files": {name: hashlib.sha256(data.encode()).hexdigest() for name, data in files.items()},
44+
}))
45+
(source / "private").mkdir()
46+
(source / "private/notes.txt").write_text("not an archive input")
47+
return source, revision, bundle
48+
49+
50+
def test_complete_archive_preserves_validated_delivery_without_private_files(prepared_source, tmp_path):
51+
source, revision, bundle = prepared_source
52+
output = tmp_path / "source.tar"
53+
archive_source(source, revision, output)
54+
extracted = tmp_path / "extracted"
55+
with tarfile.open(output) as archive:
56+
assert not any(name.startswith("private/") for name in archive.getnames())
57+
archive.extractall(extracted, filter="data")
58+
validate_bundle(extracted / "loopx/web/chat", source_root=extracted)
59+
assert (extracted / "loopx/web/chat/assets/main.js").read_bytes() == (bundle / "assets/main.js").read_bytes()
60+
61+
62+
@pytest.mark.parametrize("problem", ["corrupt", "stale", "extra", "symlink", "root_symlink", "uncommitted_input"])
63+
def test_invalid_prepared_delivery_fails_closed(prepared_source, tmp_path, problem):
64+
source, revision, bundle = prepared_source
65+
if problem == "corrupt":
66+
(bundle / "assets/main.js").write_text("corrupt")
67+
elif problem == "stale":
68+
(source / "apps/presentation/dashboard/package.json").write_text('{"changed": true}')
69+
elif problem == "extra":
70+
(bundle / "private.txt").write_text("not a declared asset")
71+
elif problem == "symlink":
72+
asset = bundle / "assets/main.js"
73+
asset.unlink()
74+
asset.symlink_to(source / "private/notes.txt")
75+
elif problem == "root_symlink":
76+
moved = bundle.rename(tmp_path / "bundle")
77+
bundle.symlink_to(moved, target_is_directory=True)
78+
else:
79+
untracked = source / "apps/presentation/dashboard/src/private.ts"
80+
untracked.parent.mkdir(parents=True)
81+
untracked.write_text("export const privateInput = true;")
82+
witness = bundle / MANIFEST
83+
manifest = json.loads(witness.read_text())
84+
manifest["source_files"] = source_inputs(source)
85+
witness.write_text(json.dumps(manifest))
86+
with pytest.raises(RuntimeError):
87+
archive_source(source, revision, tmp_path / "source.tar")
88+
89+
90+
def test_partial_archive_ignores_even_invalid_product_assets(prepared_source, tmp_path):
91+
source, revision, bundle = prepared_source
92+
(bundle / "private.txt").write_text("invalid delivery")
93+
output = tmp_path / "partial.tar"
94+
archive_source(source, revision, output, ("apps",))
95+
with tarfile.open(output) as archive:
96+
assert all(name.startswith("apps") for name in archive.getnames())

0 commit comments

Comments
 (0)