Skip to content

Commit 2876d5a

Browse files
authored
Merge pull request #462 from spacedriveapp/task-metadata-github
fix: preserve nested task metadata updates
2 parents 595086d + aadd104 commit 2876d5a

6 files changed

Lines changed: 249 additions & 11 deletions

File tree

docs/design-docs/task-tracking.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Arguments:
206206
status: String (optional)
207207
priority: String (optional)
208208
subtasks: JSON (optional, full replacement of subtasks array)
209-
metadata: JSON (optional, merged with existing)
209+
metadata: JSON (optional, deep-merged with existing; nested objects are merged recursively)
210210
complete_subtask: i32 (optional, index of subtask to mark complete)
211211
```
212212

@@ -260,6 +260,25 @@ Each task card shows:
260260
- Worker status — if in_progress, shows live worker status
261261
- Metadata badges — GitHub issue/PR links rendered as small icons
262262

263+
Recommended metadata shape for GitHub linkage:
264+
265+
```json
266+
{
267+
"github_issue": {
268+
"repo": "spacedriveapp/spacebot",
269+
"number": 123,
270+
"url": "https://github.com/spacedriveapp/spacebot/issues/123"
271+
},
272+
"github_pr": {
273+
"repo": "spacedriveapp/spacebot",
274+
"number": 456,
275+
"url": "https://github.com/spacedriveapp/spacebot/pull/456"
276+
}
277+
}
278+
```
279+
280+
Task metadata updates should deep-merge nested objects so agents and workers can add fields like `url`, `labels`, or `state` later without replacing the rest of the stored GitHub reference.
281+
263282
Clicking a card opens a detail panel (slide-out or modal) with:
264283

265284
- Full description (markdown rendered)

interface/src/routes/AgentTasks.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
22
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4+
import { faCodeBranch, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
35
import {
46
api,
57
type TaskItem,
@@ -57,6 +59,122 @@ const PRIORITY_COLORS: Record<
5759
low: "outline",
5860
};
5961

62+
interface GithubReference {
63+
kind: "issue" | "pr";
64+
label: string;
65+
url: string | null;
66+
}
67+
68+
function isRecord(value: unknown): value is Record<string, unknown> {
69+
return typeof value === "object" && value !== null && !Array.isArray(value);
70+
}
71+
72+
function toSafeExternalUrl(value: unknown): string | null {
73+
if (typeof value !== "string") return null;
74+
try {
75+
const parsed = new URL(value);
76+
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
77+
return parsed.toString();
78+
}
79+
return null;
80+
} catch {
81+
return null;
82+
}
83+
}
84+
85+
function readGithubReference(
86+
value: unknown,
87+
kind: GithubReference["kind"],
88+
): GithubReference | null {
89+
if (!isRecord(value)) {
90+
return null;
91+
}
92+
93+
const number = typeof value.number === "number" ? value.number : null;
94+
const repo = typeof value.repo === "string" ? value.repo : null;
95+
const url = toSafeExternalUrl(value.url);
96+
97+
if (number === null && url === null && repo === null) {
98+
return null;
99+
}
100+
101+
const noun = kind === "issue" ? "Issue" : "PR";
102+
const label = number !== null ? `${noun} #${number}` : repo ? `${noun} ${repo}` : noun;
103+
104+
return { kind, label, url };
105+
}
106+
107+
function getGithubReferences(metadata: Record<string, unknown>): GithubReference[] {
108+
const references = [
109+
readGithubReference(metadata.github_issue, "issue"),
110+
readGithubReference(metadata.github_pr, "pr"),
111+
].filter((reference): reference is GithubReference => reference !== null);
112+
113+
return references;
114+
}
115+
116+
function GithubMetadataBadges({
117+
metadata,
118+
references: precomputed,
119+
compact = false,
120+
}: {
121+
metadata?: Record<string, unknown>;
122+
references?: GithubReference[];
123+
compact?: boolean;
124+
}) {
125+
const references = precomputed ?? (metadata ? getGithubReferences(metadata) : []);
126+
if (references.length === 0) {
127+
return null;
128+
}
129+
130+
return (
131+
<div className="flex flex-wrap items-center gap-1.5">
132+
{references.map((reference) => {
133+
const content = (
134+
<>
135+
<FontAwesomeIcon icon={faCodeBranch} className="text-[10px]" />
136+
<span>{reference.label}</span>
137+
{reference.url && (
138+
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-[9px]" />
139+
)}
140+
</>
141+
);
142+
143+
const className = compact
144+
? "cursor-pointer hover:border-blue-400/50 hover:text-blue-300"
145+
: "cursor-pointer hover:border-blue-400/50 hover:bg-blue-500/20 hover:text-blue-300";
146+
147+
if (reference.url) {
148+
return (
149+
<a
150+
key={`${reference.kind}-${reference.label}`}
151+
href={reference.url}
152+
target="_blank"
153+
rel="noopener noreferrer"
154+
className="inline-flex"
155+
onClick={(event) => event.stopPropagation()}
156+
>
157+
<Badge variant="blue" size="sm" className={className}>
158+
{content}
159+
</Badge>
160+
</a>
161+
);
162+
}
163+
164+
return (
165+
<Badge
166+
key={`${reference.kind}-${reference.label}`}
167+
variant="blue"
168+
size="sm"
169+
>
170+
{content}
171+
</Badge>
172+
);
173+
})}
174+
</div>
175+
);
176+
}
177+
60178
export function AgentTasks({ agentId }: { agentId: string }) {
61179
const queryClient = useQueryClient();
62180
const { taskEventVersion } = useLiveContext();
@@ -331,6 +449,7 @@ function TaskCard({
331449
Worker
332450
</Badge>
333451
)}
452+
<GithubMetadataBadges metadata={task.metadata} compact />
334453
</div>
335454

336455
{/* Subtask progress bar */}
@@ -577,6 +696,19 @@ function TaskDetailDialog({
577696
</div>
578697
)}
579698

699+
{(() => {
700+
const githubRefs = getGithubReferences(task.metadata);
701+
if (githubRefs.length === 0) return null;
702+
return (
703+
<div>
704+
<label className="mb-1 block text-xs text-ink-dull">
705+
GitHub Links
706+
</label>
707+
<GithubMetadataBadges references={githubRefs} />
708+
</div>
709+
);
710+
})()}
711+
580712
{/* Metadata */}
581713
<div className="grid grid-cols-1 gap-2 text-xs text-ink-dull sm:grid-cols-2">
582714
<div>Created: {formatTimeAgo(task.created_at)}</div>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Create a task on the board. The description is the spec — write it as a full markdown document a worker can execute with no conversation context. Include requirements, constraints, file paths, examples, and acceptance criteria. Always pre-fill subtasks as a checklist execution plan. Short title, rich description, concrete subtasks.
1+
Create a task on the board. The description is the spec — write it as a full markdown document a worker can execute with no conversation context. Include requirements, constraints, file paths, examples, and acceptance criteria. Always pre-fill subtasks as a checklist execution plan. Short title, rich description, concrete subtasks. Use `metadata` for structured external references like GitHub issues or PRs when the user mentions them, for example `{ "github_issue": { "repo": "owner/repo", "number": 123, "url": "https://github.com/owner/repo/issues/123" } }`.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Update an existing task by task number. Use this to refine the spec as scope evolves — append sections, rewrite requirements, adjust subtasks, change priority. The description is a living document; update it when the user clarifies intent or when you discover new context. Move to `ready` when the spec is complete and the cortex will pick it up for execution. For worker processes, only subtask and metadata updates are allowed.
1+
Update an existing task by task number. Use this to refine the spec as scope evolves — append sections, rewrite requirements, adjust subtasks, change priority. The description is a living document; update it when the user clarifies intent or when you discover new context. Move to `ready` when the spec is complete and the cortex will pick it up for execution. Use `metadata` to attach or enrich structured external references like GitHub issues and PRs. Metadata updates deep-merge nested objects, so you can safely add fields such as `url`, `number`, `repo`, `labels`, or `state` without replacing sibling fields. For worker processes, only subtask and metadata updates are allowed.

src/tasks/store.rs

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,13 +488,35 @@ fn merge_json_object(current: Value, patch: Option<Value>) -> Value {
488488
return current;
489489
};
490490

491-
let mut merged = current.as_object().cloned().unwrap_or_default();
492-
if let Some(patch_object) = patch.as_object() {
493-
for (key, value) in patch_object {
494-
merged.insert(key.clone(), value.clone());
495-
}
491+
// Only apply object patches — ignore scalars/nulls to preserve the
492+
// invariant that task metadata is always an object.
493+
let Value::Object(patch_object) = patch else {
494+
return current;
495+
};
496+
497+
let Value::Object(mut current_object) = current else {
498+
return Value::Object(patch_object);
499+
};
500+
501+
for (key, patch_value) in patch_object {
502+
let merged_value = match current_object.remove(&key) {
503+
Some(current_value) => merge_json_value(current_value, patch_value),
504+
None => patch_value,
505+
};
506+
current_object.insert(key, merged_value);
507+
}
508+
509+
Value::Object(current_object)
510+
}
511+
512+
fn merge_json_value(current: Value, patch: Value) -> Value {
513+
match (current, patch) {
514+
(Value::Object(current_object), Value::Object(patch_object)) => merge_json_object(
515+
Value::Object(current_object),
516+
Some(Value::Object(patch_object)),
517+
),
518+
(_, patch_value) => patch_value,
496519
}
497-
Value::Object(merged)
498520
}
499521

500522
fn parse_subtasks(value: &str) -> Vec<TaskSubtask> {
@@ -695,4 +717,69 @@ mod tests {
695717
requeued.worker_id
696718
);
697719
}
720+
721+
#[tokio::test]
722+
async fn metadata_updates_deep_merge_nested_objects() {
723+
let store = setup_store().await;
724+
let created = store
725+
.create(CreateTaskInput {
726+
agent_id: "agent-test".to_string(),
727+
title: "github-linked task".to_string(),
728+
description: None,
729+
status: TaskStatus::Backlog,
730+
priority: TaskPriority::Medium,
731+
subtasks: Vec::new(),
732+
metadata: serde_json::json!({
733+
"github_issue": {
734+
"repo": "spacedriveapp/spacebot",
735+
"number": 123,
736+
"labels": ["bug"],
737+
"state": "open"
738+
},
739+
"source": "github"
740+
}),
741+
source_memory_id: None,
742+
created_by: "branch".to_string(),
743+
})
744+
.await
745+
.expect("task should be created");
746+
747+
let updated = store
748+
.update(
749+
"agent-test",
750+
created.task_number,
751+
UpdateTaskInput {
752+
metadata: Some(serde_json::json!({
753+
"github_issue": {
754+
"url": "https://github.com/spacedriveapp/spacebot/issues/123",
755+
"labels": ["bug", "tasks"]
756+
},
757+
"github_pr": {
758+
"number": 456
759+
}
760+
})),
761+
..Default::default()
762+
},
763+
)
764+
.await
765+
.expect("update should succeed")
766+
.expect("task should exist");
767+
768+
assert_eq!(
769+
updated.metadata,
770+
serde_json::json!({
771+
"github_issue": {
772+
"repo": "spacedriveapp/spacebot",
773+
"number": 123,
774+
"url": "https://github.com/spacedriveapp/spacebot/issues/123",
775+
"labels": ["bug", "tasks"],
776+
"state": "open"
777+
},
778+
"github_pr": {
779+
"number": 456
780+
},
781+
"source": "github"
782+
})
783+
);
784+
}
698785
}

src/tools/task_update.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ impl Tool for TaskUpdateTool {
101101
"required": ["title", "completed"]
102102
}
103103
},
104-
"metadata": { "type": "object", "description": "Metadata object merged with current metadata" },
104+
"metadata": { "type": "object", "description": "Metadata object deep-merged with current metadata" },
105105
"complete_subtask": { "type": "integer", "description": "Subtask index to mark complete" }
106106
},
107107
"required": ["task_number"]
@@ -135,7 +135,7 @@ impl Tool for TaskUpdateTool {
135135
"required": ["title", "completed"]
136136
}
137137
},
138-
"metadata": { "type": "object", "description": "Metadata object merged with current metadata" },
138+
"metadata": { "type": "object", "description": "Metadata object deep-merged with current metadata" },
139139
"complete_subtask": { "type": "integer", "description": "Subtask index to mark complete" },
140140
"worker_id": { "type": "string", "description": "Optional worker ID to bind to this task" },
141141
"approved_by": { "type": "string", "description": "Optional approver identifier" }

0 commit comments

Comments
 (0)