Q160: a goal's pace is measured against its target date, and claimed only when it has one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 02:44:20 -04:00
1 parent 459dcf0ea2
commit c9117fe46d
18 files changed
+172 -24

No files matched your search

+2 -1
View File
@@ -54,7 +54,8 @@ export const organizationContracts = {
]),
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType<C.CreateGoalInput>,
setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType<C.SetGoalDueDateInput>,
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
createProject: z.strictObject({
orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId,
+1
View File
@@ -183,6 +183,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
handle("createGoal", (input) => mutate(() => organization.createGoal(input)));
handle("setGoalDueDate", (input) => mutate(() => organization.setGoalDueDate(input)));
handle("createProject", (input) => mutate(() => organization.createProject(input)));
handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input)));
handle("createDocument", (input) => mutate(() => documents.create(input)));
@@ -0,0 +1,7 @@
// When a goal is planned to be reached; without it, progress is reported but no pace is claimed.
export const goalDueDateSchema = `
alter table goals add column due_at text;
insert into schema_migrations(version, name, applied_at)
values (102, 'goal_due_date', datetime('now'));
`;
+2
View File
@@ -100,6 +100,7 @@ import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays";
import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records";
import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests";
import { taskBlockedSchema } from "./migrations/101-task-blocked";
import { goalDueDateSchema } from "./migrations/102-goal-due-date";
interface Migration {
version: number;
@@ -209,6 +210,7 @@ const MIGRATIONS: Migration[] = [
{ version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema },
{ version: 100, name: "chat_reply_requests", sql: chatReplyRequestsSchema },
{ version: 101, name: "task_blocked", sql: taskBlockedSchema },
{ version: 102, name: "goal_due_date", sql: goalDueDateSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+22 -4
View File
@@ -2,7 +2,7 @@ import type { TaskDeleteImpact } from "../../shared/domain";
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetGoalDueDateInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
import { PolicyService } from "./policy-service";
@@ -359,9 +359,9 @@ export class OrganizationService {
const now = new Date().toISOString();
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
this.db.prepare(`
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
insert into goals(id, org_id, title, description, status, owner_agent_id, due_at, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, validDueAt(input.dueAt ?? null), now, now);
this.audit.record({
orgId: input.orgId,
entityType: "goal",
@@ -477,6 +477,14 @@ export class OrganizationService {
return this.tasks.deleteImpact(orgId, taskId);
}
/** Sets or clears when a goal is planned to be reached. */
setGoalDueDate(input: SetGoalDueDateInput): void {
const result = this.db.prepare("update goals set due_at = ?, updated_at = ? where id = ? and org_id = ?")
.run(validDueAt(input.dueAt), new Date().toISOString(), input.goalId, input.orgId);
if (result.changes === 0) throw new Error("Goal was not found.");
this.audit.record({ orgId: input.orgId, entityType: "goal", entityId: input.goalId, actorType: "employee", actorId: null, action: "due_date_set", payload: { dueAt: input.dueAt } });
}
setTaskBlocked(input: SetTaskBlockedInput): void {
this.tasks.setBlocked(input);
}
@@ -493,3 +501,13 @@ export class OrganizationService {
return this.tasks.agentSetStatus(input);
}
}
/** A target date is an exact ISO UTC time on a real calendar day; anything else is refused rather than stored shifted. */
function validDueAt(dueAt: string | null): string | null {
if (dueAt === null) return null;
const exact = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(dueAt);
const time = new Date(dueAt);
const normalized = dueAt.length === 20 ? dueAt.replace("Z", ".000Z") : dueAt;
if (!exact || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) throw new Error(`"${dueAt}" is not a date.`);
return normalized;
}
+1
View File
@@ -94,6 +94,7 @@ const api: DesktopApi = {
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input),
runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input),
setGoalDueDate: (input) => invoke(IPC_CHANNELS.setGoalDueDate, input),
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
+3
View File
@@ -53,6 +53,9 @@ export const zh: Record<string, string> = {
"Show earlier decisions": "显示更早的决定",
"Cancel run": "取消运行",
"Show older runs": "显示更早的运行",
"Target date": "目标日期",
"no target date": "未设目标日期",
"overdue": "已逾期",
"These are all the runs.": "已显示全部运行。",
"Running…": "运行中…",
"A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。",
+35 -18
View File
@@ -1,3 +1,4 @@
import { goalProgress, type GoalPace } from "./home/goal-progress";
import { newestRuns } from "./runs/newest-runs";
import { isBusinessTask } from "./work/task-helpers";
import { useMemo, useState, type CSSProperties } from "react";
@@ -47,7 +48,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
const goalProgress = computeGoalProgress(primaryGoal, businessTasks);
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
const greeting = greetingFor(new Date().getHours());
@@ -60,7 +61,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
<div style={STRIP}>
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} actions={actions} />
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
<WaitingCard
approvals={pendingApprovals}
@@ -95,7 +96,7 @@ function GoalCard({
actions,
}: {
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
progress: { percent: number; pace: GoalPace | null } | null;
organizationName: string;
actions: PageProps["actions"];
}) {
@@ -114,7 +115,8 @@ function GoalCard({
</div>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : t("no work yet")}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress?.pace ? statusLabel(progress.pace) : t("no target date")}</span>
{goal && <TargetDate goal={goal} actions={actions} />}
</div>
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
@@ -299,20 +301,10 @@ function RecentMessagesCard({
);
}
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
if (!goal) return null;
const scoped = tasks.filter((task) => task.goalId === goal.id);
if (scoped.length === 0) return { percent: 0, status: "on_track" };
const done = scoped.filter((task) => task.status === "done").length;
const percent = Math.round((done / scoped.length) * 100);
if (percent >= 75) return { percent, status: "ahead" };
if (percent >= 30) return { percent, status: "on_track" };
return { percent, status: "behind" };
}
function statusLabel(status: "ahead" | "on_track" | "behind"): string {
if (status === "ahead") return t("ahead");
if (status === "behind") return t("behind");
function statusLabel(pace: GoalPace): string {
if (pace === "ahead") return t("ahead");
if (pace === "behind") return t("behind");
if (pace === "overdue") return t("overdue");
return t("on track");
}
@@ -367,3 +359,28 @@ function runStateLabel(state: string): string {
if (state === "stopped") return t("Stopped");
return state;
}
/** Sets or clears when the goal should be reached, which is what its pace is measured against. */
function TargetDate({ goal, actions }: { goal: GoalRecord; actions: PageProps["actions"] }) {
const [error, setError] = useState<string | null>(null);
const value = goal.dueAt ? localDate(new Date(goal.dueAt)) : "";
const change = (date: string) => {
setError(null);
// A date means the end of that day where the operator is.
const dueAt = date ? new Date(`${date}T23:59:59`).toISOString() : null;
actions.run(() => window.agentCompany.setGoalDueDate({ orgId: goal.orgId, goalId: goal.id, dueAt }))
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
};
return (
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
<input className="input" type="date" aria-label={t("Target date")} value={value} onChange={(event) => change(event.target.value)} style={{ height: 26, padding: "0 6px", fontSize: 12 }} />
{error && <span className="form-error" role="alert">{error}</span>}
</span>
);
}
/** The calendar day of a time where the operator is, as a date input expects it. */
function localDate(time: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
}
@@ -0,0 +1,22 @@
import type { GoalRecord, TaskRecord } from "../../../../shared/domain";
/** How completion compares with the time elapsed toward the goal's target date. */
export type GoalPace = "ahead" | "on_track" | "behind" | "overdue";
/** Within this share of the plan either way, a goal is on track. */
const ON_TRACK_BAND = 0.1;
/** A goal's completion, and its pace only when it has a target date to measure against. */
export function goalProgress(goal: GoalRecord, tasks: TaskRecord[], now: Date): { percent: number; pace: GoalPace | null } {
const scoped = tasks.filter((task) => task.goalId === goal.id);
const done = scoped.filter((task) => task.status === "done").length;
const percent = scoped.length === 0 ? 0 : Math.round((done / scoped.length) * 100);
if (!goal.dueAt) return { percent, pace: null };
const start = new Date(goal.createdAt).getTime();
const due = new Date(goal.dueAt).getTime();
// Counted, not rounded: 200 of 201 done is still unfinished past the date.
if (now.getTime() > due && (scoped.length === 0 || done < scoped.length)) return { percent, pace: "overdue" };
const expected = due <= start ? 1 : Math.min(1, Math.max(0, (now.getTime() - start) / (due - start)));
const lead = percent / 100 - expected;
return { percent, pace: lead >= ON_TRACK_BAND ? "ahead" : lead >= -ON_TRACK_BAND ? "on_track" : "behind" };
}
@@ -34,6 +34,7 @@ export function GoalDialog({
title: text(form, "title"),
description: text(form, "description"),
ownerAgentId: optional(form, "ownerAgentId"),
dueAt: text(form, "dueDate") ? new Date(`${text(form, "dueDate")}T23:59:59`).toISOString() : null,
})).then((result) => { if (result) onClose(); });
};
@@ -41,6 +42,7 @@ export function GoalDialog({
<Dialog title={t("New goal")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
<Field label={t("Title")}><input className="input" name="title" required /></Field>
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
<Field label={t("Target date")}><input className="input" type="date" name="dueDate" /></Field>
<Field label={t("Owner agent")}>
<select className="input" name="ownerAgentId" defaultValue="">
<option value="">{t("Unassigned")}</option>
+9
View File
@@ -140,6 +140,15 @@ export interface CreateGoalInput {
title: string;
description: string;
ownerAgentId: string | null;
/** When the goal is planned to be reached; omitted means no target date. */
dueAt?: string | null;
}
export interface SetGoalDueDateInput {
orgId: string;
goalId: string;
/** ISO time the goal is planned to be reached, or null to clear it. */
dueAt: string | null;
}
export interface CreateProjectInput {
+2
View File
@@ -35,6 +35,7 @@ import type {
DeleteAutomationRuleInput,
DeleteDocumentInput,
DeleteTaskInput,
SetGoalDueDateInput,
SetTaskBlockedInput,
DeleteKnowledgeEntryInput,
ResumeGoalInput,
@@ -206,6 +207,7 @@ export interface DesktopApi {
/** What deleting a task would remove, keep and close. */
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
/** Cancels a run that has not started yet. */
setGoalDueDate(input: SetGoalDueDateInput): Promise<DesktopResult<AppSnapshot>>;
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
/** Runs older than a loaded one, for history beyond the snapshot's recent runs. */
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
+2
View File
@@ -172,6 +172,8 @@ export interface GoalRecord {
ownerAgentId: string | null;
/** Set when the goal's review loop stopped without a decision. */
stalledReason: string | null;
/** When the goal is planned to be reached; null when no target date was set. */
dueAt: string | null;
createdAt: string;
updatedAt: string;
}
+1
View File
@@ -87,6 +87,7 @@ export const IPC_CHANNELS = {
taskDeleteImpact: "agentcompany:task-delete-impact",
cancelQueuedRun: "agentcompany:cancel-queued-run",
runHistory: "agentcompany:run-history",
setGoalDueDate: "agentcompany:set-goal-due-date",
revokeConnector: "agentcompany:revoke-connector",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",