Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,12 +1508,12 @@ export async function setupGoalChannel(options: { execute: boolean; goalId: stri
);
}

export async function configureGoalChannelAutoNotify(options: { autoNotify: boolean; goalId: string }) {
export async function configureGoalChannelAutoNotify(options: { autoNotify: boolean; goalId: string; kind?: "human_gate" | "blocked_notice" }) {
return goalChannelOperationSchema.parse(
await requestJson<unknown>("/api/chat/goal-channel/configure", {
method: "POST",
body: JSON.stringify({
auto_notify_human_gates: options.autoNotify,
...(options.kind === "blocked_notice" ? { auto_notify_blocked_notices: options.autoNotify } : { auto_notify_human_gates: options.autoNotify }),
goal_id: options.goalId,
}),
}),
Expand Down
6 changes: 6 additions & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ export const goalChannelNotificationRowSchema = z.object({
configured: z.boolean().optional().default(false),
enabled: z.boolean().optional().default(false),
human_gate_auto_notify_enabled: z.boolean().optional().default(false),
blocked_notice_auto_notify_enabled: z.boolean().optional().default(false),
blocked_notice_delivery: z.object({
delivered_count: z.number(),
unverified_count: z.number(),
resolved_count: z.number(),
}).optional(),
target_ref: z.string().optional().nullable(),
receipt_count: z.number().optional().default(0),
last_notified_at: z.string().optional().nullable(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,28 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied
<strong>{t("capabilities.larkInboxNotificationSetting")}</strong>
<p>{t("capabilities.larkInboxNotificationDescription")}</p>
</div>
<GoalAutoNotifyToggle
callbacks={callbacks}
goalId={goalId}
notification={notification}
onChanged={onNotificationChanged}
/>
<div className="personal-capability-linked-controls">
<GoalAutoNotifyToggle
callbacks={callbacks}
goalId={goalId}
notification={notification}
onChanged={onNotificationChanged}
/>
<GoalAutoNotifyToggle
callbacks={callbacks}
goalId={goalId}
kind="blocked_notice"
notification={notification}
onChanged={onNotificationChanged}
/>
{notification?.blockedNoticeDelivery ? <p className="personal-notification-hint">
{t("notifications.blockedDelivery", {
delivered: notification.blockedNoticeDelivery.deliveredCount,
unverified: notification.blockedNoticeDelivery.unverifiedCount,
resolved: notification.blockedNoticeDelivery.resolvedCount,
})}
</p> : null}
</div>
</section>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,8 @@ const en = {
"lark.error.cliStart": "lark-cli failed to start. Check the installation and restart LoopX.",
"lark.error.disconnect": "Disconnect failed",
"notifications.autoNotify": "Send automatically when your confirmation is required",
"notifications.blockedAutoNotify": "Send blocked task notices to this Goal Channel",
"notifications.blockedDelivery": "Blocked notices: {delivered} verified, {unverified} unverified, {resolved} resolved",
"notifications.bind": "Bind notification group",
"notifications.bindConfirm": "Bind “{goal}” to “{target}”. LoopX will verify that the bot is in the group and send a confirmation message.",
"notifications.bindFailed": "Binding failed",
Expand Down Expand Up @@ -1010,8 +1012,8 @@ const en = {
"capabilities.goalScope": "Goal",
"capabilities.goalValue": "Current Goal value",
"capabilities.loadFailed": "Could not load Goal capabilities",
"capabilities.larkInboxNotificationDescription": "This switch controls automatic group messages for human gates. Incoming Lark events remain available when it is off.",
"capabilities.larkInboxNotificationSetting": "Human-gate group notifications",
"capabilities.larkInboxNotificationDescription": "Choose which Goal Channel notices to send. Incoming Lark events remain available when both are off.",
"capabilities.larkInboxNotificationSetting": "Goal Channel notifications",
"capabilities.loading": "Loading Goal capabilities…",
"capabilities.machineOnly": "This capability is configured only at machine scope.",
"capabilities.machineValue": "Device defaults",
Expand Down Expand Up @@ -1922,6 +1924,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"lark.error.cliStart": "lark-cli 启动失败。请检查安装状态,然后重新启动 LoopX。",
"lark.error.disconnect": "解绑失败",
"notifications.autoNotify": "需要你确认时自动推送到群里",
"notifications.blockedAutoNotify": "任务受阻时推送到此目标群",
"notifications.blockedDelivery": "受阻通知:已核验 {delivered} 条,未核验 {unverified} 条,已解除 {resolved} 条",
"notifications.bind": "绑定到通知群",
"notifications.bindConfirm": "将把「{goal}」绑定到通知群「{target}」,绑定时会验证机器人在群内并发送一条确认消息。",
"notifications.bindFailed": "绑定失败",
Expand Down Expand Up @@ -2258,8 +2262,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"capabilities.goalScope": "Goal",
"capabilities.goalValue": "当前 Goal 值",
"capabilities.loadFailed": "无法加载 Goal 能力",
"capabilities.larkInboxNotificationDescription": "此开关只控制遇到人工 Gate 时是否自动发群消息;关闭后仍会保留飞书事件收件箱能力。",
"capabilities.larkInboxNotificationSetting": "Gate 群通知",
"capabilities.larkInboxNotificationDescription": "分别选择要自动发送的目标群通知;两个开关均关闭时仍可接收飞书事件。",
"capabilities.larkInboxNotificationSetting": "目标群通知",
"capabilities.loading": "正在加载 Goal 能力…",
"capabilities.machineOnly": "此能力只能在机器作用域配置。",
"capabilities.machineValue": "设备默认值",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ export function GoalAutoNotifyToggle({
goalId,
notification,
onChanged,
kind = "human_gate",
}: {
callbacks: PersonalWorkspaceCallbacks;
goalId: string;
notification?: WorkspaceGoalNotification;
onChanged: () => void;
kind?: "human_gate" | "blocked_notice";
}) {
const { t } = useWorkspaceI18n();
const [busy, setBusy] = useState(false);
Expand All @@ -36,7 +38,7 @@ export function GoalAutoNotifyToggle({
setBusy(true);
setError(null);
try {
const result = await callbacks.onToggleGoalAutoNotify({ autoNotify, goalId });
const result = await callbacks.onToggleGoalAutoNotify({ autoNotify, goalId, kind });
if (!result.ok) {
setError(result.public_summary ?? result.blocker ?? t("notifications.setupFailed"));
return;
Expand All @@ -53,12 +55,12 @@ export function GoalAutoNotifyToggle({
<>
<label className="personal-notification-toggle">
<input
checked={notification?.humanGateAutoNotifyEnabled ?? false}
checked={kind === "blocked_notice" ? notification?.blockedNoticeAutoNotifyEnabled ?? false : notification?.humanGateAutoNotifyEnabled ?? false}
disabled={busy || notification?.configured !== true || !callbacks.onToggleGoalAutoNotify}
onChange={(event) => void toggle(event.target.checked)}
type="checkbox"
/>
<span>{t("notifications.autoNotify")}</span>
<span>{t(kind === "blocked_notice" ? "notifications.blockedAutoNotify" : "notifications.autoNotify")}</span>
{busy ? <Loader2 aria-hidden className="is-spinning" size={14} /> : null}
</label>
{error ? <p className="personal-notification-error" role="alert">{error}</p> : null}
Expand Down Expand Up @@ -130,6 +132,7 @@ function GoalNotificationRow({
notification={notification}
onChanged={onChanged}
/>

</>
) : targets.length === 0 ? (
<p className="personal-notification-hint">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ export type WorkspaceGoalNotification = {
configured: boolean;
enabled: boolean;
humanGateAutoNotifyEnabled: boolean;
blockedNoticeAutoNotifyEnabled?: boolean;
blockedNoticeDelivery?: { deliveredCount: number; unverifiedCount: number; resolvedCount: number };
lastNotifiedAt?: string | null;
receiptCount: number;
targetRef?: string | null;
Expand Down Expand Up @@ -493,7 +495,7 @@ export type PersonalWorkspaceCallbacks = {
onOpenNotificationSettings?: (goalId?: string) => void;
onFetchNotificationTargets?: () => Promise<Array<{ enabled: boolean; provider: string; target_name: string }>>;
onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
onToggleGoalAutoNotify?: (options: { autoNotify: boolean; goalId: string; kind?: "human_gate" | "blocked_notice" }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>;
};

// What one send hands back for review: at most one decision the owner reviews
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli
.personal-capability-editor-status.is-read-only { background: #fff5e8; color: #9a5a10; }
.personal-capability-linked-setting { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; margin: 0 24px 20px; padding: 14px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); }
.personal-capability-linked-setting > div { display: grid; gap: 4px; }
.personal-capability-linked-controls { min-width: 0; justify-items: start; }
.personal-capability-linked-setting strong { font-size: 12px; }
.personal-capability-linked-setting .personal-notification-toggle { justify-self: end; min-height: 44px; }
.personal-capability-linked-setting .personal-notification-error { grid-column: 1 / -1; }
Expand Down
6 changes: 6 additions & 0 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,12 @@ function buildPersonalHomeModel(
configured: row.configured,
enabled: row.enabled,
humanGateAutoNotifyEnabled: row.human_gate_auto_notify_enabled,
blockedNoticeAutoNotifyEnabled: row.blocked_notice_auto_notify_enabled,
blockedNoticeDelivery: row.blocked_notice_delivery ? {
deliveredCount: row.blocked_notice_delivery.delivered_count,
unverifiedCount: row.blocked_notice_delivery.unverified_count,
resolvedCount: row.blocked_notice_delivery.resolved_count,
} : undefined,
lastNotifiedAt: row.last_notified_at ?? null,
receiptCount: row.receipt_count,
targetRef: row.target_ref ?? null,
Expand Down
31 changes: 31 additions & 0 deletions docs/architecture/rfcs/goal-channel-collaboration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,37 @@ delivery persistently with:
loopx goal-channel configure --goal-id <goal-id> --no-auto-notify-human-gates --execute
```

Blocked Todo notices use a separate, default-off opt-in. They carry the task,
cause, impact, responsible party, recovery condition, and next action. A blocked
primary Todo remains visible when safe fallback work continues. Enable or
remove this delivery through the same private Goal Channel binding:

```bash
loopx goal-channel configure --goal-id <goal-id> --auto-notify-blocked-notices
loopx goal-channel configure --goal-id <goal-id> --auto-notify-blocked-notices --execute
loopx goal-channel configure --goal-id <goal-id> --no-auto-notify-blocked-notices --execute
```

A material refresh sends only through the authorized Lark sink. Deduplication
binds blocker identity, revision, destination chat, and delivery generation.
An explicitly observed open Todo retires the old blocker as resumed; terminal
or superseding canonical facts retire it separately. Missing or paginated rows
never prove recovery. Repeated recovery works even within one timestamp.

Each refresh attempts at most eight pending effects, prioritizing unattempted
notices before retries. Verified receipts do not consume that budget; the full
frontier and deferred pending receipts remain available for later refreshes.
A batch with pending work is not reported as fully verified. Switching channels
requires independent send/readback for the new destination and retains the old
history. Public status counts refer to the current target and exclude retired
receipts from active delivery totals.

The private binding records pending, sent-but-unverified, delivered, resumed,
resolved, and superseded receipts. Provider failure or a missing sink leaves
notices pending without stopping safe fallback. `status.json` exposes only
public-safe counts and the two opt-ins. Chat replies do not approve or recover
Todos; recovery remains a canonical Todo fact.

The opt-in is stored only in the project-local private Goal Channel binding.
It does not grant repository or LoopX transition authority. Chat replies can
provide context, but a gate changes only after LoopX validates and records the
Expand Down
25 changes: 25 additions & 0 deletions docs/architecture/rfcs/goal-channel-collaboration-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,31 @@ loopx goal-channel configure --goal-id <goal-id> --auto-notify-human-gates --exe
loopx goal-channel configure --goal-id <goal-id> --no-auto-notify-human-gates --execute
```

受阻 Todo 通知有独立的 opt-in,默认关闭。通知包含任务、原因、影响、解除责任人、
恢复条件和下一步;安全回退继续时,主要受阻任务仍会显式呈现。通过同一个私有
Goal Channel binding 开启或关闭:

```bash
loopx goal-channel configure --goal-id <goal-id> --auto-notify-blocked-notices
loopx goal-channel configure --goal-id <goal-id> --auto-notify-blocked-notices --execute
loopx goal-channel configure --goal-id <goal-id> --no-auto-notify-blocked-notices --execute
```

实际刷新只向已授权的 Lark sink 发送。去重同时绑定阻塞身份、修订、目标群和投递世代。
明确观察到 canonical Todo 恢复为 open 时,旧阻塞回执标记为 resumed;终结及被取代
事实分别退休旧回执。缺失或分页省略的任务不能证明恢复;同一时间戳内多次恢复也会
产生不同投递世代。

每次刷新最多尝试八条待发送效果,未尝试的通知优先于重试,已核验回执不占用额度。
完整候选与延后的 pending 回执保留到后续刷新;仍有待处理通知时不能宣称全部核验。
切换目标群必须独立发送并回读,旧群历史继续保留。公开计数只针对当前目标群,已退休
回执不计入当前已送达数量。

私有 binding 保存待发送、已发送但未核验、已核验、已恢复、已解除及被取代状态。
发送失败或没有可用 sink 时保持待发送状态,安全回退不受阻。`status.json` 只公开计数
与两个 opt-in,不公开私有消息身份。群聊回复不构成 Todo 批准或解除;恢复须由
canonical Todo 状态确认。

该 opt-in 只保存在项目本地私有的 Goal Channel binding 中,不授予仓库或 LoopX
状态迁移权限。群聊回复可以补充 context,但只有经过 LoopX 校验并记录的 decision
才能改变 gate 状态。
Expand Down
17 changes: 17 additions & 0 deletions examples/loopx-chat-server-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,23 @@ def main() -> None:
assert code == 400, configure_invalid
assert configure_invalid["error_code"] == "invalid_goal_channel_configure", configure_invalid

code, blocked_notice_missing = request_json(
f"{base_url}/api/chat/goal-channel/configure",
method="POST",
body={"goal_id": GOAL_ID, "auto_notify_blocked_notices": False},
)
assert code == 400, blocked_notice_missing
assert blocked_notice_missing["blocker"] == "channel_binding_missing", blocked_notice_missing

code, configure_conflict = request_json(
f"{base_url}/api/chat/goal-channel/configure",
method="POST",
body={"goal_id": GOAL_ID, "auto_notify_human_gates": True,
"auto_notify_blocked_notices": True},
)
assert code == 400, configure_conflict
assert configure_conflict["error_code"] == "invalid_goal_channel_configure", configure_conflict

registry_before_subagent_preview = registry.read_text(encoding="utf-8")
code, invalid_domain = request_json(
f"{base_url}/api/chat/goal-subagents/dry-run",
Expand Down
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs";
import { capabilityScopeScenario } from "./personal-workspace-browser/capability-scope.mjs";
import { performanceDiagnosisScenario } from "./personal-workspace-browser/performance-diagnosis.mjs";
import { blockedNoticeSettingsScenario } from "./personal-workspace-browser/blocked-notice-settings.mjs";
import { automationCadenceScenario } from "./personal-workspace-browser/automation-cadence.mjs";
import { turnStepsScenario } from "./personal-workspace-browser/turn-steps.mjs";
import { monitorReadbackScenario } from "./personal-workspace-browser/monitor-readback.mjs";
Expand Down Expand Up @@ -68,6 +69,7 @@ scenarioCatalog.push(monitorReadbackScenario);
scenarioCatalog.push(turnStepsScenario);
scenarioCatalog.push(goalWorkMapScenario);
scenarioCatalog.push(performanceDiagnosisScenario);
scenarioCatalog.push(blockedNoticeSettingsScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
Loading
Loading