Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ffe4b380 | ||
|
|
cd244b8cbd | ||
|
|
b22272345e | ||
|
|
b75b68c786 | ||
|
|
29d953a5eb | ||
|
|
609298dd7a | ||
|
|
ff20ce2563 | ||
|
|
ba5fe4cdf7 |
@@ -64,6 +64,8 @@ Instance-{Name}/
|
||||
├── AgentList.json ← Agenten-Register (Name, Beschreibung, Ordner)
|
||||
├── SharedWorkspace/ ← Geteilter Arbeitsbereich
|
||||
│ └── coordination/ ← Status-Dateien für Inter-Agenten-Kommunikation
|
||||
├── SharedWorkspaces/ ← Bereiche mit Mitgliederliste (#684), einer je Einheit
|
||||
│ └── {bereich}/ ← in FileRW als workspace "unit" oder "{bereich}"
|
||||
└── Agent-{Name}/
|
||||
├── AgentSettings.json ← Agent-Konfiguration (Modell, Tools, Scheduler)
|
||||
├── Identity.md ← WER ist der Agent (Rolle, Expertise)
|
||||
|
||||
@@ -244,6 +244,25 @@ Vier Festlegungen:
|
||||
Für bestehende Instanzen bleibt `workspace: "shared"` gültig und zeigt auf den einen
|
||||
Bereich, den es dort gibt. Ohne das bräche jede Aufgabe, die heute läuft.
|
||||
|
||||
**Umgesetzt am 2026-09-24 (#684), mit zwei Abweichungen im Wortlaut, nicht in der Sache:**
|
||||
|
||||
- Der Ordner `SharedWorkspace` wird **nicht umbenannt**. Er bleibt der Bereich `shared`
|
||||
mit dem bisherigen `sharedAccessLevel` am Agenten; die neuen Bereiche liegen daneben
|
||||
unter `SharedWorkspaces/<id>/`. Am alten Pfad hängen FTP, RocketChat, der Agenten-Editor,
|
||||
die Aufgabendateien und die Sicherungen — eine Umbenennung hätte jede laufende
|
||||
Installation gebrochen. Für jeden Bereich gibt es genau eine Quelle des Rechts: für
|
||||
`shared` die Stufe am Agenten, für alle anderen die Mitgliederliste.
|
||||
- Der eigene Bereich einer Einheit trägt ihre Id; ein Agent erreicht ihn in FileRW als
|
||||
`unit`, ohne die Id zu kennen. Welche Bereiche er hat, steht im Organisationsabschnitt
|
||||
seines Prompts.
|
||||
- **Mit Einheiten ist `shared:/tasks/` für FileRW gesperrt.** Eine direkt geschriebene
|
||||
Aufgabendatei ginge an jeder Prüfung des Bretts vorbei. Aufgaben laufen nur über das
|
||||
Taskboard. Ohne Einheiten ändert sich nichts.
|
||||
- **Das Brett einer Aufgabe wird nicht gespeichert**, es folgt aus der Organisation: die
|
||||
Einheit des Zuständigen, sonst die des Anlegers (`created_by`). Offene Aufgaben ziehen
|
||||
damit beim Umzug von selbst mit. Wer eine Aufgabe angelegt hat, sieht sie auch, wenn sie
|
||||
auf dem Brett der Leitung liegt.
|
||||
|
||||
### Die eine Regel, die alles zusammenhält
|
||||
|
||||
**Ein Agent kennt nur seine Einheit und seine Leitung.** Alles andere geht über die
|
||||
|
||||
@@ -690,7 +690,11 @@ public sealed class AppHost : IAsyncDisposable
|
||||
loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Probe"),
|
||||
CancellationToken.None,
|
||||
agent.WorkspacePath,
|
||||
agent.SharedWorkspacePath);
|
||||
agent.SharedWorkspacePath)
|
||||
{
|
||||
// #684: a call in a named workspace is only found with the organisation.
|
||||
Organisation = Core.Organisation.OrgChart.From(instance)
|
||||
};
|
||||
}
|
||||
|
||||
var recovery = new CrashRecovery(audit, tools, loggerFactory, KontextFuer);
|
||||
@@ -737,7 +741,8 @@ public sealed class AppHost : IAsyncDisposable
|
||||
dauerfreigaben: dauerfreigaben);
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
|
||||
tasks: taskRepository);
|
||||
|
||||
var headroom = engine.BudgetGuard is { } guard
|
||||
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
|
||||
@@ -756,7 +761,14 @@ public sealed class AppHost : IAsyncDisposable
|
||||
// #716: Late work waits for headroom the rest of the day does not need, and a
|
||||
// day the budget will not last is named while there is still time to act.
|
||||
lateRunGate: headroom,
|
||||
budgetOutlook: headroom);
|
||||
budgetOutlook: headroom,
|
||||
// #714: A task tree that has come to rest is reviewed by the lead of its unit.
|
||||
treeWatch: new TaskTreeWatch(
|
||||
taskRepository,
|
||||
(task, ct) => board.CreateAsync(task, ct),
|
||||
stateStore,
|
||||
() => engine.OrganisationProvider?.Invoke(),
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.TreeWatch")));
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed record UnitSettingChange(
|
||||
bool Outlier,
|
||||
bool SelectedByDefault);
|
||||
|
||||
/// <summary>#684: One entry of a workspace's member list, as the administration takes it.</summary>
|
||||
public sealed record WorkspaceMemberInput(string? UnitId, string? AgentId, WorkspaceAccess Access, bool IncludeSubUnits = true);
|
||||
|
||||
/// <summary>What a move or a lowered ceiling would do before it is done.</summary>
|
||||
public sealed record StructurePreview(
|
||||
IReadOnlyList<string> AffectedAgents,
|
||||
@@ -86,6 +89,10 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||
return AdminResult.Failure("A unit needs a name.");
|
||||
if (Chart.Unit(id) is not null)
|
||||
return AdminResult.Failure($"There is already a unit '{id}'.");
|
||||
if (!WorkspaceLayout.IsValidId(id))
|
||||
return AdminResult.Failure($"'{id}' is reserved — a unit's workspace carries its id, and '{id}' already means something in FileRW.");
|
||||
if (Chart.Workspace(id) is not null)
|
||||
return AdminResult.Failure($"There is already a workspace '{id}'. A unit's own workspace carries the unit's id — choose another id.");
|
||||
if (!string.IsNullOrWhiteSpace(parentId) && Chart.Unit(parentId) is null)
|
||||
return AdminResult.Failure($"There is no unit '{parentId}' to put it under.");
|
||||
|
||||
@@ -97,8 +104,11 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||
Goal = goal?.Trim() ?? ""
|
||||
});
|
||||
|
||||
// #684: every unit gets its own workspace — the normal case, it costs no decision.
|
||||
host.Instance.Workspaces.Add(OwnWorkspace(id, name.Trim()));
|
||||
|
||||
Save(null, "unit_create", new { id, name, parentId, goal }, reason);
|
||||
return AdminResult.Success($"Unit '{name.Trim()}' created.");
|
||||
return AdminResult.Success($"Unit '{name.Trim()}' created, with its workspace '{id}'.");
|
||||
}
|
||||
|
||||
public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
|
||||
@@ -129,8 +139,28 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||
return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
|
||||
|
||||
host.Instance.Units.Remove(unit);
|
||||
Save(null, "unit_delete", new { unitId }, reason);
|
||||
return AdminResult.Success($"Unit '{unit.Name}' deleted.");
|
||||
|
||||
// #684: what was filed belongs to the unit, and the unit is gone — so nothing is
|
||||
// deleted here. Its entries leave the member lists; its own workspace goes only if
|
||||
// it is empty, otherwise it stays without owner for a human to decide.
|
||||
var kept = new List<string>();
|
||||
foreach (var workspace in host.Instance.Workspaces.ToList())
|
||||
{
|
||||
workspace.Members.RemoveAll(m => Same(m.UnitId, unit.Id));
|
||||
if (!Same(workspace.OwnerUnitId, unit.Id))
|
||||
continue;
|
||||
|
||||
workspace.OwnerUnitId = null;
|
||||
if (Same(workspace.Id, unit.Id) && !HasFiles(workspace.Id))
|
||||
host.Instance.Workspaces.Remove(workspace);
|
||||
else
|
||||
kept.Add(workspace.Id);
|
||||
}
|
||||
|
||||
Save(null, "unit_delete", new { unitId, workspacesKept = kept }, reason);
|
||||
return AdminResult.Success(kept.Count == 0
|
||||
? $"Unit '{unit.Name}' deleted."
|
||||
: $"Unit '{unit.Name}' deleted. Its workspace(s) {string.Join(", ", kept.Select(k => $"'{k}'"))} still hold files and remain, without owner.");
|
||||
}
|
||||
|
||||
/// <summary>The lead must be a member of the unit. <c>null</c> leaves it without a lead.</summary>
|
||||
@@ -549,6 +579,177 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── workspaces (#684) ───
|
||||
|
||||
/// <summary>
|
||||
/// A workspace shared across units on purpose — the article master that purchasing owns
|
||||
/// and marketing reads. It needs a named owner or none (then it belongs to the human),
|
||||
/// and its id must not be a unit's: those are kept for the units' own workspaces.
|
||||
/// </summary>
|
||||
public AdminResult CreateWorkspace(
|
||||
string id, string name, string? purpose, string? ownerUnitId,
|
||||
IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
|
||||
{
|
||||
id = (id ?? "").Trim();
|
||||
if (!WorkspaceLayout.IsValidId(id))
|
||||
return AdminResult.Failure($"A workspace id consists of letters, digits, '-' and '_', and is none of {string.Join(", ", WorkspaceLayout.Reserved)}.");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return AdminResult.Failure("A workspace needs a name.");
|
||||
if (Chart.Workspace(id) is not null)
|
||||
return AdminResult.Failure($"There is already a workspace '{id}'.");
|
||||
if (Chart.Unit(id) is not null)
|
||||
return AdminResult.Failure($"'{id}' is the id of a unit — its own workspace carries it. Choose another id.");
|
||||
if (!string.IsNullOrWhiteSpace(ownerUnitId) && FindUnit(ownerUnitId) is null)
|
||||
return AdminResult.Failure($"There is no unit '{ownerUnitId}' to own it.");
|
||||
if (CheckMembers(members) is { } problem)
|
||||
return AdminResult.Failure(problem);
|
||||
|
||||
host.Instance.Workspaces.Add(new WorkspaceConfig
|
||||
{
|
||||
Id = id,
|
||||
Name = name.Trim(),
|
||||
Purpose = purpose?.Trim() ?? "",
|
||||
OwnerUnitId = string.IsNullOrWhiteSpace(ownerUnitId) ? null : FindUnit(ownerUnitId)!.Id,
|
||||
Members = members.Select(ToMember).ToList()
|
||||
});
|
||||
|
||||
Save(null, "workspace_create", new { id, name, ownerUnitId, members }, reason);
|
||||
return AdminResult.Success($"Workspace '{name.Trim()}' created.");
|
||||
}
|
||||
|
||||
/// <summary>Name, purpose and protected folders. <c>null</c> leaves a value as it is.</summary>
|
||||
public AdminResult UpdateWorkspace(
|
||||
string workspaceId, string? name, string? purpose, IReadOnlyList<string>? protectedPaths, string reason = "")
|
||||
{
|
||||
if (FindWorkspace(workspaceId) is not { } workspace)
|
||||
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
|
||||
if (name is not null && string.IsNullOrWhiteSpace(name))
|
||||
return AdminResult.Failure("A workspace needs a name.");
|
||||
|
||||
var paths = protectedPaths?
|
||||
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
||||
.Where(p => p.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (paths?.FirstOrDefault(p => p.Split('/').Any(part => part is "." or "..")) is { } bad)
|
||||
return AdminResult.Failure($"'{bad}' is not a folder inside the workspace.");
|
||||
|
||||
if (name is not null) workspace.Name = name.Trim();
|
||||
if (purpose is not null) workspace.Purpose = purpose.Trim();
|
||||
if (paths is not null) workspace.ProtectedPaths = paths;
|
||||
|
||||
Save(null, "workspace_update", new { workspaceId, name, purpose, protectedPaths = paths }, reason);
|
||||
return AdminResult.Success($"Workspace '{workspace.Name}' saved.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the member list. The right stands here, at the workspace — whoever is not on
|
||||
/// the list does not see it. Works for a unit's own workspace too: widening it to a
|
||||
/// reading neighbour is exactly the kind of decision that belongs to a human.
|
||||
/// </summary>
|
||||
public AdminResult SetWorkspaceMembers(string workspaceId, IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
|
||||
{
|
||||
if (FindWorkspace(workspaceId) is not { } workspace)
|
||||
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
|
||||
if (CheckMembers(members) is { } problem)
|
||||
return AdminResult.Failure(problem);
|
||||
|
||||
var before = workspace.Members.Select(m => new { m.UnitId, m.AgentId, m.Access, m.IncludeSubUnits }).ToList();
|
||||
workspace.Members = members.Select(ToMember).ToList();
|
||||
|
||||
Save(null, "workspace_members", new { workspaceId, before, after = members }, reason);
|
||||
return AdminResult.Success($"Members of '{workspace.Name}' saved.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only an empty workspace can be deleted: nothing filed is ever thrown away from here.
|
||||
/// A unit's own workspace goes with its unit, not on its own.
|
||||
/// </summary>
|
||||
public AdminResult DeleteWorkspace(string workspaceId, string reason = "")
|
||||
{
|
||||
if (FindWorkspace(workspaceId) is not { } workspace)
|
||||
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
|
||||
if (Chart.UnitWorkspace(workspace.OwnerUnitId) == workspace)
|
||||
return AdminResult.Failure($"'{workspace.Id}' is the own workspace of unit '{workspace.OwnerUnitId}' — it goes when the unit is deleted.");
|
||||
if (HasFiles(workspace.Id))
|
||||
return AdminResult.Failure($"Workspace '{workspace.Name}' still holds files. Nothing filed is deleted from here — move or remove them first.");
|
||||
|
||||
host.Instance.Workspaces.Remove(workspace);
|
||||
Save(null, "workspace_delete", new { workspaceId }, reason);
|
||||
return AdminResult.Success($"Workspace '{workspace.Name}' deleted.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives a unit its own workspace if it has none — for units that existed before
|
||||
/// workspaces did. Does nothing for a unit that has one.
|
||||
/// </summary>
|
||||
public AdminResult CreateUnitWorkspace(string unitId, string reason = "")
|
||||
{
|
||||
if (FindUnit(unitId) is not { } unit)
|
||||
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||
if (Chart.UnitWorkspace(unit.Id) is not null)
|
||||
return AdminResult.Success($"'{unit.Name}' already has its workspace.");
|
||||
if (Chart.Workspace(unit.Id) is not null)
|
||||
return AdminResult.Failure($"There is a workspace '{unit.Id}' that does not belong to the unit. Rename or delete it first.");
|
||||
if (!WorkspaceLayout.IsValidId(unit.Id))
|
||||
return AdminResult.Failure($"The unit id '{unit.Id}' cannot be a workspace id.");
|
||||
|
||||
host.Instance.Workspaces.Add(OwnWorkspace(unit.Id, unit.Name));
|
||||
Save(null, "workspace_create", new { id = unit.Id, ownerUnitId = unit.Id }, reason);
|
||||
return AdminResult.Success($"'{unit.Name}' has its workspace now.");
|
||||
}
|
||||
|
||||
/// <summary>The folder of a workspace; <c>null</c> when the instance has no shared folder.</summary>
|
||||
public string? WorkspaceFolder(string workspaceId)
|
||||
=> WorkspaceLayout.PathOf(
|
||||
host.Instance.Agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)),
|
||||
workspaceId);
|
||||
|
||||
/// <summary>How many files a workspace holds, in all its folders.</summary>
|
||||
public int FileCount(string workspaceId)
|
||||
=> WorkspaceFolder(workspaceId) is { } folder && Directory.Exists(folder)
|
||||
? Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Count()
|
||||
: 0;
|
||||
|
||||
private bool HasFiles(string workspaceId) => FileCount(workspaceId) > 0;
|
||||
|
||||
private static WorkspaceConfig OwnWorkspace(string unitId, string unitName) => new()
|
||||
{
|
||||
Id = unitId,
|
||||
Name = unitName,
|
||||
OwnerUnitId = unitId,
|
||||
Members = [new WorkspaceMember { UnitId = unitId, Access = WorkspaceAccess.Write, IncludeSubUnits = true }]
|
||||
};
|
||||
|
||||
private string? CheckMembers(IReadOnlyList<WorkspaceMemberInput> members)
|
||||
{
|
||||
foreach (var member in members)
|
||||
{
|
||||
var byUnit = !string.IsNullOrWhiteSpace(member.UnitId);
|
||||
var byAgent = !string.IsNullOrWhiteSpace(member.AgentId);
|
||||
if (byUnit == byAgent)
|
||||
return "Each member is either a unit or an agent.";
|
||||
if (byUnit && FindUnit(member.UnitId) is null)
|
||||
return $"There is no unit '{member.UnitId}'.";
|
||||
if (byAgent && host.AgentAdmin.Find(member.AgentId!) is null)
|
||||
return $"There is no agent '{member.AgentId}'.";
|
||||
if (member.Access == WorkspaceAccess.None)
|
||||
return "A member needs at least read access — leave it off the list instead.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private WorkspaceMember ToMember(WorkspaceMemberInput input) => new()
|
||||
{
|
||||
UnitId = string.IsNullOrWhiteSpace(input.UnitId) ? null : FindUnit(input.UnitId)!.Id,
|
||||
AgentId = string.IsNullOrWhiteSpace(input.AgentId) ? null : host.AgentAdmin.Find(input.AgentId!)!.AgentId,
|
||||
Access = input.Access,
|
||||
IncludeSubUnits = input.IncludeSubUnits
|
||||
};
|
||||
|
||||
private WorkspaceConfig? FindWorkspace(string? workspaceId)
|
||||
=> workspaceId is null ? null : host.Instance.Workspaces.FirstOrDefault(w => Same(w.Id, workspaceId));
|
||||
|
||||
private UnitConfig? FindUnit(string? unitId)
|
||||
=> unitId is null ? null : host.Instance.Units.FirstOrDefault(u => Same(u.Id, unitId));
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record TaskInput(
|
||||
string? WhenValue = null,
|
||||
string? TimeZone = null,
|
||||
string Acceptance = "",
|
||||
int Priority = 3);
|
||||
int Priority = 3,
|
||||
string ParentId = "");
|
||||
|
||||
/// <summary>
|
||||
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
|
||||
@@ -85,6 +86,8 @@ public sealed class TaskAdministration(
|
||||
|
||||
if (Pruefe(input, out var assignee, out var when) is { } fehler)
|
||||
return AdminResult.Failure(fehler);
|
||||
if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem)
|
||||
return AdminResult.Failure(parentProblem);
|
||||
|
||||
var created = await board.CreateAsync(new TaskItem
|
||||
{
|
||||
@@ -95,7 +98,8 @@ public sealed class TaskAdministration(
|
||||
Assignee = assignee,
|
||||
When = when,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskItemType.Work
|
||||
Type = TaskItemType.Work,
|
||||
ParentId = input.ParentId.Trim()
|
||||
}, ct);
|
||||
|
||||
return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created));
|
||||
@@ -108,6 +112,8 @@ public sealed class TaskAdministration(
|
||||
|
||||
if (Pruefe(input, out var assignee, out var when) is { } fehler)
|
||||
return AdminResult.Failure(fehler);
|
||||
if (await CheckParentAsync(id, input.ParentId, ct) is { } parentProblem)
|
||||
return AdminResult.Failure(parentProblem);
|
||||
|
||||
var updated = await board.UpdateAsync(id, current => current with
|
||||
{
|
||||
@@ -116,7 +122,8 @@ public sealed class TaskAdministration(
|
||||
Acceptance = input.Acceptance.Trim(),
|
||||
Priority = Math.Clamp(input.Priority, 1, 5),
|
||||
Assignee = assignee,
|
||||
When = when
|
||||
When = when,
|
||||
ParentId = input.ParentId.Trim()
|
||||
}, ct);
|
||||
|
||||
return updated is null
|
||||
@@ -124,6 +131,19 @@ public sealed class TaskAdministration(
|
||||
: AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated));
|
||||
}
|
||||
|
||||
/// <summary>#714: The parent must exist and must not lie below the task itself.</summary>
|
||||
private async Task<string?> CheckParentAsync(string? taskId, string parentId, CancellationToken ct)
|
||||
{
|
||||
parentId = parentId.Trim();
|
||||
if (parentId.Length == 0 || board is null)
|
||||
return null;
|
||||
if (await board.GetAsync(parentId, ct) is null)
|
||||
return $"There is no task '{parentId}' to serve as parent.";
|
||||
if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct))
|
||||
return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Pausieren heisst <c>backlog</c>, Aktivieren <c>todo</c> - dieselben Zustaende, die der Scanner kennt.</summary>
|
||||
public async Task<AdminResult> SetEnabledAsync(string id, bool enabled, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -179,6 +179,14 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("units")]
|
||||
public List<UnitConfig> Units { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// #684: Shared workspaces with member lists — one per unit, plus those shared across
|
||||
/// units on purpose. The instance's one shared folder is not listed here; it stays the
|
||||
/// workspace <c>shared</c>, governed by each agent's level as before.
|
||||
/// </summary>
|
||||
[JsonPropertyName("workspaces")]
|
||||
public List<WorkspaceConfig> Workspaces { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public List<ServiceConfig> Services { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// #684: A shared workspace — a folder several agents work in, with a named owner and a list
|
||||
/// of who may use it how.
|
||||
///
|
||||
/// <para><b>The right stands at the workspace, not at the agent</b> (Organisation-Konzept,
|
||||
/// section 3). Before, every agent carried one <c>sharedAccessLevel</c> for the one shared
|
||||
/// folder of the instance, and "accounting may write its files, marketing may only read
|
||||
/// them" could not be said. Here each workspace lists its members; whoever is not on the
|
||||
/// list does not see it — not even that it exists.</para>
|
||||
///
|
||||
/// <para><b>The one shared folder stays what it was.</b> The folder <c>SharedWorkspace</c>
|
||||
/// with the per-agent level keeps working as the workspace <c>shared</c>, so every running
|
||||
/// installation and every task that names it goes on as before. Workspaces configured here
|
||||
/// live next to it, under <c>SharedWorkspaces/<id>/</c>. There is never a second
|
||||
/// value for the same thing: <c>shared</c> is governed only by the agent's level, every
|
||||
/// workspace here only by its member list.</para>
|
||||
/// </summary>
|
||||
public sealed class WorkspaceConfig
|
||||
{
|
||||
/// <summary>The name agents use and the folder name; lower case, digits and dashes.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>What goes in here — shown to the members so they file things in the right place.</summary>
|
||||
[JsonPropertyName("purpose")]
|
||||
public string Purpose { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The unit the workspace belongs to — what is filed here is its property, and stays
|
||||
/// when an agent moves on. <c>null</c>: owned by the instance (the human).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ownerUnitId")]
|
||||
public string? OwnerUnitId { get; set; }
|
||||
|
||||
[JsonPropertyName("members")]
|
||||
public List<WorkspaceMember> Members { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Folders in which files can only be added, not changed or deleted — except by members
|
||||
/// with <see cref="WorkspaceAccess.Manage"/>. Relative to the workspace.
|
||||
/// </summary>
|
||||
[JsonPropertyName("protectedPaths")]
|
||||
public List<string> ProtectedPaths { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: One entry on a workspace's member list — a unit or a single agent, with a level.
|
||||
/// A unit entry includes the units below it unless <see cref="IncludeSubUnits"/> is off;
|
||||
/// a unit's own workspace is meant for the whole department.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceMember
|
||||
{
|
||||
[JsonPropertyName("unitId")]
|
||||
public string? UnitId { get; set; }
|
||||
|
||||
[JsonPropertyName("agentId")]
|
||||
public string? AgentId { get; set; }
|
||||
|
||||
[JsonPropertyName("access")]
|
||||
public WorkspaceAccess Access { get; set; } = WorkspaceAccess.Read;
|
||||
|
||||
[JsonPropertyName("includeSubUnits")]
|
||||
public bool IncludeSubUnits { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: What a member may do in a workspace. Each level includes the ones below it.
|
||||
/// Deleting and changing protected files needs <see cref="Manage"/> — as the old
|
||||
/// <c>Admin</c> level did for the one shared folder.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<WorkspaceAccess>))]
|
||||
public enum WorkspaceAccess
|
||||
{
|
||||
None = 0,
|
||||
Read = 1,
|
||||
Write = 2,
|
||||
Manage = 3
|
||||
}
|
||||
@@ -98,10 +98,42 @@ public static class OrgBriefing
|
||||
sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}");
|
||||
}
|
||||
|
||||
AppendWorkspaces(sb, org, agentId);
|
||||
|
||||
sb.Append("Agents not named here cannot be reached from your position — go through your lead.");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: The shared workspaces the agent may use. FileRW's own description cannot name
|
||||
/// them — it is the same for every agent — so they are listed here, where the agent's
|
||||
/// position is described anyway, and change with it when it moves.
|
||||
/// </summary>
|
||||
private static void AppendWorkspaces(StringBuilder sb, OrgChart org, string agentId)
|
||||
{
|
||||
var grants = org.WorkspacesFor(agentId);
|
||||
if (grants.Count == 0)
|
||||
return;
|
||||
|
||||
sb.AppendLine("Your shared workspaces (FileRW, parameter \"workspace\"):");
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
var workspace = grant.Workspace;
|
||||
var name = grant.IsOwnUnit
|
||||
? $"\"{WorkspaceLayout.Unit}\" — your unit's workspace (also \"{workspace.Id}\")"
|
||||
: $"\"{workspace.Id}\"" + (string.IsNullOrWhiteSpace(workspace.Name) || workspace.Name == workspace.Id ? "" : $" ({workspace.Name})");
|
||||
var access = grant.Access switch
|
||||
{
|
||||
Config.WorkspaceAccess.Read => "read only",
|
||||
Config.WorkspaceAccess.Write => "read and write",
|
||||
_ => "read, write and delete"
|
||||
};
|
||||
sb.AppendLine($"- {name}, {access}{Describe(workspace.Purpose)}");
|
||||
}
|
||||
if (grants.Any(g => g.IsOwnUnit))
|
||||
sb.AppendLine("What your unit works on belongs in \"unit\", where your colleagues find it; \"personal\" is yours alone.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the organisation section of a system prompt, appends it, or removes it —
|
||||
/// leaving everything before it untouched. Existing chat contexts keep their system
|
||||
|
||||
@@ -24,12 +24,17 @@ public sealed class OrgChart
|
||||
private readonly Dictionary<string, AgentConfig> _agents;
|
||||
private readonly Dictionary<string, List<UnitConfig>> _children;
|
||||
private readonly Dictionary<string, List<AgentConfig>> _members;
|
||||
private readonly Dictionary<string, WorkspaceConfig> _workspaces;
|
||||
|
||||
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
|
||||
private const string Root = "";
|
||||
|
||||
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
|
||||
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents, IEnumerable<WorkspaceConfig>? workspaces = null)
|
||||
{
|
||||
_workspaces = new Dictionary<string, WorkspaceConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var workspace in workspaces ?? [])
|
||||
_workspaces.TryAdd(workspace.Id, workspace);
|
||||
|
||||
_units = new Dictionary<string, UnitConfig>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var unit in units)
|
||||
_units.TryAdd(unit.Id, unit);
|
||||
@@ -57,7 +62,7 @@ public sealed class OrgChart
|
||||
}
|
||||
}
|
||||
|
||||
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents);
|
||||
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents, instance.Workspaces);
|
||||
|
||||
/// <summary>Whether the instance has any structure at all.</summary>
|
||||
public bool HasUnits => _units.Count > 0;
|
||||
@@ -241,6 +246,63 @@ public sealed class OrgChart
|
||||
return ceiling;
|
||||
}
|
||||
|
||||
// ─── workspaces (#684) ───
|
||||
|
||||
public IReadOnlyCollection<WorkspaceConfig> Workspaces => _workspaces.Values;
|
||||
|
||||
public WorkspaceConfig? Workspace(string? workspaceId)
|
||||
=> workspaceId is not null && _workspaces.TryGetValue(workspaceId, out var workspace) ? workspace : null;
|
||||
|
||||
/// <summary>
|
||||
/// A unit's own workspace: the one owned by the unit that carries its id. Created with
|
||||
/// the unit; an agent reaches it as <c>unit</c> without knowing the id.
|
||||
/// </summary>
|
||||
public WorkspaceConfig? UnitWorkspace(string? unitId)
|
||||
=> Workspace(unitId) is { } workspace
|
||||
&& string.Equals(workspace.OwnerUnitId, unitId, StringComparison.OrdinalIgnoreCase)
|
||||
? workspace
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// How far an agent may use a workspace — the highest level among the entries that name
|
||||
/// it: the agent itself, its unit, or a unit above it whose entry includes sub-units.
|
||||
/// </summary>
|
||||
public WorkspaceAccess AccessTo(string agentId, string workspaceId)
|
||||
{
|
||||
if (Workspace(workspaceId) is not { } workspace || Agent(agentId) is null)
|
||||
return WorkspaceAccess.None;
|
||||
|
||||
var unitId = UnitOf(agentId)?.Id;
|
||||
var above = unitId is null
|
||||
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
: Ancestors(unitId).Select(u => u.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var access = WorkspaceAccess.None;
|
||||
foreach (var member in workspace.Members)
|
||||
{
|
||||
var matches =
|
||||
(member.AgentId is { Length: > 0 } a && string.Equals(a, agentId, StringComparison.OrdinalIgnoreCase))
|
||||
|| (member.UnitId is { Length: > 0 } u && unitId is not null
|
||||
&& (string.Equals(u, unitId, StringComparison.OrdinalIgnoreCase)
|
||||
|| (member.IncludeSubUnits && above.Contains(u))));
|
||||
if (matches && member.Access > access)
|
||||
access = member.Access;
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
/// <summary>Every workspace the agent may use, with its level — its own unit's first.</summary>
|
||||
public IReadOnlyList<WorkspaceGrant> WorkspacesFor(string agentId)
|
||||
{
|
||||
var own = UnitWorkspace(UnitOf(agentId)?.Id);
|
||||
return _workspaces.Values
|
||||
.Select(w => new WorkspaceGrant(w, AccessTo(agentId, w.Id), ReferenceEquals(w, own)))
|
||||
.Where(g => g.Access > WorkspaceAccess.None)
|
||||
.OrderByDescending(g => g.IsOwnUnit)
|
||||
.ThenBy(g => g.Workspace.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What is wrong with the structure — empty when it is sound. Checked when the
|
||||
/// administration writes and when an instance starts; the tree is still usable with
|
||||
@@ -264,6 +326,21 @@ public sealed class OrgChart
|
||||
: $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist.");
|
||||
}
|
||||
|
||||
foreach (var workspace in _workspaces.Values)
|
||||
{
|
||||
if (!WorkspaceLayout.IsValidId(workspace.Id))
|
||||
problems.Add($"Workspace '{workspace.Id}' has an invalid id — lower case letters, digits and dashes, not one of {string.Join(", ", WorkspaceLayout.Reserved)}.");
|
||||
if (workspace.OwnerUnitId is { Length: > 0 } owner && !_units.ContainsKey(owner))
|
||||
problems.Add($"Workspace '{workspace.Id}' belongs to the unit '{owner}', which does not exist.");
|
||||
foreach (var member in workspace.Members)
|
||||
{
|
||||
if (member.UnitId is { Length: > 0 } u && !_units.ContainsKey(u))
|
||||
problems.Add($"Workspace '{workspace.Id}' names the unit '{u}' as member, which does not exist.");
|
||||
if (member.AgentId is { Length: > 0 } a && !_agents.ContainsKey(a))
|
||||
problems.Add($"Workspace '{workspace.Id}' names the agent '{a}' as member, which does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var agent in _agents.Values)
|
||||
{
|
||||
if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Organisation;
|
||||
|
||||
/// <summary>#684: A workspace an agent may use, and how far.</summary>
|
||||
/// <param name="IsOwnUnit">The workspace of the agent's own unit — reachable as <c>unit</c>.</param>
|
||||
public sealed record WorkspaceGrant(WorkspaceConfig Workspace, WorkspaceAccess Access, bool IsOwnUnit);
|
||||
|
||||
/// <summary>
|
||||
/// #684: Where the workspaces live on disk, and which names are taken.
|
||||
///
|
||||
/// <para>The instance's one shared folder is <c>Agents/SharedWorkspace</c>, and it stays
|
||||
/// there: FTP, RocketChat, the agent editor, the task files and the backups all find it by
|
||||
/// that path. The workspaces with member lists live beside it, one folder each under
|
||||
/// <c>Agents/SharedWorkspaces/</c>. Found from the shared folder's path, which every tool
|
||||
/// context already carries, so nothing new has to be passed around.</para>
|
||||
/// </summary>
|
||||
public static class WorkspaceLayout
|
||||
{
|
||||
public const string Personal = "personal";
|
||||
public const string Shared = "shared";
|
||||
|
||||
/// <summary>The own unit's workspace, without having to know the unit's id.</summary>
|
||||
public const string Unit = "unit";
|
||||
|
||||
/// <summary>The folder next to <c>SharedWorkspace</c> that holds the workspaces.</summary>
|
||||
public const string ContainerFolder = "SharedWorkspaces";
|
||||
|
||||
/// <summary>Names a workspace cannot have — they mean something else in FileRW.</summary>
|
||||
public static readonly IReadOnlyList<string> Reserved = [Personal, Shared, Unit];
|
||||
|
||||
/// <summary>Letters, digits, '-' and '_' — the same rule as for unit ids, since a unit's
|
||||
/// workspace carries the unit's id — and not one of the reserved names.</summary>
|
||||
public static bool IsValidId(string? id)
|
||||
=> !string.IsNullOrWhiteSpace(id)
|
||||
&& id.Length <= 64
|
||||
&& id.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')
|
||||
&& !Reserved.Contains(id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The folder of a workspace, from the path of the instance's shared folder;
|
||||
/// <c>null</c> when that is not configured or the id is not valid.
|
||||
/// </summary>
|
||||
public static string? PathOf(string? sharedWorkspacePath, string workspaceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspacePath) || !IsValidId(workspaceId))
|
||||
return null;
|
||||
|
||||
var shared = Path.GetFullPath(sharedWorkspacePath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var agentsDirectory = Path.GetDirectoryName(shared);
|
||||
return agentsDirectory is null
|
||||
? null
|
||||
: Path.Combine(agentsDirectory, ContainerFolder, workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,12 @@ public sealed class SqliteStorage
|
||||
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
|
||||
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
// #684: Who created a task — decides its board when nobody in particular is assigned.
|
||||
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// #714: The task a task serves — the goal chain.
|
||||
SpalteErgaenzen(connection, "Tasks", "ParentId", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
||||
using var index = connection.CreateCommand();
|
||||
index.CommandText = """
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ITaskRepository? _tasks;
|
||||
|
||||
public EngineTaskDispatcher(
|
||||
AgentEngine engine,
|
||||
@@ -38,8 +39,10 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider? clock = null)
|
||||
TimeProvider? clock = null,
|
||||
ITaskRepository? tasks = null)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_engine = engine;
|
||||
_agents = agents;
|
||||
_instanceId = instanceId;
|
||||
@@ -56,7 +59,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
return await DispatchToolJobAsync(task, ct);
|
||||
|
||||
var agents = _agents();
|
||||
var message = BuildMessage(task);
|
||||
var message = BuildMessage(task, await WhyAsync(task, ct));
|
||||
var kind = TaskAssignee.KindOf(task.Assignee);
|
||||
|
||||
AgentRunResult result;
|
||||
@@ -158,7 +161,27 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
return agents.Count == 1 ? agents[0] : null;
|
||||
}
|
||||
|
||||
internal string BuildMessage(TaskItem task)
|
||||
/// <summary>
|
||||
/// #714: The goal chain of the task — its parents up to the goal of the unit. A lookup
|
||||
/// that fails costs the paragraph, never the run.
|
||||
/// </summary>
|
||||
private async Task<string?> WhyAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (_tasks is null)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var parents = await GoalChain.ParentsAsync(task, _tasks.GetAsync, ct);
|
||||
return GoalChain.Describe(task, parents, _engine.OrganisationProvider?.Invoke());
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Goal chain of task {TaskId} could not be read; the run goes ahead without it", task.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal string BuildMessage(TaskItem task, string? why = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
|
||||
@@ -168,6 +191,12 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
// und ein Modell ohne diese Zeile raet aus seinem Trainingsstand.
|
||||
sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(why))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(why.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
{
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #714: Why a task exists — its parent, that one's parent, up to the goal of the unit.
|
||||
/// Taken from Paperclip (Organisation-Konzept, 9a): an agent that only sees its own order
|
||||
/// cannot weigh it. "Write a product text" is a different job when it serves "launch the
|
||||
/// spring collection by Friday" than when it serves "clean up old listings".
|
||||
///
|
||||
/// <para><b>Purpose, not precondition.</b> <see cref="TaskItem.BlockedBy"/> says when a task
|
||||
/// may run; the parent says what it is for. Both can point to the same task, and mean
|
||||
/// different things.</para>
|
||||
///
|
||||
/// <para>Walked at run time, never stored as text: a parent that is renamed or finished
|
||||
/// shows up as it is now. The walk stops at a cycle and after <see cref="MaxDepth"/> steps,
|
||||
/// so a broken chain cannot hang a run or flood its prompt.</para>
|
||||
/// </summary>
|
||||
public static class GoalChain
|
||||
{
|
||||
public const int MaxDepth = 8;
|
||||
|
||||
/// <summary>The parents of a task, nearest first. Stops at a missing parent or a cycle.</summary>
|
||||
public static async Task<IReadOnlyList<TaskItem>> ParentsAsync(
|
||||
TaskItem task, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
|
||||
{
|
||||
var parents = new List<TaskItem>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { task.Id };
|
||||
var next = task.ParentId;
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(next) && parents.Count < MaxDepth && seen.Add(next))
|
||||
{
|
||||
if (await lookup(next, ct) is not { } parent)
|
||||
break;
|
||||
parents.Add(parent);
|
||||
next = parent.ParentId;
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether making <paramref name="parentId"/> the parent of <paramref name="taskId"/>
|
||||
/// would close a circle — the parent is the task itself or lies below it.
|
||||
/// </summary>
|
||||
public static async Task<bool> WouldCycleAsync(
|
||||
string taskId, string parentId, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(taskId, parentId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var next = parentId;
|
||||
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next))
|
||||
{
|
||||
if (string.Equals(next, taskId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
next = (await lookup(next, ct))?.ParentId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The goal above the chain: that of the unit of the topmost task that belongs to one —
|
||||
/// a goal a human set on the top level belongs to no unit, so the next one down counts —
|
||||
/// or of the nearest unit above it that has a goal. <c>null</c> without units or without
|
||||
/// a goal.
|
||||
/// </summary>
|
||||
public static (string UnitName, string Goal)? UnitGoal(IReadOnlyList<TaskItem> fromTop, OrgChart? org)
|
||||
{
|
||||
if (org is not { HasUnits: true })
|
||||
return null;
|
||||
var unitId = fromTop.Select(t => TaskBoard.UnitOf(t, org)).FirstOrDefault(u => u is not null);
|
||||
if (org.Unit(unitId) is not { } unit)
|
||||
return null;
|
||||
|
||||
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate.Goal))
|
||||
return (candidate.Name, candidate.Goal.Trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph that goes into the run: "you do this because … because … because".
|
||||
/// <c>null</c> when there is nothing above the task — then the run looks as before.
|
||||
/// </summary>
|
||||
public static string? Describe(TaskItem task, IReadOnlyList<TaskItem> parents, OrgChart? org)
|
||||
{
|
||||
var goal = UnitGoal([.. parents.Reverse(), task], org);
|
||||
if (parents.Count == 0 && goal is null)
|
||||
return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Why this task exists — it serves, from nearest to farthest:");
|
||||
foreach (var parent in parents)
|
||||
sb.AppendLine($"- {parent.Id} \"{parent.Title}\" ({TaskText.Of(parent.Status)})");
|
||||
if (goal is { } g)
|
||||
sb.AppendLine($"- the goal of the unit \"{g.UnitName}\": {g.Goal}");
|
||||
sb.Append("Weigh your work against this. If the task no longer serves it — the parent is done or canceled, or the task contradicts the goal — say so in your result instead of working blindly.");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
INSERT INTO Tasks
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId);
|
||||
""";
|
||||
BindDefinition(insert, task);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
@@ -60,6 +60,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
RequireApproval = @requireApproval, Acceptance = @acceptance,
|
||||
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
|
||||
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
|
||||
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
|
||||
ParentId = @parentId,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
@@ -331,7 +333,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
@@ -351,6 +353,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
cmd.Parameters.AddWithValue("@fileName", task.FileName);
|
||||
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
|
||||
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
|
||||
// #684: a file without it (written by hand, or older) keeps what the database knows.
|
||||
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
|
||||
cmd.Parameters.AddWithValue("@parentId", task.ParentId ?? "");
|
||||
}
|
||||
|
||||
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
|
||||
@@ -401,7 +406,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
BudgetMissCount = r.GetInt32(22),
|
||||
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
|
||||
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
|
||||
CatchUpPending = r.GetInt32(25) != 0
|
||||
CatchUpPending = r.GetInt32(25) != 0,
|
||||
CreatedBy = r.GetString(26),
|
||||
ParentId = r.GetString(27)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #684: One board per unit. With 50 agents one board for everything is worthless as an
|
||||
/// overview — the test instance had 159 tasks with seven agents — and a bookkeeping task
|
||||
/// could slow down the creation of a marketing task through the duplicate check.
|
||||
///
|
||||
/// <para><b>The board of a task is not stored, it follows from the organisation:</b> the unit
|
||||
/// of the agent it is assigned to, or — for a task for a human or a fresh run — the unit of
|
||||
/// the agent that created it. So open tasks move with an agent that moves (Organisation-
|
||||
/// Konzept, section 3), and nothing has to be migrated. A task that belongs to no unit is on
|
||||
/// the board of the top level.</para>
|
||||
///
|
||||
/// <para><b>Who sees which board:</b> an agent its own unit's; a lead also the boards of every
|
||||
/// unit below the one it leads; everyone the tasks they created themselves, so a task handed
|
||||
/// up to the lead stays in view of whoever handed it up. A task across units is a delegation
|
||||
/// to the other unit's lead — the address book decides that, not this.</para>
|
||||
///
|
||||
/// <para><b>Without units nothing changes:</b> there is one board, and everyone sees it.</para>
|
||||
/// </summary>
|
||||
public static class TaskBoard
|
||||
{
|
||||
/// <summary>The unit whose board the task is on; <c>null</c> for the top level.</summary>
|
||||
public static string? UnitOf(TaskItem task, OrgChart org)
|
||||
{
|
||||
var assigned = TaskAssignee.AgentId(task.Assignee);
|
||||
if (assigned.Length > 0 && org.Agent(assigned) is not null)
|
||||
return org.UnitOf(assigned)?.Id;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.CreatedBy) && org.Agent(task.CreatedBy) is not null)
|
||||
return org.UnitOf(task.CreatedBy)?.Id;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What an agent may see — <c>null</c> for "everything": without units, without an
|
||||
/// organisation (outside the engine), or for an agent the organisation does not know.
|
||||
/// </summary>
|
||||
public static Func<TaskItem, bool>? VisibleTo(OrgChart? org, string agentId)
|
||||
{
|
||||
if (org is not { HasUnits: true } || org.Agent(agentId) is null)
|
||||
return null;
|
||||
|
||||
var boards = BoardsOf(org, agentId);
|
||||
var topLevel = org.UnitOf(agentId) is null;
|
||||
|
||||
return task =>
|
||||
string.Equals(task.CreatedBy, agentId, StringComparison.OrdinalIgnoreCase)
|
||||
|| (UnitOf(task, org) is { } unit ? boards.Contains(unit) : topLevel);
|
||||
}
|
||||
|
||||
/// <summary>The units whose boards an agent sees: its own, and all below the unit it leads.</summary>
|
||||
public static IReadOnlySet<string> BoardsOf(OrgChart org, string agentId)
|
||||
{
|
||||
var boards = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (org.UnitOf(agentId) is { } own)
|
||||
boards.Add(own.Id);
|
||||
|
||||
if (org.LedBy(agentId) is { } led)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(led.Id);
|
||||
while (pending.TryPop(out var unitId))
|
||||
{
|
||||
if (!seen.Add(unitId))
|
||||
continue; // a broken tree must not hang this
|
||||
boards.Add(unitId);
|
||||
foreach (var child in org.Children(unitId))
|
||||
pending.Push(child.Id);
|
||||
}
|
||||
}
|
||||
return boards;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,8 @@ public static class TaskFrontmatter
|
||||
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
|
||||
ToolName = scalars.GetValueOrDefault("tool_name", ""),
|
||||
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
|
||||
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
|
||||
ParentId = scalars.GetValueOrDefault("parent", ""),
|
||||
Body = body
|
||||
};
|
||||
return true;
|
||||
@@ -169,6 +171,10 @@ public static class TaskFrontmatter
|
||||
sb.Append($"type: {TaskText.Of(task.Type)}\n");
|
||||
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
|
||||
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
|
||||
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
|
||||
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
|
||||
if (!string.IsNullOrWhiteSpace(task.ParentId))
|
||||
sb.Append($"parent: {Scalar(task.ParentId)}\n");
|
||||
|
||||
if (task.When is { } w)
|
||||
{
|
||||
|
||||
@@ -228,6 +228,20 @@ public sealed record TaskItem
|
||||
/// </summary>
|
||||
public bool CatchUpPending { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #684: The agent that created the task; empty for tasks from a human, from a file
|
||||
/// written by hand, or from before this field. Decides the board of a task nobody in
|
||||
/// particular is assigned to — see <see cref="TaskBoard"/>.
|
||||
/// </summary>
|
||||
public string CreatedBy { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// #714: The task this one serves — its purpose ("what for"), where <see cref="BlockedBy"/>
|
||||
/// is its precondition ("when"). Empty for a task at the top; above the top stands the
|
||||
/// goal of the unit. See <see cref="GoalChain"/>.
|
||||
/// </summary>
|
||||
public string ParentId { get; init; } = "";
|
||||
|
||||
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
|
||||
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
|
||||
|
||||
@@ -269,4 +283,24 @@ public sealed record TaskQuery
|
||||
public bool IncludeArchived { get; init; }
|
||||
|
||||
public int Limit { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// #684: The same filter as the repository's query, for a list that is already in
|
||||
/// memory — when a board has to be filtered first and the database cannot do that.
|
||||
/// </summary>
|
||||
public bool Matches(TaskItem task)
|
||||
{
|
||||
if (Status is { } status ? task.Status != status : !IncludeArchived && task.Status == TaskItemStatus.Archived)
|
||||
return false;
|
||||
if (!string.IsNullOrWhiteSpace(Assignee) && !string.Equals(task.Assignee, Assignee.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (!string.IsNullOrWhiteSpace(Search))
|
||||
{
|
||||
var search = Search.Trim();
|
||||
if (!task.Title.Contains(search, StringComparison.OrdinalIgnoreCase)
|
||||
&& !task.Body.Contains(search, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
private readonly ILateRunGate? _lateRunGate;
|
||||
|
||||
private readonly IBudgetOutlook? _outlook;
|
||||
private readonly ITaskTreeWatch? _treeWatch;
|
||||
|
||||
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
|
||||
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
|
||||
@@ -163,8 +164,10 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
int maxParallelRuns = DefaultMaxParallelRuns,
|
||||
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
|
||||
ILateRunGate? lateRunGate = null,
|
||||
IBudgetOutlook? budgetOutlook = null)
|
||||
IBudgetOutlook? budgetOutlook = null,
|
||||
ITaskTreeWatch? treeWatch = null)
|
||||
{
|
||||
_treeWatch = treeWatch;
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
_market = market ?? new AlwaysOpenMarketCalendar();
|
||||
@@ -286,6 +289,10 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
SummarizeMissesIfNewDay(now);
|
||||
await WarnAboutShortfallsAsync(candidates, now, ct);
|
||||
|
||||
// #714: resting task trees are looked at — the watch decides itself how often.
|
||||
if (_treeWatch is not null)
|
||||
await _treeWatch.CheckAsync(now, ct);
|
||||
|
||||
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.State;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>A check the scanner runs on every tick; it decides itself how often it acts.</summary>
|
||||
public interface ITaskTreeWatch
|
||||
{
|
||||
Task CheckAsync(DateTime nowUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>An open task whose whole subtree has come to rest.</summary>
|
||||
public sealed record RestingTree(TaskItem Root, IReadOnlyList<TaskItem> Below);
|
||||
|
||||
/// <summary>
|
||||
/// #714: A task tree that has stopped moving is looked at. Taken from Paperclip's "task
|
||||
/// watchdog" (Organisation-Konzept, 9a): agents stop for wrong reasons — "done" without
|
||||
/// evidence, a review nobody does, a blocker read wrongly, an error given up on that could
|
||||
/// have been fixed. Without a check the tree simply lies still, and nobody notices.
|
||||
///
|
||||
/// <para><b>Detecting is code, interpreting is a model.</b> Whether a tree rests is a query
|
||||
/// over the board (this class, the first piece of #646). Whether the rest is justified is
|
||||
/// decided by the lead of the unit the tree belongs to — or a human, where there is no lead
|
||||
/// — through a review task. The reviewer may send work back (comment, reopen), not do it
|
||||
/// itself (the line of #680).</para>
|
||||
///
|
||||
/// <para><b>Once per state of rest.</b> The review is not repeated while the tree stays
|
||||
/// exactly as it was: a reviewer who accepts the rest is not asked again. Only when the
|
||||
/// tree has moved and comes to rest in a different state is it looked at again. The review
|
||||
/// task is not part of the tree, so it does not change what it reviews.</para>
|
||||
/// </summary>
|
||||
public sealed class TaskTreeWatch(
|
||||
ITaskRepository repo,
|
||||
Func<TaskItem, CancellationToken, Task> createTask,
|
||||
IStateStore state,
|
||||
Func<OrgChart?> organisation,
|
||||
ILogger logger) : ITaskTreeWatch
|
||||
{
|
||||
/// <summary>How often the board is looked at.</summary>
|
||||
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
|
||||
|
||||
private const string StateKeyPrefix = "tasktree.rest.";
|
||||
|
||||
private DateTime _lastCheckAt = DateTime.MinValue;
|
||||
|
||||
public async Task CheckAsync(DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
if (nowUtc - _lastCheckAt < Interval)
|
||||
return;
|
||||
_lastCheckAt = nowUtc;
|
||||
|
||||
try
|
||||
{
|
||||
var all = await repo.ListAllAsync(includeArchived: false, ct);
|
||||
foreach (var tree in FindResting(all))
|
||||
await ReviewOnceAsync(tree, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Check of resting task trees failed — next attempt in {Minutes} min", Interval.TotalMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The open tasks whose subtree has come to rest — only the topmost of them, so a tree is
|
||||
/// reviewed once and not again for each of its resting branches. Pure, so the rules can
|
||||
/// be tested without an instance.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<RestingTree> FindResting(IReadOnlyList<TaskItem> all)
|
||||
{
|
||||
var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var children = all
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.ParentId) && byId.ContainsKey(t.ParentId))
|
||||
.GroupBy(t => t.ParentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<TaskItem> Below(string id)
|
||||
{
|
||||
var result = new List<TaskItem>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(id);
|
||||
while (pending.TryPop(out var current))
|
||||
{
|
||||
foreach (var child in children.GetValueOrDefault(current) ?? [])
|
||||
{
|
||||
if (!seen.Add(child.Id))
|
||||
continue; // a cycle in the data must not hang this
|
||||
result.Add(child);
|
||||
pending.Push(child.Id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var resting = new Dictionary<string, RestingTree>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var root in all.Where(t => children.ContainsKey(t.Id) && IsOpen(t)))
|
||||
{
|
||||
var below = Below(root.Id);
|
||||
if (below.Count > 0 && below.All(IsAtRest) && IsAtRest(root))
|
||||
resting[root.Id] = new RestingTree(root, below);
|
||||
}
|
||||
|
||||
// Only the topmost: a resting tree inside another resting tree is part of that one.
|
||||
return resting.Values
|
||||
.Where(tree => !Ancestors(tree.Root, byId).Any(a => resting.ContainsKey(a.Id)))
|
||||
.OrderBy(tree => tree.Root.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Not finished: the tree still has a purpose to reach.</summary>
|
||||
private static bool IsOpen(TaskItem task)
|
||||
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
|
||||
|
||||
/// <summary>
|
||||
/// Nothing will move this task by itself: it is not running, and the scanner will not
|
||||
/// start it — a todo for an agent is still on its way, a todo for a human is not.
|
||||
/// </summary>
|
||||
private static bool IsAtRest(TaskItem task) => task.Status switch
|
||||
{
|
||||
TaskItemStatus.InProgress => false,
|
||||
TaskItemStatus.Todo => TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human,
|
||||
_ => true
|
||||
};
|
||||
|
||||
private static IEnumerable<TaskItem> Ancestors(TaskItem task, IReadOnlyDictionary<string, TaskItem> byId)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { task.Id };
|
||||
var next = task.ParentId;
|
||||
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next) && byId.TryGetValue(next, out var parent))
|
||||
{
|
||||
yield return parent;
|
||||
next = parent.ParentId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the tree looks like — the same value means nothing moved since the last review.</summary>
|
||||
public static string Fingerprint(RestingTree tree)
|
||||
{
|
||||
var text = string.Join("|", new[] { tree.Root }.Concat(tree.Below)
|
||||
.OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(t => $"{t.Id}:{TaskText.Of(t.Status)}"));
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..16];
|
||||
}
|
||||
|
||||
private async Task ReviewOnceAsync(RestingTree tree, CancellationToken ct)
|
||||
{
|
||||
var key = StateKeyPrefix + tree.Root.Id;
|
||||
var fingerprint = Fingerprint(tree);
|
||||
if (await state.GetAsync(key, ct) == fingerprint)
|
||||
return;
|
||||
|
||||
var reviewer = ReviewerOf(tree.Root, organisation());
|
||||
await createTask(BuildReview(tree, reviewer), ct);
|
||||
await state.SetAsync(key, fingerprint, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Task tree {Root} has come to rest ({Count} tasks below) — review assigned to {Reviewer}",
|
||||
tree.Root.Id, tree.Below.Count, reviewer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lead of the unit whose board the tree is on — or, where that unit has none, the
|
||||
/// lead above it; without any, a human.
|
||||
/// </summary>
|
||||
public static string ReviewerOf(TaskItem root, OrgChart? org)
|
||||
{
|
||||
if (org is not { HasUnits: true } || TaskBoard.UnitOf(root, org) is not { } unitId)
|
||||
return TaskAssignee.Human;
|
||||
|
||||
foreach (var unit in new[] { org.Unit(unitId)! }.Concat(org.Ancestors(unitId)))
|
||||
{
|
||||
if (org.LeaderOf(unit.Id) is { } lead)
|
||||
return "@" + lead.AgentId;
|
||||
}
|
||||
return TaskAssignee.Human;
|
||||
}
|
||||
|
||||
/// <summary>The review task. It stands beside the tree, not in it — it must not change what it reviews.</summary>
|
||||
public static TaskItem BuildReview(RestingTree tree, string reviewer)
|
||||
{
|
||||
var body = new StringBuilder();
|
||||
body.AppendLine($"The work under {tree.Root.Id} \"{tree.Root.Title}\" has come to rest: nothing in it is running or about to run, but {tree.Root.Id} is not finished.");
|
||||
body.AppendLine();
|
||||
body.AppendLine($"- {tree.Root.Id} [{TaskText.Of(tree.Root.Status)}] {tree.Root.Title} ({tree.Root.Assignee})");
|
||||
foreach (var task in tree.Below.OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase))
|
||||
body.AppendLine($" - {task.Id} [{TaskText.Of(task.Status)}] {task.Title} ({task.Assignee}{(string.IsNullOrWhiteSpace(task.ParentId) ? "" : $", serves {task.ParentId}")})");
|
||||
body.AppendLine();
|
||||
body.AppendLine("Decide whether the standstill is justified. Read the tasks and their comments. Typical wrong stops:");
|
||||
body.AppendLine("- marked done without evidence that the result exists,");
|
||||
body.AppendLine("- waiting for a review that nobody does,");
|
||||
body.AppendLine("- blocked by something that is already finished, or was misread,");
|
||||
body.AppendLine("- an error given up on that could have been fixed.");
|
||||
body.AppendLine();
|
||||
body.AppendLine("If it is justified, say why in your result. If not, send the work back: comment on the task and reopen it (task_comment with reopen), or finish or cancel the top task. Do not do the work yourself — your job is to judge it, not to redo it.");
|
||||
|
||||
return new TaskItem
|
||||
{
|
||||
Title = $"Review: work under {tree.Root.Id} has come to rest",
|
||||
Body = body.ToString().TrimEnd(),
|
||||
Assignee = reviewer,
|
||||
Priority = Math.Clamp(tree.Root.Priority, 1, 5),
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskItemType.Work,
|
||||
Acceptance = "The result says, for each wrongly stopped task, what was sent back — or why the standstill is justified."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,28 @@ public sealed class TaskboardService
|
||||
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
=> _repo.ListAsync(query, ct);
|
||||
|
||||
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
|
||||
/// <summary>
|
||||
/// #684: A list restricted to what one agent may see. The boards cannot be expressed in
|
||||
/// the database query — they follow from the organisation — so the whole board is read
|
||||
/// and filtered here, then capped: filtering after the database's cap would silently
|
||||
/// drop tasks of the own unit behind those of others.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
if (visible is null)
|
||||
return await _repo.ListAsync(query, ct);
|
||||
|
||||
var all = await _repo.ListAllAsync(query.IncludeArchived || query.Status == TaskItemStatus.Archived, ct);
|
||||
return all
|
||||
.Where(visible)
|
||||
.Where(query.Matches)
|
||||
.OrderByDescending(t => t.Priority)
|
||||
.ThenByDescending(t => t.UpdatedAt)
|
||||
.Take(Math.Clamp(query.Limit, 1, 500))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The whole board, without the cap of <see cref="ListAsync(TaskQuery, CancellationToken)"/>.</summary>
|
||||
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
|
||||
=> _repo.ListAllAsync(includeArchived, ct);
|
||||
|
||||
|
||||
@@ -2298,6 +2298,25 @@ button.org-einheit .org-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* #684: workspaces below the structure */
|
||||
.org-bereiche {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.org-bereiche > p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.org-mitglieder select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-mitglieder td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.org-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2353,3 +2372,8 @@ tr.org-ausreisser td {
|
||||
@media (max-width: 900px) {
|
||||
.org-raster { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* #684: the board filter above the task cards */
|
||||
.taskboard-bretter {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,43 @@
|
||||
"org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
|
||||
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
|
||||
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
|
||||
"org.workspaces": "Arbeitsbereiche",
|
||||
"org.workspacesHint": "Gemeinsame Ordner mit Mitgliederliste. Wer nicht auf der Liste steht, sieht den Bereich nicht.",
|
||||
"org.noWorkspaces": "Noch keine Arbeitsbereiche — eine Einheit bekommt ihren beim Anlegen.",
|
||||
"org.newWorkspace": "Neuer Arbeitsbereich",
|
||||
"org.newWorkspaceHint": "Für etwas, das mehrere Einheiten bewusst teilen, etwa einen Artikelstamm. Die Eigentümer-Einheit verwaltet ihn; Leser kommen danach dazu.",
|
||||
"org.workspaceId": "Id (Ordnername)",
|
||||
"org.workspaceName": "Name",
|
||||
"org.purpose": "Zweck",
|
||||
"org.owner": "Eigentümer",
|
||||
"org.noOwner": "— Instanz (Mensch) —",
|
||||
"org.fileCount": "{n} Dateien",
|
||||
"org.unitOwnWorkspace": "eigener Bereich der Einheit",
|
||||
"org.unitWorkspace": "Arbeitsbereich",
|
||||
"org.unitWorkspaceMissing": "Diese Einheit hat noch keinen eigenen Arbeitsbereich.",
|
||||
"org.createUnitWorkspace": "Arbeitsbereich anlegen",
|
||||
"org.editWorkspace": "Arbeitsbereich bearbeiten",
|
||||
"org.protectedPaths": "Geschützte Ordner",
|
||||
"org.protectedHint": "Durch Komma getrennt. Dort können Dateien hinzukommen, ändern oder löschen dürfen sie nur Verwalter des Bereichs.",
|
||||
"org.memberEntries": "Mitgliederliste",
|
||||
"org.membersHint": "Das Recht steht am Bereich. Ein Einheitseintrag gilt auch für die Einheiten darunter, wenn nicht abgeschaltet. Löschen braucht Verwalten.",
|
||||
"org.noWorkspaceMembers": "niemand",
|
||||
"org.memberKind": "Art",
|
||||
"org.memberWho": "Wer",
|
||||
"org.memberUnit": "Einheit",
|
||||
"org.memberAgent": "Agent",
|
||||
"org.accessLevel": "Stufe",
|
||||
"org.access.Read": "lesen",
|
||||
"org.access.Write": "schreiben",
|
||||
"org.access.Manage": "verwalten",
|
||||
"org.includeSubUnits": "inkl. Untereinheiten",
|
||||
"org.addMember": "Mitglied hinzufügen",
|
||||
"org.removeMember": "Mitglied entfernen",
|
||||
"org.whoGetsIn": "Wer hineinkommt",
|
||||
"org.nobodyHasAccess": "Niemand — nur ein Mensch kommt heran.",
|
||||
"org.deleteWorkspace": "Arbeitsbereich löschen",
|
||||
"org.deleteWorkspaceHint": "Nur ein leerer Bereich lässt sich löschen. Abgelegtes wird von hier aus nie gelöscht.",
|
||||
"org.unitWorkspaceStays": "Der eigene Bereich einer Einheit geht mit der Einheit. Hält er dann noch Dateien, bleibt er ohne Eigentümer.",
|
||||
"team.title": "Teamübersicht",
|
||||
"team.subtitle": "Jede Einheit ist eine Karte, verbunden wie die Organisation. Eine Linie trägt die Farbe des dringlichsten Zustands darunter, eine rote Linie führt also zum Problem. Klick auf Karte oder Agent zeigt Einzelheiten.",
|
||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||
@@ -309,6 +346,11 @@
|
||||
"tasks.budgetMissedLast": "zuletzt {time}",
|
||||
"tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}",
|
||||
"tasks.budgetCatchUp": "ein Nachholauf ab {time}",
|
||||
"tasks.board": "Brett",
|
||||
"tasks.boardAll": "Alle Bretter",
|
||||
"tasks.boardTopLevel": "Oberste Ebene",
|
||||
"tasks.parent": "Dient Aufgabe (Eltern)",
|
||||
"tasks.serves": "dient {id}",
|
||||
|
||||
"services.title": "Dienste",
|
||||
"services.newService": "Neuer Dienst",
|
||||
|
||||
@@ -127,6 +127,43 @@
|
||||
"org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
|
||||
"org.toolsLostConfirm": "Remove the tools and apply",
|
||||
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
|
||||
"org.workspaces": "Workspaces",
|
||||
"org.workspacesHint": "Shared folders with a member list. Whoever is not on the list does not see the workspace.",
|
||||
"org.noWorkspaces": "No workspaces yet — a unit gets one when it is created.",
|
||||
"org.newWorkspace": "New workspace",
|
||||
"org.newWorkspaceHint": "For something several units share on purpose, like an article master. The owning unit manages it; add readers afterwards.",
|
||||
"org.workspaceId": "Id (folder name)",
|
||||
"org.workspaceName": "Name",
|
||||
"org.purpose": "Purpose",
|
||||
"org.owner": "Owner",
|
||||
"org.noOwner": "— instance (human) —",
|
||||
"org.fileCount": "{n} files",
|
||||
"org.unitOwnWorkspace": "the unit's own workspace",
|
||||
"org.unitWorkspace": "Workspace",
|
||||
"org.unitWorkspaceMissing": "This unit has no workspace of its own yet.",
|
||||
"org.createUnitWorkspace": "Create its workspace",
|
||||
"org.editWorkspace": "Edit workspace",
|
||||
"org.protectedPaths": "Protected folders",
|
||||
"org.protectedHint": "Comma separated. Files there can be added, but only members who manage the workspace change or delete them.",
|
||||
"org.memberEntries": "Member list",
|
||||
"org.membersHint": "The right stands at the workspace. A unit entry includes the units below it unless switched off. Deleting files needs Manage.",
|
||||
"org.noWorkspaceMembers": "nobody",
|
||||
"org.memberKind": "Kind",
|
||||
"org.memberWho": "Who",
|
||||
"org.memberUnit": "Unit",
|
||||
"org.memberAgent": "Agent",
|
||||
"org.accessLevel": "Level",
|
||||
"org.access.Read": "read",
|
||||
"org.access.Write": "write",
|
||||
"org.access.Manage": "manage",
|
||||
"org.includeSubUnits": "incl. sub-units",
|
||||
"org.addMember": "Add member",
|
||||
"org.removeMember": "Remove member",
|
||||
"org.whoGetsIn": "Who gets in",
|
||||
"org.nobodyHasAccess": "Nobody — only a human reaches it.",
|
||||
"org.deleteWorkspace": "Delete workspace",
|
||||
"org.deleteWorkspaceHint": "Only an empty workspace can be deleted. Nothing filed is deleted from here.",
|
||||
"org.unitWorkspaceStays": "A unit's own workspace goes with its unit. If it still holds files then, it stays without owner.",
|
||||
"team.title": "Team overview",
|
||||
"team.subtitle": "Every unit is a card, connected as the organisation. A line takes the colour of the most urgent state below it, so a red line leads to the problem. Click a card or an agent for details.",
|
||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||
@@ -309,6 +346,11 @@
|
||||
"tasks.budgetMissedLast": "last at {time}",
|
||||
"tasks.budgetCarriedOver": "carried over, due again from {time}",
|
||||
"tasks.budgetCatchUp": "one catch-up run from {time}",
|
||||
"tasks.board": "Board",
|
||||
"tasks.boardAll": "All boards",
|
||||
"tasks.boardTopLevel": "Top level",
|
||||
"tasks.parent": "Serves task (parent)",
|
||||
"tasks.serves": "serves {id}",
|
||||
|
||||
"services.title": "Services",
|
||||
"services.newService": "New Service",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//
|
||||
// A list, not a graph: this is where the structure is edited. Watching it work is the team
|
||||
// overview (#711).
|
||||
//
|
||||
// #684: Workspaces sit beside the units. A unit gets its own; a workspace across units is
|
||||
// made on purpose, with an owner. The right stands at the workspace — its member list — and
|
||||
// the page shows, computed by the instance, who that list actually lets in.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
|
||||
@@ -13,9 +17,10 @@ import { t } from "../modules/i18n.js";
|
||||
|
||||
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
|
||||
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
|
||||
const ACCESS = ["Read", "Write", "Manage"];
|
||||
|
||||
let data = null;
|
||||
let selection = null; // { kind: "unit" | "agent", id }
|
||||
let selection = null; // { kind: "unit" | "agent" | "workspace", id }
|
||||
let treeBox = null;
|
||||
let detailBox = null;
|
||||
let reasonInput = null;
|
||||
@@ -57,11 +62,35 @@ async function load() {
|
||||
|
||||
function find(sel) {
|
||||
if (!sel || !data) return null;
|
||||
if (sel.kind === "workspace") return data.workspaces.find((w) => w.id === sel.id);
|
||||
return sel.kind === "unit"
|
||||
? data.units.find((u) => u.id === sel.id)
|
||||
: data.agents.find((a) => a.agentId === sel.id);
|
||||
}
|
||||
|
||||
/** A unit's own workspace, if it has one. */
|
||||
function ownWorkspace(unitId) {
|
||||
return data.workspaces.find((w) => w.isUnitOwn && w.ownerUnitId === unitId);
|
||||
}
|
||||
|
||||
function unitPath(unitId) {
|
||||
return data.units.find((u) => u.id === unitId)?.path ?? unitId;
|
||||
}
|
||||
|
||||
function accessLabel(access) {
|
||||
return t(`org.access.${access}`);
|
||||
}
|
||||
|
||||
/** "Purchasing (write, incl. sub-units); ads (read)". */
|
||||
function membersSummary(workspace) {
|
||||
if (!workspace.members.length) return t("org.noWorkspaceMembers");
|
||||
return workspace.members.map((m) => {
|
||||
const who = m.unitId ? unitPath(m.unitId) : m.agentId;
|
||||
const sub = m.unitId && m.includeSubUnits ? `, ${t("org.includeSubUnits")}` : "";
|
||||
return `${who} (${accessLabel(m.access)}${sub})`;
|
||||
}).join("; ");
|
||||
}
|
||||
|
||||
function childrenOf(unitId) {
|
||||
return data.units.filter((u) => (u.parentId ?? null) === unitId);
|
||||
}
|
||||
@@ -180,7 +209,28 @@ function renderTree() {
|
||||
treeBox.replaceChildren(
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
|
||||
erstelleElement("h2", { text: t("org.structure") }), newUnit),
|
||||
list);
|
||||
list,
|
||||
workspaceList());
|
||||
}
|
||||
|
||||
function workspaceList() {
|
||||
const rows = data.workspaces.map((w) => erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: `org-knoten org-bereich ${selection?.kind === "workspace" && selection.id === w.id ? "aktiv" : ""}`,
|
||||
onclick: () => select({ kind: "workspace", id: w.id })
|
||||
},
|
||||
erstelleElement("span", { klasse: "org-name", text: `▤ ${w.name}` }),
|
||||
erstelleElement("span", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: `${w.ownerUnitId ? unitPath(w.ownerUnitId) : t("org.noOwner")} · ${t("org.fileCount", { n: w.fileCount })}`
|
||||
})));
|
||||
|
||||
return erstelleElement("div", { klasse: "org-bereiche" },
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
|
||||
erstelleElement("h2", { text: t("org.workspaces") }),
|
||||
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.newWorkspace"), onclick: () => openCreateWorkspace() })),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.workspacesHint") }),
|
||||
...(rows.length ? rows : [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noWorkspaces") })]));
|
||||
}
|
||||
|
||||
function unitBlock(unit, depth) {
|
||||
@@ -261,7 +311,10 @@ function renderDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
|
||||
const panels = selection.kind === "unit" ? unitPanel(item)
|
||||
: selection.kind === "workspace" ? workspacePanel(item)
|
||||
: agentPanel(item);
|
||||
detailBox.replaceChildren(reasonCard, ...panels);
|
||||
}
|
||||
|
||||
function unitPanel(unit) {
|
||||
@@ -311,6 +364,7 @@ function unitPanel(unit) {
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
|
||||
]));
|
||||
|
||||
cards.push(unitWorkspaceCard(unit));
|
||||
cards.push(ceilingCard(unit));
|
||||
cards.push(settingsCard(unit));
|
||||
|
||||
@@ -456,7 +510,8 @@ function agentPanel(agent) {
|
||||
erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }),
|
||||
erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }),
|
||||
agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null,
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` })
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }),
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.workspaces")}: ${agentWorkspaces(agent) || "—"}` })
|
||||
]),
|
||||
erstelleKarte(t("org.moveAgent"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
|
||||
@@ -469,3 +524,207 @@ function agentPanel(agent) {
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// ─── workspaces (#684) ───
|
||||
|
||||
function agentWorkspaces(agent) {
|
||||
return data.workspaces
|
||||
.map((w) => ({ w, grant: w.access.find((a) => a.agentId === agent.agentId) }))
|
||||
.filter((x) => x.grant)
|
||||
.map((x) => `${x.w.id} (${accessLabel(x.grant.access)})`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function unitWorkspaceCard(unit) {
|
||||
const own = ownWorkspace(unit.id);
|
||||
if (!own) {
|
||||
return erstelleKarte(t("org.unitWorkspace"), [
|
||||
erstelleElement("p", { klasse: "text-klein", text: t("org.unitWorkspaceMissing") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "klein", text: t("org.createUnitWorkspace"),
|
||||
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/workspace`, "POST", { reason: reason() })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
return erstelleKarte(t("org.unitWorkspace"), [
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${own.id} · ${t("org.fileCount", { n: own.fileCount })}` }),
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.memberEntries")}: ${membersSummary(own)}` }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: t("org.editWorkspace"),
|
||||
onclick: () => select({ kind: "workspace", id: own.id })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function openCreateWorkspace() {
|
||||
const id = erstelleElement("input", { type: "text", placeholder: "articles", required: true });
|
||||
const name = erstelleElement("input", { type: "text", placeholder: "Article master", required: true });
|
||||
const purpose = erstelleElement("textarea", { rows: 2 });
|
||||
const owner = unitOptions(erstelleElement("select"), null, new Set(), t("org.noOwner"));
|
||||
|
||||
const form = erstelleElement("form", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.workspaceId") }), id,
|
||||
erstelleElement("label", { text: t("org.workspaceName") }), name,
|
||||
erstelleElement("label", { text: t("org.purpose") }), purpose,
|
||||
erstelleElement("label", { text: t("org.owner") }), owner,
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.newWorkspaceHint") }));
|
||||
|
||||
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
|
||||
const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") });
|
||||
const modal = erstelleModal(t("org.newWorkspace"), form, [cancel, create]);
|
||||
cancel.addEventListener("click", () => modal.schliessen());
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
// The owning unit manages what it owns; everyone else is added on purpose afterwards.
|
||||
const members = owner.value ? [{ unitId: owner.value, agentId: null, access: "Manage", includeSubUnits: true }] : [];
|
||||
const ok = await send("/organisation/workspaces", "POST", {
|
||||
id: id.value.trim(), name: name.value.trim(), purpose: purpose.value, ownerUnitId: owner.value || null, members, reason: reason()
|
||||
});
|
||||
if (ok) {
|
||||
modal.schliessen();
|
||||
select({ kind: "workspace", id: id.value.trim() });
|
||||
}
|
||||
});
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
function workspacePanel(workspace) {
|
||||
const base = `/organisation/workspaces/${encodeURIComponent(workspace.id)}`;
|
||||
const owner = workspace.ownerUnitId ? unitPath(workspace.ownerUnitId) : t("org.noOwner");
|
||||
|
||||
// Name, purpose, protected folders.
|
||||
const name = erstelleElement("input", { type: "text", value: workspace.name });
|
||||
const purpose = erstelleElement("textarea", { rows: 2 });
|
||||
purpose.value = workspace.purpose ?? "";
|
||||
const protectedPaths = erstelleElement("input", { type: "text", value: workspace.protectedPaths.join(", "), placeholder: "prices, archive" });
|
||||
|
||||
const general = erstelleKarte(workspace.name, [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein",
|
||||
text: `${workspace.id} · ${t("org.owner")}: ${owner}${workspace.isUnitOwn ? ` (${t("org.unitOwnWorkspace")})` : ""}` }),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein",
|
||||
text: `${t("org.fileCount", { n: workspace.fileCount })} · SharedWorkspaces/${workspace.id}/` }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.workspaceName") }), name,
|
||||
erstelleElement("label", { text: t("org.purpose") }), purpose,
|
||||
erstelleElement("label", { text: t("org.protectedPaths") }), protectedPaths,
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.protectedHint") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "primaer klein", text: t("common.save"),
|
||||
onclick: () => send(base, "PATCH", {
|
||||
name: name.value,
|
||||
purpose: purpose.value,
|
||||
protectedPaths: protectedPaths.value.split(",").map((x) => x.trim()).filter(Boolean),
|
||||
reason: reason()
|
||||
})
|
||||
}))
|
||||
]);
|
||||
|
||||
return [general, membersCard(workspace, base), accessCard(workspace), deleteWorkspaceCard(workspace, base)];
|
||||
}
|
||||
|
||||
/** The member list, edited as a whole and saved in one go. */
|
||||
function membersCard(workspace, base) {
|
||||
const rows = erstelleElement("tbody");
|
||||
|
||||
const addRow = (member) => {
|
||||
const kind = erstelleElement("select", {},
|
||||
erstelleElement("option", { value: "unit", text: t("org.memberUnit") }),
|
||||
erstelleElement("option", { value: "agent", text: t("org.memberAgent") }));
|
||||
kind.value = member.agentId ? "agent" : "unit";
|
||||
|
||||
const target = erstelleElement("select");
|
||||
const fillTarget = () => {
|
||||
target.replaceChildren();
|
||||
const options = kind.value === "unit"
|
||||
? data.units.map((u) => [u.id, u.path])
|
||||
: data.agents.map((a) => [a.agentId, a.displayName && a.displayName !== a.agentId ? `${a.displayName} (${a.agentId})` : a.agentId]);
|
||||
for (const [value, text] of options) target.append(erstelleElement("option", { value, text }));
|
||||
const current = kind.value === "unit" ? member.unitId : member.agentId;
|
||||
if (current) target.value = current;
|
||||
};
|
||||
|
||||
const access = erstelleElement("select", {},
|
||||
...ACCESS.map((a) => erstelleElement("option", { value: a, text: accessLabel(a) })));
|
||||
access.value = member.access ?? "Read";
|
||||
|
||||
const sub = erstelleElement("input", { type: "checkbox" });
|
||||
sub.checked = member.includeSubUnits !== false;
|
||||
const syncSub = () => { sub.disabled = kind.value !== "unit"; };
|
||||
|
||||
kind.addEventListener("change", () => { fillTarget(); syncSub(); });
|
||||
fillTarget();
|
||||
syncSub();
|
||||
|
||||
const row = erstelleElement("tr", {},
|
||||
erstelleElement("td", {}, kind),
|
||||
erstelleElement("td", {}, target),
|
||||
erstelleElement("td", {}, access),
|
||||
erstelleElement("td", {}, erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, sub, t("org.includeSubUnits"))),
|
||||
erstelleElement("td", {}, erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: "×", title: t("org.removeMember"), "aria-label": t("org.removeMember"),
|
||||
onclick: () => row.remove()
|
||||
})));
|
||||
row.lesen = () => ({
|
||||
unitId: kind.value === "unit" ? target.value : null,
|
||||
agentId: kind.value === "agent" ? target.value : null,
|
||||
access: access.value,
|
||||
includeSubUnits: kind.value === "unit" ? sub.checked : true
|
||||
});
|
||||
rows.append(row);
|
||||
};
|
||||
|
||||
for (const member of workspace.members) addRow(member);
|
||||
|
||||
return erstelleKarte(t("org.memberEntries"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.membersHint") }),
|
||||
erstelleElement("table", { klasse: "daten-tabelle org-mitglieder" },
|
||||
erstelleElement("thead", {}, erstelleElement("tr", {},
|
||||
...[t("org.memberKind"), t("org.memberWho"), t("org.accessLevel"), "", ""].map((h) => erstelleElement("th", { text: h })))),
|
||||
rows),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: t("org.addMember"),
|
||||
onclick: () => addRow({ unitId: data.units[0]?.id ?? null, agentId: data.units.length ? null : data.agents[0]?.agentId, access: "Read" })
|
||||
}),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "primaer klein", text: t("common.save"),
|
||||
onclick: () => send(`${base}/members`, "PUT", {
|
||||
members: [...rows.children].map((r) => r.lesen()),
|
||||
reason: reason()
|
||||
})
|
||||
}))
|
||||
]);
|
||||
}
|
||||
|
||||
/** Who the list lets in — computed by the instance from the tree, not guessed here. */
|
||||
function accessCard(workspace) {
|
||||
return erstelleKarte(t("org.whoGetsIn"), [
|
||||
workspace.access.length
|
||||
? erstelleElement("ul", { klasse: "org-liste" }, ...workspace.access.map((a) => erstelleElement("li", {},
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: `${a.agentId} — ${accessLabel(a.access)}`,
|
||||
onclick: () => select({ kind: "agent", id: a.agentId })
|
||||
}))))
|
||||
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.nobodyHasAccess") })
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteWorkspaceCard(workspace, base) {
|
||||
if (workspace.isUnitOwn) {
|
||||
return erstelleKarte(null, [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.unitWorkspaceStays") })]);
|
||||
}
|
||||
return erstelleKarte(null, [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.deleteWorkspaceHint") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "gefahr klein", text: t("org.deleteWorkspace"),
|
||||
disabled: workspace.fileCount > 0,
|
||||
onclick: async () => {
|
||||
if (await send(`${base}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
|
||||
selection = null;
|
||||
renderDetail();
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
|
||||
|
||||
let zeigeAbgeschlossene = false;
|
||||
|
||||
/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */
|
||||
let brettFilter = "";
|
||||
|
||||
export async function rendereAufgaben() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
@@ -114,9 +117,14 @@ async function ladeTaskboard() {
|
||||
|
||||
try {
|
||||
const alle = (await api("/tasks?limit=200")) || [];
|
||||
const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
|
||||
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
|
||||
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
|
||||
container.replaceChildren();
|
||||
|
||||
// #684: With units, every task is on a unit's board — pick one to see only that.
|
||||
const bretter = brettAuswahl(alle);
|
||||
if (bretter) container.append(bretter);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte(t("tasks.empty"), [
|
||||
@@ -141,6 +149,30 @@ async function ladeTaskboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it
|
||||
* shows only boards that hold something; absent when no task is on a unit's board.
|
||||
*/
|
||||
function brettAuswahl(tasks) {
|
||||
const bretter = new Map();
|
||||
for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board);
|
||||
if (bretter.size === 0) return null;
|
||||
|
||||
const auswahl = erstelleElement("select", {
|
||||
"aria-label": t("tasks.board"),
|
||||
onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); }
|
||||
},
|
||||
erstelleElement("option", { value: "", text: t("tasks.boardAll") }),
|
||||
erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }),
|
||||
...[...bretter.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad })));
|
||||
auswahl.value = brettFilter;
|
||||
|
||||
return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" },
|
||||
erstelleElement("span", { text: t("tasks.board") }), auswahl);
|
||||
}
|
||||
|
||||
function terminText(task) {
|
||||
if (!task.when) return t("tasks.scheduleNone");
|
||||
const art = task.when.kind;
|
||||
@@ -155,7 +187,10 @@ function erstelleAufgabenKarte(task) {
|
||||
"div",
|
||||
{ klasse: "task-status-zeile gruppe-horizontal" },
|
||||
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
|
||||
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` })
|
||||
erstelleElement("span", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}${task.parentId ? ` · ↑ ${t("tasks.serves", { id: task.parentId })}` : ""}`
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -308,6 +343,9 @@ async function oeffneAufgabenDialog(task) {
|
||||
const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." });
|
||||
abnahme.value = task?.acceptance || "";
|
||||
|
||||
// #714: The task this one serves — the run is told the chain up to the unit's goal.
|
||||
const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" });
|
||||
|
||||
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
|
||||
|
||||
const aktualisiere = () => {
|
||||
@@ -334,6 +372,7 @@ async function oeffneAufgabenDialog(task) {
|
||||
erstelleElement("label", { text: "Value:" }), terminWert,
|
||||
erstelleElement("label", { text: "Timezone:" }), zeitzone,
|
||||
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
|
||||
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
|
||||
erstelleElement("span"), hinweis
|
||||
);
|
||||
|
||||
@@ -364,7 +403,8 @@ async function oeffneAufgabenDialog(task) {
|
||||
whenValue: terminArt.value ? terminWert.value.trim() : null,
|
||||
timeZone: terminArt.value ? zeitzone.value.trim() : null,
|
||||
acceptance: abnahme.value.trim(),
|
||||
priority: task?.priority ?? 3
|
||||
priority: task?.priority ?? 3,
|
||||
parentId: eltern.value.trim()
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -24,9 +26,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
public string Name => "FileRW";
|
||||
|
||||
public string Description => "File management across two separate workspace directories:\n" +
|
||||
public string Description => "File management in workspaces:\n" +
|
||||
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
|
||||
"- workspace='shared': SHARED folder — all agents on the team can access it.\n" +
|
||||
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
|
||||
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
|
||||
"are listed in the Organisation section of your instructions.\n" +
|
||||
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
|
||||
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
|
||||
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
|
||||
@@ -49,8 +53,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "The workspace in which to execute the action (source workspace for copy). Default is 'personal'."
|
||||
"description": "The workspace in which to execute the action (source workspace for copy): 'personal', 'shared', 'unit' or the name of a workspace you are a member of. Default is 'personal'."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
@@ -85,8 +88,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
},
|
||||
"destinationWorkspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "Destination workspace for copy. Default is the same workspace as 'workspace'."
|
||||
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
|
||||
},
|
||||
"destinationPath": {
|
||||
"type": "string",
|
||||
@@ -126,36 +128,37 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var workspace = (input.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
||||
if (ResolveArea(requested, context) is not { } area)
|
||||
return ToolResult.Fail(UnknownWorkspace(requested!, context));
|
||||
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
var wsName = workspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace";
|
||||
return ToolResult.Fail($"Configuration error: '{wsName}' is not defined.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(area.Root))
|
||||
return ToolResult.Fail($"Configuration error: the folder of workspace '{area.Name}' is not defined.");
|
||||
|
||||
// Sicherstellen, dass rootPath absolut ist
|
||||
rootPath = Path.GetFullPath(rootPath);
|
||||
var rootPath = Path.GetFullPath(area.Root);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed(action, workspace, context))
|
||||
if (!IsActionAllowed(action, area))
|
||||
{
|
||||
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.");
|
||||
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.");
|
||||
}
|
||||
|
||||
// A workspace with a member list comes into being when it is first used.
|
||||
if (!area.IsPersonal && area.Name != WorkspaceLayout.Shared)
|
||||
Directory.CreateDirectory(rootPath);
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
|
||||
"read_many" => await HandleReadManyAsync(input, rootPath, workspace, context, ct),
|
||||
"write" => await HandleWriteAsync(input, rootPath, workspace, context, ct),
|
||||
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
|
||||
"list" => await HandleListAsync(input, rootPath, workspace, context, ct),
|
||||
"delete" => await HandleDeleteAsync(input, rootPath, workspace, context, ct),
|
||||
"copy" => await HandleCopyAsync(input, rootPath, workspace, context, ct),
|
||||
"read" => await HandleReadAsync(input, rootPath, area, context, ct),
|
||||
"read_many" => await HandleReadManyAsync(input, rootPath, area, context, ct),
|
||||
"write" => await HandleWriteAsync(input, rootPath, area, context, ct),
|
||||
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
|
||||
"list" => await HandleListAsync(input, rootPath, area, context, ct),
|
||||
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
|
||||
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
|
||||
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||
};
|
||||
@@ -178,22 +181,24 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
return null;
|
||||
|
||||
var action = a.GetString() ?? "";
|
||||
var workspace = (input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
|
||||
? w.GetString() : null) ?? "personal";
|
||||
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
|
||||
? w.GetString() : null;
|
||||
|
||||
if (!IsActionAllowed(action, workspace, context))
|
||||
return $"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.";
|
||||
if (ResolveArea(requested, context) is not { } area)
|
||||
return UnknownWorkspace(requested!, context);
|
||||
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (action != "delete" || string.IsNullOrWhiteSpace(rootPath))
|
||||
if (!IsActionAllowed(action, area))
|
||||
return $"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.";
|
||||
|
||||
if (action != "delete" || string.IsNullOrWhiteSpace(area.Root))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
rootPath = Path.GetFullPath(rootPath);
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var rootPath = Path.GetFullPath(area.Root);
|
||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
|
||||
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
||||
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
@@ -205,51 +210,133 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsActionAllowed(string action, string workspace, AgentToolContext context)
|
||||
// ─── Workspaces (#684) ───
|
||||
|
||||
/// <summary>The folder in the shared workspace that holds the task files.</summary>
|
||||
private const string TasksFolder = "tasks";
|
||||
|
||||
/// <summary>A workspace as one call sees it: where it lies and what the agent may do there.</summary>
|
||||
private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList<string> ProtectedPaths)
|
||||
{
|
||||
if (workspace == "personal") return true; // Alles erlaubt
|
||||
public bool IsPersonal => Name == WorkspaceLayout.Personal;
|
||||
}
|
||||
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
/// <summary>
|
||||
/// Resolves the name an agent gave. "personal" and "shared" work as they always did —
|
||||
/// "shared" still by the agent's own <c>sharedAccessLevel</c>. "unit" is the own unit's
|
||||
/// workspace; any other name must be a workspace the agent is a member of. Everything
|
||||
/// else is <c>null</c>: an unknown name and a workspace the agent may not see get the
|
||||
/// same answer, so the answer does not reveal that it exists.
|
||||
/// </summary>
|
||||
private static Area? ResolveArea(string? requested, AgentToolContext context)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(requested) ? WorkspaceLayout.Personal : requested.Trim();
|
||||
|
||||
if (level == "Denied") return false;
|
||||
if (name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase))
|
||||
return new Area(WorkspaceLayout.Personal, context.WorkspacePath, WorkspaceAccess.Manage, []);
|
||||
|
||||
if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
|
||||
return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context));
|
||||
|
||||
if (context.Organisation is not { } org)
|
||||
return null;
|
||||
|
||||
var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase)
|
||||
? org.UnitWorkspace(org.UnitOf(context.AgentId)?.Id)
|
||||
: org.Workspace(name);
|
||||
if (workspace is null)
|
||||
return null;
|
||||
|
||||
var access = org.AccessTo(context.AgentId, workspace.Id);
|
||||
return access == WorkspaceAccess.None
|
||||
? null
|
||||
: new Area(workspace.Id, WorkspaceLayout.PathOf(context.SharedWorkspacePath, workspace.Id), access,
|
||||
NormaliseProtectedPaths(workspace.ProtectedPaths));
|
||||
}
|
||||
|
||||
/// <summary>The answer to a name that is not a workspace this agent may use — with the ones it may.</summary>
|
||||
private static string UnknownWorkspace(string requested, AgentToolContext context)
|
||||
{
|
||||
var names = new List<string> { WorkspaceLayout.Personal };
|
||||
if (SharedAccess(context) > WorkspaceAccess.None)
|
||||
names.Add(WorkspaceLayout.Shared);
|
||||
if (context.Organisation is { } org)
|
||||
{
|
||||
names.AddRange(org.WorkspacesFor(context.AgentId).Select(g =>
|
||||
g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id));
|
||||
}
|
||||
return $"Unknown workspace '{requested}'. You can use: {string.Join(", ", names)}.";
|
||||
}
|
||||
|
||||
/// <summary>The one shared folder, by the level the agent carries for it — as before #684.</summary>
|
||||
private static WorkspaceAccess SharedAccess(AgentToolContext context)
|
||||
=> (context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied") switch
|
||||
{
|
||||
"Read" => WorkspaceAccess.Read,
|
||||
"ReadWrite" => WorkspaceAccess.Write,
|
||||
"Admin" => WorkspaceAccess.Manage,
|
||||
_ => WorkspaceAccess.None
|
||||
};
|
||||
|
||||
private static bool IsActionAllowed(string action, Area area)
|
||||
{
|
||||
if (area.IsPersonal) return true; // Alles erlaubt
|
||||
|
||||
return action switch
|
||||
{
|
||||
"read" or "read_many" or "list" or "copy" => true,
|
||||
"write" or "append" or "stock_add" => level is "ReadWrite" or "Admin",
|
||||
"delete" => level is "Admin",
|
||||
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
|
||||
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
|
||||
"delete" => area.Access >= WorkspaceAccess.Manage,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Managing a workspace includes changing and deleting protected files — as the old Admin level did.</summary>
|
||||
private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt.
|
||||
/// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add.
|
||||
/// Kein Überschreiben existierender Dateien, kein Löschen.
|
||||
/// Admin-Level umgeht den Schutz.
|
||||
/// Wer den Bereich verwaltet, umgeht den Schutz.
|
||||
/// </summary>
|
||||
private bool IsPathProtected(string fullPath, string rootPath, AgentToolContext context)
|
||||
private static bool IsPathProtected(string fullPath, string rootPath, Area area)
|
||||
{
|
||||
var protectedPaths = GetProtectedPaths(context);
|
||||
if (protectedPaths.Count == 0) return false;
|
||||
if (area.ProtectedPaths.Count == 0) return false;
|
||||
|
||||
var relativePath = Path.GetRelativePath(rootPath, fullPath)
|
||||
.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
// GetProtectedPaths hängt an jeden Eintrag ein "/" an, deshalb greift
|
||||
// StartsWith hier auf einer echten Verzeichnisgrenze: "stocks/" trifft
|
||||
// "stocks/x.json", aber nicht "stocks-alt/x.json".
|
||||
return protectedPaths.Any(pp =>
|
||||
// Jeder Eintrag endet auf "/", deshalb greift StartsWith hier auf einer echten
|
||||
// Verzeichnisgrenze: "stocks/" trifft "stocks/x.json", aber nicht "stocks-alt/x.json".
|
||||
return area.ProtectedPaths.Any(pp =>
|
||||
relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool IsAdminLevel(AgentToolContext context)
|
||||
/// <summary>
|
||||
/// #684: With units, the task files in the shared folder are closed to FileRW. Written
|
||||
/// directly, a task file would pass every check the task board makes — the board of the
|
||||
/// unit, the address book of the assignment. Tasks go through the Taskboard tool. Without
|
||||
/// units nothing changes, so running installations keep what they have.
|
||||
/// </summary>
|
||||
private static bool IsBlocked(string fullPath, Area area, AgentToolContext context)
|
||||
{
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
return level == "Admin";
|
||||
if (area.Name != WorkspaceLayout.Shared || context.Organisation?.HasUnits != true || string.IsNullOrWhiteSpace(area.Root))
|
||||
return false;
|
||||
|
||||
var relative = Path.GetRelativePath(Path.GetFullPath(area.Root), fullPath).Replace('\\', '/');
|
||||
return relative.Equals(TasksFolder, StringComparison.OrdinalIgnoreCase)
|
||||
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
|
||||
=> paths
|
||||
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
||||
.Where(p => p.Length > 0)
|
||||
.Select(p => p + "/")
|
||||
.ToList();
|
||||
|
||||
private static List<string> GetProtectedPaths(AgentToolContext context)
|
||||
{
|
||||
if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null)
|
||||
@@ -295,13 +382,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
if (action is not ("write" or "append"))
|
||||
return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'.");
|
||||
|
||||
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
var requested = arguments.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
||||
if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area)
|
||||
return ProbeFinding.Unknown("Workspace path is unknown.");
|
||||
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(root), area, true, context);
|
||||
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (!File.Exists(path))
|
||||
@@ -349,19 +434,23 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, Area area, bool checkExtension, AgentToolContext context)
|
||||
{
|
||||
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
|
||||
|
||||
// Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams.
|
||||
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, workspace);
|
||||
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, area.Name);
|
||||
|
||||
if (IsBlocked(fullPath, area, context))
|
||||
throw new UnauthorizedAccessException(
|
||||
"Tasks are kept on the task board — use the Taskboard tool (task_list, task_find, task_create, task_update), not their files.");
|
||||
|
||||
// Extension Check
|
||||
if (checkExtension && !Directory.Exists(fullPath))
|
||||
{
|
||||
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
|
||||
var configKey = workspace == "shared" ? "sharedAllowedExtensions" : "personalAllowedExtensions";
|
||||
var configKey = area.IsPersonal ? "personalAllowedExtensions" : "sharedAllowedExtensions";
|
||||
|
||||
var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je
|
||||
? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList()
|
||||
@@ -369,7 +458,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{workspace}'.");
|
||||
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{area.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,37 +466,42 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #662: Gibt es den Pfad im jeweils anderen Workspace, sagt die Fehlermeldung das.
|
||||
/// #662: Gibt es den Pfad in einem anderen Workspace, sagt die Fehlermeldung das.
|
||||
///
|
||||
/// <para>Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig
|
||||
/// <c>workspace: "shared"</c> und bekommt nur "nicht gefunden" zurueck. Es rät dann
|
||||
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis kommt nur, wenn der
|
||||
/// Agent den anderen Workspace auch benutzen darf - sonst waere er eine Einladung
|
||||
/// zu etwas, das ohnehin abgewiesen wird.</para>
|
||||
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis nennt nur Workspaces,
|
||||
/// die der Agent fuer diese Aktion auch benutzen darf - sonst waere er eine Einladung
|
||||
/// zu etwas, das ohnehin abgewiesen wird, und verriete obendrein, dass es sie gibt.</para>
|
||||
///
|
||||
/// <para>Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt.</para>
|
||||
/// </summary>
|
||||
private string AndererWorkspaceHinweis(
|
||||
string action, string workspace, string? relativePath, AgentToolContext context, bool verzeichnis)
|
||||
string action, Area area, string? relativePath, AgentToolContext context, bool verzeichnis)
|
||||
{
|
||||
var anderer = workspace == "shared" ? "personal" : "shared";
|
||||
if (!IsActionAllowed(action, anderer, context))
|
||||
return "";
|
||||
var candidates = new List<string> { WorkspaceLayout.Personal, WorkspaceLayout.Shared };
|
||||
if (context.Organisation is { } org)
|
||||
candidates.AddRange(org.WorkspacesFor(context.AgentId).Select(g => g.Workspace.Id));
|
||||
|
||||
var root = anderer == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
return "";
|
||||
foreach (var name in candidates.Where(n => !string.Equals(n, area.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (ResolveArea(name, context) is not { Root: { Length: > 0 } root } other || !IsActionAllowed(action, other))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, anderer);
|
||||
var da = verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad);
|
||||
return da
|
||||
? $" The path exists in the {anderer} workspace — call with workspace: \"{anderer}\"."
|
||||
: "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ""; // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
|
||||
try
|
||||
{
|
||||
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, other.Name);
|
||||
if (IsBlocked(pfad, other, context))
|
||||
continue;
|
||||
if (verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad))
|
||||
return $" The path exists in the {other.Name} workspace — call with workspace: \"{other.Name}\".";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string? RelativerPfad(JsonElement input)
|
||||
@@ -415,14 +509,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
? p.GetString()
|
||||
: null;
|
||||
|
||||
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return ToolResult.Fail(
|
||||
$"File not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("read", workspace, RelativerPfad(input), context, verzeichnis: false));
|
||||
$"File not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("read", area, RelativerPfad(input), context, verzeichnis: false));
|
||||
}
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -431,7 +525,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, ct);
|
||||
var relativePath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Ok($"[{workspace}:/{relativePath}]\n{content}");
|
||||
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -458,17 +552,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// und <see cref="MaxZeichenJeAufruf"/> Zeichen; was nicht mehr hineinpasst, wird am
|
||||
/// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann.</para>
|
||||
/// </summary>
|
||||
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var (dateien, fehler) = SammleDateien(input, rootPath, workspace, context);
|
||||
var (dateien, fehler) = SammleDateien(input, rootPath, area, context);
|
||||
if (fehler is not null)
|
||||
return ToolResult.Fail(fehler);
|
||||
|
||||
if (dateien.Count == 0)
|
||||
{
|
||||
return ToolResult.Fail(
|
||||
$"No matching file found in {workspace} workspace."
|
||||
+ AndererWorkspaceHinweis("read_many", workspace, RelativerPfad(input), context, verzeichnis: true));
|
||||
$"No matching file found in {area.Name} workspace."
|
||||
+ AndererWorkspaceHinweis("read_many", area, RelativerPfad(input), context, verzeichnis: true));
|
||||
}
|
||||
|
||||
var uebersprungen = new List<string>();
|
||||
@@ -490,13 +584,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
|
||||
finally { sperre.Release(); }
|
||||
|
||||
text.Append($"\n--- {workspace}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
||||
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
||||
gelesen++;
|
||||
}
|
||||
|
||||
uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/')));
|
||||
|
||||
var kopf = new StringBuilder($"[{workspace} Workspace] {gelesen} of {dateien.Count} file(s) read.");
|
||||
var kopf = new StringBuilder($"[{area.Name} Workspace] {gelesen} of {dateien.Count} file(s) read.");
|
||||
if (fehlend.Count > 0)
|
||||
kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
|
||||
if (uebersprungen.Count > 0)
|
||||
@@ -511,7 +605,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes <c>read</c>.
|
||||
/// </summary>
|
||||
private (List<string> Dateien, string? Fehler) SammleDateien(
|
||||
JsonElement input, string rootPath, string workspace, AgentToolContext context)
|
||||
JsonElement input, string rootPath, Area area, AgentToolContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -523,12 +617,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
if (eintrag.ValueKind != JsonValueKind.String) continue;
|
||||
var relativ = eintrag.GetString();
|
||||
if (string.IsNullOrWhiteSpace(relativ)) continue;
|
||||
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
|
||||
dateien.Add(PruefeLesepfad(relativ, rootPath, area, context));
|
||||
}
|
||||
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
|
||||
}
|
||||
|
||||
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
|
||||
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), area.Name);
|
||||
if (!Directory.Exists(verzeichnis))
|
||||
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
|
||||
|
||||
@@ -544,7 +638,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
|
||||
// Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist,
|
||||
// kommt auch hier nicht durch, nur ohne den Lauf abzubrechen.
|
||||
return (gefunden.Where(f => IstLesbar(f, workspace, context)).ToList(), null);
|
||||
return (gefunden.Where(f => IstLesbar(f, area, context)).ToList(), null);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
@@ -552,18 +646,18 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private string PruefeLesepfad(string relativePath, string rootPath, string workspace, AgentToolContext context)
|
||||
private string PruefeLesepfad(string relativePath, string rootPath, Area area, AgentToolContext context)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
|
||||
return GetAndValidatePath(json, rootPath, workspace, true, context);
|
||||
return GetAndValidatePath(json, rootPath, area, true, context);
|
||||
}
|
||||
|
||||
private bool IstLesbar(string vollerPfad, string workspace, AgentToolContext context)
|
||||
private bool IstLesbar(string vollerPfad, Area area, AgentToolContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
|
||||
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, workspace, true, context);
|
||||
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, area, true, context);
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
@@ -572,9 +666,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -582,7 +676,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
try
|
||||
{
|
||||
// Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && File.Exists(path) && IsPathProtected(path, rootPath, context))
|
||||
if (!CanOverrideProtection(area) && File.Exists(path) && IsPathProtected(path, rootPath, area))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
@@ -600,7 +694,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
||||
// Agenten aber nicht.
|
||||
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] File successfully written: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -608,9 +702,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
@@ -625,7 +719,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
|
||||
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
|
||||
await writer.WriteAsync(content.AsMemory(), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Content successfully appended to: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -652,14 +746,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
|
||||
/// schlimmere Falle.</para>
|
||||
/// </summary>
|
||||
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
return ToolResult.Fail(
|
||||
$"Directory not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
|
||||
$"Directory not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
||||
+ AndererWorkspaceHinweis("list", area, RelativerPfad(input), context, verzeichnis: true));
|
||||
}
|
||||
|
||||
var muster = GetOptionalString(input, "pattern");
|
||||
@@ -669,7 +763,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
List<string> pfade;
|
||||
try
|
||||
{
|
||||
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
|
||||
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
||||
.Where(e => !IsBlocked(e, area, context))
|
||||
.ToList();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -706,7 +802,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
: (absteigend ? "newest first" : "oldest first");
|
||||
|
||||
var text = new StringBuilder();
|
||||
text.Append($"[{workspace} Workspace] Directory: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
||||
text.Append($"[{area.Name} Workspace] Directory: {area.Name}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
||||
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
|
||||
if (!string.IsNullOrWhiteSpace(muster))
|
||||
text.Append($" Pattern: {muster}.");
|
||||
@@ -747,12 +843,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
private static int? GetInt(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
|
||||
|
||||
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
||||
|
||||
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
|
||||
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
@@ -767,15 +863,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] File deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Directory deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
return ToolResult.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"File or directory not found in {workspace} workspace.");
|
||||
return ToolResult.Fail($"File or directory not found in {area.Name} workspace.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -783,32 +879,36 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct)
|
||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceWorkspace, false, context);
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
||||
|
||||
var destWorkspace = (input.TryGetProperty("destinationWorkspace", out var dw) ? dw.GetString() : null) ?? sourceWorkspace;
|
||||
var requestedDestination = input.TryGetProperty("destinationWorkspace", out var dw) && dw.ValueKind == JsonValueKind.String
|
||||
? dw.GetString()
|
||||
: null;
|
||||
var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destRelPath))
|
||||
return ToolResult.Fail("'destinationPath' is required for copy action.");
|
||||
|
||||
var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (string.IsNullOrWhiteSpace(destRootPath))
|
||||
return ToolResult.Fail($"Configuration error: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' is not defined.");
|
||||
var destArea = requestedDestination is null ? sourceArea : ResolveArea(requestedDestination, context);
|
||||
if (destArea is null)
|
||||
return ToolResult.Fail(UnknownWorkspace(requestedDestination!, context));
|
||||
if (string.IsNullOrWhiteSpace(destArea.Root))
|
||||
return ToolResult.Fail($"Configuration error: the folder of workspace '{destArea.Name}' is not defined.");
|
||||
|
||||
destRootPath = Path.GetFullPath(destRootPath);
|
||||
var destRootPath = Path.GetFullPath(destArea.Root);
|
||||
|
||||
if (!IsActionAllowed("write", destWorkspace, context))
|
||||
return ToolResult.Fail($"Access denied: Writing to workspace '{destWorkspace}' is not allowed for this agent.");
|
||||
if (!IsActionAllowed("write", destArea))
|
||||
return ToolResult.Fail($"Access denied: Writing to workspace '{destArea.Name}' is not allowed for this agent.");
|
||||
|
||||
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
|
||||
var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context);
|
||||
var destPath = GetAndValidatePath(destInput, destRootPath, destArea, false, context);
|
||||
|
||||
if (File.Exists(sourcePath))
|
||||
{
|
||||
// Schutz: Kein Überschreiben in geschützten Bereichen per Copy
|
||||
if (destWorkspace == "shared" && !IsAdminLevel(context) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, context))
|
||||
if (!CanOverrideProtection(destArea) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, destArea))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(destRootPath, destPath);
|
||||
return ToolResult.Fail(
|
||||
@@ -832,10 +932,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
|
||||
|
||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, context)), ct);
|
||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
finally
|
||||
@@ -852,8 +952,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
{
|
||||
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
|
||||
@@ -895,7 +995,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
||||
sharedRoot = Path.GetFullPath(sharedRoot);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed("stock_add", "shared", context))
|
||||
if (ResolveArea(WorkspaceLayout.Shared, context) is not { } shared || !IsActionAllowed("stock_add", shared))
|
||||
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
|
||||
|
||||
// Sanitize Ticker für Verzeichnisname
|
||||
|
||||
@@ -22,7 +22,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
public string Name => "Taskboard";
|
||||
|
||||
public string Description => """
|
||||
The shared taskboard — persists across runs and restarts.
|
||||
The taskboard — persists across runs and restarts. If your instance has units, you
|
||||
see your unit's board; a lead also sees the boards of the units below.
|
||||
|
||||
Use it to track and distribute work instead of keeping it in context:
|
||||
own open items, assignments to other agents, recurring schedules.
|
||||
@@ -40,6 +41,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
- '@<agentId>'→ the named agent with their context
|
||||
- '@human' → waits for a human (default if unclear)
|
||||
|
||||
Purpose: set 'parent' to the task this one serves (task_create, task_update).
|
||||
The run of a task is told the chain up to the goal of the unit — so split work
|
||||
into sub-tasks under the task they serve instead of loose tasks.
|
||||
|
||||
Schedules ('when' field): one-time ('at'), recurring ('every') or 'cron'.
|
||||
The time zone 'tz' is required (e.g. 'Europe/Berlin').
|
||||
|
||||
@@ -106,6 +111,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
"type": "string",
|
||||
"description": "Acceptance criteria against which the result is verified."
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "ID of the task this one serves (its purpose — unlike blocked_by, which is a precondition). Empty string removes it (task_update)."
|
||||
},
|
||||
"blocked_by": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
@@ -157,15 +166,18 @@ public sealed class TaskboardTool : IAgentTool
|
||||
&& CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach)
|
||||
return ToolResult.Fail(outOfReach);
|
||||
|
||||
// #684: The boards this agent sees; null means all of them (no units).
|
||||
var visible = TaskBoard.VisibleTo(context.Organisation, context.AgentId);
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
|
||||
"task_find" => await FindAsync(input, board, ct),
|
||||
"task_list" => await ListAsync(input, board, ct),
|
||||
"task_update" => await UpdateAsync(input, board, ct),
|
||||
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
|
||||
"task_create" => await CreateAsync(input, board, context.AgentId, visible, ct),
|
||||
"task_find" => await FindAsync(input, board, visible, ct),
|
||||
"task_list" => await ListAsync(input, board, visible, ct),
|
||||
"task_update" => await UpdateAsync(input, board, visible, ct),
|
||||
"task_comment" => await CommentAsync(input, board, context.AgentId, visible, ct),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||
};
|
||||
}
|
||||
@@ -206,13 +218,13 @@ public sealed class TaskboardTool : IAgentTool
|
||||
/// the first sentence.</para>
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> FindAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
var title = GetString(input, "title") ?? GetString(input, "search");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return ToolResult.Fail("'title' is required — what should be searched for?");
|
||||
|
||||
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
|
||||
var treffer = await SucheAehnlicheAsync(board, title, null, visible, ct);
|
||||
if (treffer.Count == 0)
|
||||
return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create.");
|
||||
|
||||
@@ -232,18 +244,43 @@ public sealed class TaskboardTool : IAgentTool
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
|
||||
/// <summary>
|
||||
/// Offene Aufgaben mit verwandtem Titel, engste zuerst — nur auf den Brettern, die der
|
||||
/// Agent sieht (#684). Eine aehnliche Aufgabe der Buchhaltung haelt keine Marketing-Aufgabe
|
||||
/// auf, und die Ablehnung verraete sonst, was dort liegt.
|
||||
/// </summary>
|
||||
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
|
||||
TaskboardService board, string title, string? exceptId, CancellationToken ct)
|
||||
TaskboardService board, string title, string? exceptId, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
|
||||
// stayed invisible once the board grew beyond that.
|
||||
var alle = await board.ListAllAsync(ct);
|
||||
return TaskSimilarity.FindOpen(alle, title, exceptId);
|
||||
return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #714: A parent must exist, be on a board the agent sees, and must not lie below the
|
||||
/// task itself — a circle would make the chain endless.
|
||||
/// </summary>
|
||||
private static async Task<string?> CheckParentAsync(
|
||||
string? taskId, string parentId, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
if (!await CanSeeAsync(board, parentId, visible, ct) || await board.GetAsync(parentId, ct) is null)
|
||||
return $"No task found with ID '{parentId}' to serve as parent.";
|
||||
if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct))
|
||||
return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: A task on a board the agent does not see is answered like one that does not
|
||||
/// exist — the answer must not tell that it is there.
|
||||
/// </summary>
|
||||
private static async Task<bool> CanSeeAsync(TaskboardService board, string id, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
=> visible is null || (await board.GetAsync(id, ct) is { } task && visible(task));
|
||||
|
||||
private static async Task<ToolResult> CreateAsync(
|
||||
JsonElement input, TaskboardService board, string author, CancellationToken ct)
|
||||
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
var title = GetString(input, "title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -252,11 +289,15 @@ public sealed class TaskboardTool : IAgentTool
|
||||
if (!TryReadWhen(input, out var when, out var whenError))
|
||||
return ToolResult.Fail(whenError!);
|
||||
|
||||
var parent = GetString(input, "parent")?.Trim() ?? "";
|
||||
if (parent.Length > 0 && await CheckParentAsync(null, parent, board, visible, ct) is { } parentProblem)
|
||||
return ToolResult.Fail(parentProblem);
|
||||
|
||||
// #674: The same finding a second time is not a second task. It is recorded on the
|
||||
// existing one — so that later one can see how often and for how long.
|
||||
if (GetBool(input, "force") != true)
|
||||
{
|
||||
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
|
||||
var dublette = (await SucheAehnlicheAsync(board, title, null, visible, ct))
|
||||
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
|
||||
|
||||
if (dublette is not null)
|
||||
@@ -285,7 +326,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
RequireApproval = GetBool(input, "require_approval") ?? false,
|
||||
Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
|
||||
BlockedBy = GetStringList(input, "blocked_by"),
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
|
||||
// #684: decides the board when the task is for a human or a fresh run.
|
||||
CreatedBy = author,
|
||||
ParentId = parent
|
||||
};
|
||||
|
||||
var created = await board.CreateAsync(definition, ct);
|
||||
@@ -296,7 +340,7 @@ public sealed class TaskboardTool : IAgentTool
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ListAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
var query = new TaskQuery
|
||||
{
|
||||
@@ -306,7 +350,7 @@ public sealed class TaskboardTool : IAgentTool
|
||||
Limit = GetInt(input, "limit") ?? 50
|
||||
};
|
||||
|
||||
var tasks = await board.ListAsync(query, ct);
|
||||
var tasks = await board.ListAsync(query, visible, ct);
|
||||
if (tasks.Count == 0)
|
||||
return ToolResult.Ok("No matching tasks.");
|
||||
|
||||
@@ -314,16 +358,22 @@ public sealed class TaskboardTool : IAgentTool
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> UpdateAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
var id = GetString(input, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("'id' is required.");
|
||||
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
|
||||
return ToolResult.Fail($"No task found with ID '{id}'.");
|
||||
|
||||
if (!TryReadWhen(input, out var when, out var whenError))
|
||||
return ToolResult.Fail(whenError!);
|
||||
var hasWhen = input.TryGetProperty("when", out _);
|
||||
|
||||
var parent = GetString(input, "parent")?.Trim();
|
||||
if (parent is { Length: > 0 } && await CheckParentAsync(id.Trim(), parent, board, visible, ct) is { } parentProblem)
|
||||
return ToolResult.Fail(parentProblem);
|
||||
|
||||
var result = await board.UpdateAsync(id.Trim(), current => current with
|
||||
{
|
||||
Title = GetString(input, "title")?.Trim() ?? current.Title,
|
||||
@@ -336,7 +386,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval,
|
||||
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
|
||||
BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen,
|
||||
ParentId = parent ?? current.ParentId
|
||||
}, ct);
|
||||
|
||||
if (result is null)
|
||||
@@ -348,11 +399,13 @@ public sealed class TaskboardTool : IAgentTool
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> CommentAsync(
|
||||
JsonElement input, TaskboardService board, string author, CancellationToken ct)
|
||||
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
|
||||
{
|
||||
var id = GetString(input, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("'id' is required.");
|
||||
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
|
||||
return ToolResult.Fail($"No task found with ID '{id}'.");
|
||||
|
||||
var text = GetString(input, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
@@ -386,6 +439,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
|
||||
block.AppendLine();
|
||||
block.AppendLine($" Assignee: {task.Assignee} | Priority: {task.Priority}{FormatWhen(task.When)}");
|
||||
if (!string.IsNullOrWhiteSpace(task.ParentId))
|
||||
block.AppendLine($" Serves: {task.ParentId}");
|
||||
if (task.BlockedBy.Count > 0)
|
||||
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
|
||||
|
||||
|
||||
@@ -117,7 +117,13 @@ public sealed record TaskDto(
|
||||
DateTime? NextRunUtc = null,
|
||||
TaskLastRunDto? LastRun = null,
|
||||
string? Hinweis = null,
|
||||
TaskBudgetMissDto? BudgetMiss = null);
|
||||
TaskBudgetMissDto? BudgetMiss = null,
|
||||
string? CreatedBy = null,
|
||||
// #714: the task this one serves.
|
||||
string? ParentId = null,
|
||||
// #684: the unit whose board the task is on, computed from the organisation; null on the top level.
|
||||
string? Board = null,
|
||||
string? BoardPath = null);
|
||||
|
||||
/// <summary>
|
||||
/// #716: Runs of this task the budget refused — only present while something is open
|
||||
@@ -185,7 +191,8 @@ public sealed record TaskInputDto(
|
||||
string? WhenValue = null,
|
||||
string? TimeZone = null,
|
||||
string Acceptance = "",
|
||||
int Priority = 3);
|
||||
int Priority = 3,
|
||||
string? ParentId = null);
|
||||
|
||||
/// <summary>Die Kostenprognose einer Instanz (#595).</summary>
|
||||
public sealed record ForecastDto(
|
||||
|
||||
@@ -108,7 +108,8 @@ internal static class Mapping
|
||||
input.WhenValue,
|
||||
input.TimeZone,
|
||||
input.Acceptance ?? "",
|
||||
input.Priority);
|
||||
input.Priority,
|
||||
input.ParentId ?? "");
|
||||
|
||||
public static ForecastDto ToDto(this InstanzPrognose prognose)
|
||||
=> new(
|
||||
@@ -153,7 +154,17 @@ internal static class Mapping
|
||||
BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null
|
||||
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
|
||||
task.CatchUpPending, task.IsCarriedOver)
|
||||
: null);
|
||||
: null,
|
||||
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy,
|
||||
ParentId: string.IsNullOrWhiteSpace(task.ParentId) ? null : task.ParentId);
|
||||
|
||||
/// <summary>#684: The board of a task, for a list the human sees whole.</summary>
|
||||
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
|
||||
{
|
||||
if (!org.HasUnits || Core.Tasks.TaskBoard.UnitOf(task, org) is not { } unit)
|
||||
return dto;
|
||||
return dto with { Board = unit, BoardPath = org.PathOf(unit) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
|
||||
|
||||
@@ -5,7 +5,8 @@ public sealed record OrganisationDto(
|
||||
IReadOnlyList<UnitDto> Units,
|
||||
IReadOnlyList<OrgMemberDto> Agents,
|
||||
IReadOnlyList<string> Problems,
|
||||
IReadOnlyList<string> KnownTools);
|
||||
IReadOnlyList<string> KnownTools,
|
||||
IReadOnlyList<WorkspaceDto> Workspaces);
|
||||
|
||||
public sealed record UnitDto(
|
||||
string Id,
|
||||
@@ -61,3 +62,38 @@ public sealed record SettingPreviewInputDto(string Setting, string Value, string
|
||||
public sealed record SettingChangeDto(string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault);
|
||||
|
||||
public sealed record SettingApplyDto(string Setting, IReadOnlyDictionary<string, string> Values, string Reason = "", string? DefaultValue = null);
|
||||
|
||||
/// <summary>
|
||||
/// #684: A workspace with its member list — and, computed by the instance, who that list
|
||||
/// actually lets in and how far. <see cref="IsUnitOwn"/> marks a unit's own workspace.
|
||||
/// </summary>
|
||||
public sealed record WorkspaceDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string Purpose,
|
||||
string? OwnerUnitId,
|
||||
bool IsUnitOwn,
|
||||
IReadOnlyList<WorkspaceMemberDto> Members,
|
||||
IReadOnlyList<string> ProtectedPaths,
|
||||
int FileCount,
|
||||
IReadOnlyList<WorkspaceAccessDto> Access);
|
||||
|
||||
/// <summary>A member entry: a unit or an agent; <see cref="Access"/> is Read, Write or Manage.</summary>
|
||||
public sealed record WorkspaceMemberDto(string? UnitId, string? AgentId, string Access, bool IncludeSubUnits = true);
|
||||
|
||||
public sealed record WorkspaceAccessDto(string AgentId, string Access);
|
||||
|
||||
public sealed record WorkspaceInputDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Purpose = null,
|
||||
string? OwnerUnitId = null,
|
||||
IReadOnlyList<WorkspaceMemberDto>? Members = null,
|
||||
string Reason = "");
|
||||
|
||||
public sealed record WorkspaceUpdateDto(string? Name = null, string? Purpose = null, IReadOnlyList<string>? ProtectedPaths = null, string Reason = "");
|
||||
|
||||
public sealed record WorkspaceMembersInputDto(IReadOnlyList<WorkspaceMemberDto> Members, string Reason = "");
|
||||
|
||||
/// <summary>A change that needs nothing but the reason for the audit log.</summary>
|
||||
public sealed record ReasonInputDto(string Reason = "");
|
||||
|
||||
@@ -260,9 +260,14 @@ internal static class ApiEndpoints
|
||||
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
|
||||
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
|
||||
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
|
||||
await host.TaskAdmin.ListAsync(limit, ct) is { } list
|
||||
? Results.Ok(list.Select(t => t.ToDto()).ToList())
|
||||
: Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."));
|
||||
{
|
||||
if (await host.TaskAdmin.ListAsync(limit, ct) is not { } list)
|
||||
return Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
|
||||
|
||||
// #684: A human sees every board; each task says which one it is on.
|
||||
var org = host.Organisation.Current();
|
||||
return Results.Ok(list.Select(t => t.ToDto().WithBoard(t.Task, org)).ToList());
|
||||
});
|
||||
|
||||
tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) =>
|
||||
(await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -38,7 +39,23 @@ internal static class OrganisationEndpoints
|
||||
a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model))
|
||||
.ToList();
|
||||
|
||||
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools);
|
||||
var workspaces = chart.Workspaces
|
||||
.OrderBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(w => new WorkspaceDto(
|
||||
w.Id, w.Name, w.Purpose, w.OwnerUnitId,
|
||||
ReferenceEquals(chart.UnitWorkspace(w.OwnerUnitId), w),
|
||||
w.Members.Select(m => new WorkspaceMemberDto(m.UnitId, m.AgentId, m.Access.ToString(), m.IncludeSubUnits)).ToList(),
|
||||
w.ProtectedPaths,
|
||||
host.Organisation.FileCount(w.Id),
|
||||
chart.Agents
|
||||
.Select(a => (a.AgentId, Access: chart.AccessTo(a.AgentId, w.Id)))
|
||||
.Where(x => x.Access > WorkspaceAccess.None)
|
||||
.OrderBy(x => x.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(x => new WorkspaceAccessDto(x.AgentId, x.Access.ToString()))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools, workspaces);
|
||||
});
|
||||
|
||||
org.MapPost("/units", (AppHost host, UnitInputDto input) =>
|
||||
@@ -79,12 +96,51 @@ internal static class OrganisationEndpoints
|
||||
.ToList());
|
||||
});
|
||||
|
||||
MapWorkspaces(org);
|
||||
|
||||
org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) =>
|
||||
Enum.TryParse<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
|
||||
? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse()
|
||||
: Results.BadRequest(new OutcomeDto(false, "Unknown setting.")));
|
||||
}
|
||||
|
||||
// ─── workspaces (#684) ───
|
||||
|
||||
private static void MapWorkspaces(RouteGroupBuilder org)
|
||||
{
|
||||
org.MapPost("/workspaces", (AppHost host, WorkspaceInputDto input) =>
|
||||
ToMembers(input.Members ?? []) is not { } members
|
||||
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
|
||||
: host.Organisation.CreateWorkspace(input.Id, input.Name, input.Purpose, input.OwnerUnitId, members, input.Reason).ToResponse());
|
||||
|
||||
org.MapPatch("/workspaces/{workspaceId}", (AppHost host, string workspaceId, WorkspaceUpdateDto input) =>
|
||||
host.Organisation.UpdateWorkspace(workspaceId, input.Name, input.Purpose, input.ProtectedPaths, input.Reason).ToResponse());
|
||||
|
||||
org.MapPut("/workspaces/{workspaceId}/members", (AppHost host, string workspaceId, WorkspaceMembersInputDto input) =>
|
||||
ToMembers(input.Members ?? []) is not { } members
|
||||
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
|
||||
: host.Organisation.SetWorkspaceMembers(workspaceId, members, input.Reason).ToResponse());
|
||||
|
||||
org.MapDelete("/workspaces/{workspaceId}", (AppHost host, string workspaceId, string? reason) =>
|
||||
host.Organisation.DeleteWorkspace(workspaceId, reason ?? "").ToResponse());
|
||||
|
||||
org.MapPost("/units/{unitId}/workspace", (AppHost host, string unitId, ReasonInputDto? input) =>
|
||||
host.Organisation.CreateUnitWorkspace(unitId, input?.Reason ?? "").ToResponse());
|
||||
}
|
||||
|
||||
/// <summary><c>null</c> when a level is not one of Read, Write, Manage.</summary>
|
||||
private static List<WorkspaceMemberInput>? ToMembers(IReadOnlyList<WorkspaceMemberDto> members)
|
||||
{
|
||||
var result = new List<WorkspaceMemberInput>();
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!Enum.TryParse<WorkspaceAccess>(member.Access, ignoreCase: true, out var access) || !Enum.IsDefined(access))
|
||||
return null;
|
||||
result.Add(new WorkspaceMemberInput(member.UnitId, member.AgentId, access, member.IncludeSubUnits));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static StructurePreviewDto ToDto(StructurePreview preview)
|
||||
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #684: Workspaces in the administration. A unit gets its own with it; a workspace across
|
||||
/// units is created on purpose, with an owner; nothing filed is ever deleted from here.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceAdministrationTests : IDisposable
|
||||
{
|
||||
private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", "ws-" + Guid.NewGuid().ToString("N")[..8], "Agents");
|
||||
private readonly TestHost _test;
|
||||
|
||||
public WorkspaceAdministrationTests()
|
||||
{
|
||||
_test = new TestHost(Agent("buyer"), Agent("ads"), Agent("bookkeeper"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_test.Dispose();
|
||||
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private AgentConfig Agent(string id) => new()
|
||||
{
|
||||
AgentId = id,
|
||||
DisplayName = id,
|
||||
SharedWorkspacePath = Path.Combine(_agents, "SharedWorkspace")
|
||||
};
|
||||
|
||||
private OrgAdministration Org => _test.Host.Organisation;
|
||||
|
||||
private List<WorkspaceConfig> Workspaces => _test.Host.Instance.Workspaces;
|
||||
|
||||
private void Setup()
|
||||
{
|
||||
Org.CreateUnit("purchasing", "Purchasing", null).Ok.ShouldBeTrue();
|
||||
Org.CreateUnit("marketing", "Marketing", null).Ok.ShouldBeTrue();
|
||||
Org.MoveAgent("buyer", "purchasing", "setup").Ok.ShouldBeTrue();
|
||||
Org.MoveAgent("ads", "marketing", "setup").Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private void File(string workspaceId, string name)
|
||||
{
|
||||
var folder = Org.WorkspaceFolder(workspaceId)!;
|
||||
Directory.CreateDirectory(folder);
|
||||
System.IO.File.WriteAllText(Path.Combine(folder, name), "x");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_unit_comes_with_its_own_workspace_for_itself_and_below()
|
||||
{
|
||||
Setup();
|
||||
|
||||
var own = Workspaces.Single(w => w.Id == "purchasing");
|
||||
own.OwnerUnitId.ShouldBe("purchasing");
|
||||
own.Members.ShouldHaveSingleItem().UnitId.ShouldBe("purchasing");
|
||||
own.Members[0].Access.ShouldBe(WorkspaceAccess.Write);
|
||||
own.Members[0].IncludeSubUnits.ShouldBeTrue();
|
||||
Org.Current().AccessTo("buyer", "purchasing").ShouldBe(WorkspaceAccess.Write);
|
||||
Org.Current().AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_cannot_take_a_reserved_name_or_the_id_of_an_existing_workspace()
|
||||
{
|
||||
Org.CreateUnit("shared", "Shared", null).Ok.ShouldBeFalse();
|
||||
Org.CreateWorkspace("articles", "Articles", null, null, []).Ok.ShouldBeTrue();
|
||||
Org.CreateUnit("articles", "Articles team", null).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_workspace_across_units_has_an_owner_and_levels()
|
||||
{
|
||||
Setup();
|
||||
|
||||
var result = Org.CreateWorkspace("articles", "Article master", "One file per article.", "purchasing",
|
||||
[
|
||||
new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Manage),
|
||||
new WorkspaceMemberInput("marketing", null, WorkspaceAccess.Read)
|
||||
], "purchasing maintains, marketing reads");
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
Org.Current().AccessTo("buyer", "articles").ShouldBe(WorkspaceAccess.Manage);
|
||||
Org.Current().AccessTo("ads", "articles").ShouldBe(WorkspaceAccess.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ids_of_units_are_kept_for_their_own_workspaces()
|
||||
{
|
||||
Setup();
|
||||
|
||||
Org.CreateWorkspace("marketing", "Other", null, null, []).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nowhere", null, WorkspaceAccess.Read)]
|
||||
[InlineData(null, "ghost", WorkspaceAccess.Read)]
|
||||
[InlineData("marketing", "ads", WorkspaceAccess.Read)]
|
||||
[InlineData(null, null, WorkspaceAccess.Read)]
|
||||
[InlineData("marketing", null, WorkspaceAccess.None)]
|
||||
public void Members_must_be_one_existing_unit_or_agent_with_a_level(string? unit, string? agent, WorkspaceAccess access)
|
||||
{
|
||||
Setup();
|
||||
|
||||
Org.CreateWorkspace("x", "X", null, null, [new WorkspaceMemberInput(unit, agent, access)]).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_member_list_is_replaced_and_the_old_one_is_audited_away()
|
||||
{
|
||||
Setup();
|
||||
|
||||
Org.SetWorkspaceMembers("purchasing",
|
||||
[
|
||||
new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Write),
|
||||
new WorkspaceMemberInput(null, "ads", WorkspaceAccess.Read)
|
||||
], "ads may look at the orders").Ok.ShouldBeTrue();
|
||||
|
||||
Org.Current().AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protected_folders_are_folders_inside_the_workspace()
|
||||
{
|
||||
Setup();
|
||||
|
||||
Org.UpdateWorkspace("purchasing", null, null, ["prices/", "/archive"]).Ok.ShouldBeTrue();
|
||||
Workspaces.Single(w => w.Id == "purchasing").ProtectedPaths.ShouldBe(["prices", "archive"]);
|
||||
Org.UpdateWorkspace("purchasing", null, null, ["../outside"]).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_an_empty_workspace_can_be_deleted_and_never_a_unit_s_own()
|
||||
{
|
||||
Setup();
|
||||
Org.CreateWorkspace("articles", "Articles", null, "purchasing", []).Ok.ShouldBeTrue();
|
||||
File("articles", "a-100.md");
|
||||
|
||||
Org.DeleteWorkspace("articles").Ok.ShouldBeFalse("it holds a file");
|
||||
Org.DeleteWorkspace("purchasing").Ok.ShouldBeFalse("it goes with its unit");
|
||||
|
||||
System.IO.File.Delete(Path.Combine(Org.WorkspaceFolder("articles")!, "a-100.md"));
|
||||
Org.DeleteWorkspace("articles").Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_a_unit_keeps_what_its_workspace_holds()
|
||||
{
|
||||
Setup();
|
||||
Org.CreateWorkspace("articles", "Articles", null, "purchasing",
|
||||
[new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Manage)]).Ok.ShouldBeTrue();
|
||||
File("purchasing", "orders.md");
|
||||
Org.MoveAgent("buyer", null, "leaving").Ok.ShouldBeTrue();
|
||||
|
||||
var result = Org.DeleteUnit("purchasing", "closed down");
|
||||
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
result.Message.ShouldContain("'purchasing'");
|
||||
var kept = Workspaces.Single(w => w.Id == "purchasing");
|
||||
kept.OwnerUnitId.ShouldBeNull();
|
||||
kept.Members.ShouldBeEmpty();
|
||||
Workspaces.Single(w => w.Id == "articles").Members.ShouldBeEmpty("the unit left every member list");
|
||||
System.IO.File.Exists(Path.Combine(Org.WorkspaceFolder("purchasing")!, "orders.md")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleting_a_unit_with_an_empty_workspace_removes_both()
|
||||
{
|
||||
Setup();
|
||||
Org.MoveAgent("ads", null, "leaving").Ok.ShouldBeTrue();
|
||||
|
||||
Org.DeleteUnit("marketing").Ok.ShouldBeTrue();
|
||||
|
||||
Workspaces.ShouldNotContain(w => w.Id == "marketing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_unit_from_before_workspaces_can_get_its_own()
|
||||
{
|
||||
_test.Host.Instance.Units.Add(new UnitConfig { Id = "legacy", Name = "Legacy" });
|
||||
|
||||
Org.CreateUnitWorkspace("legacy").Ok.ShouldBeTrue();
|
||||
Org.Current().UnitWorkspace("legacy").ShouldNotBeNull();
|
||||
Org.CreateUnitWorkspace("legacy").Message.ShouldContain("already");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Organisation;
|
||||
|
||||
/// <summary>
|
||||
/// #684: Who may use which workspace. The right stands at the workspace — a member list of
|
||||
/// units and agents with a level — and the tree decides what a unit entry covers.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceAccessTests
|
||||
{
|
||||
private static readonly UnitConfig[] Units =
|
||||
[
|
||||
new() { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "head" },
|
||||
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
|
||||
new() { Id = "marketing", Name = "Marketing" }
|
||||
];
|
||||
|
||||
private static readonly AgentConfig[] Agents =
|
||||
[
|
||||
new() { AgentId = "head", UnitId = "purchasing" },
|
||||
new() { AgentId = "scout", UnitId = "suppliers" },
|
||||
new() { AgentId = "ads", UnitId = "marketing" },
|
||||
new() { AgentId = "ceo" }
|
||||
];
|
||||
|
||||
private static OrgChart Chart(params WorkspaceConfig[] workspaces) => new(Units, Agents, workspaces);
|
||||
|
||||
private static WorkspaceConfig Workspace(string id, string? owner, params WorkspaceMember[] members)
|
||||
=> new() { Id = id, Name = id, OwnerUnitId = owner, Members = [.. members] };
|
||||
|
||||
[Fact]
|
||||
public void A_unit_entry_covers_the_units_below()
|
||||
{
|
||||
var org = Chart(Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
|
||||
|
||||
org.AccessTo("head", "purchasing").ShouldBe(WorkspaceAccess.Write);
|
||||
org.AccessTo("scout", "purchasing").ShouldBe(WorkspaceAccess.Write);
|
||||
org.AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None);
|
||||
org.AccessTo("ceo", "purchasing").ShouldBe(WorkspaceAccess.None, "the top level is not a member of every unit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_sub_units_the_entry_covers_the_unit_alone()
|
||||
{
|
||||
var org = Chart(Workspace("round", null,
|
||||
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read, IncludeSubUnits = false }));
|
||||
|
||||
org.AccessTo("head", "round").ShouldBe(WorkspaceAccess.Read);
|
||||
org.AccessTo("scout", "round").ShouldBe(WorkspaceAccess.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_highest_level_of_all_matching_entries_counts()
|
||||
{
|
||||
var org = Chart(Workspace("articles", "purchasing",
|
||||
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read },
|
||||
new WorkspaceMember { AgentId = "scout", Access = WorkspaceAccess.Manage }));
|
||||
|
||||
org.AccessTo("scout", "articles").ShouldBe(WorkspaceAccess.Manage);
|
||||
org.AccessTo("head", "articles").ShouldBe(WorkspaceAccess.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_agent_on_the_top_level_can_be_named_directly()
|
||||
=> Chart(Workspace("board", null, new WorkspaceMember { AgentId = "ceo", Access = WorkspaceAccess.Write }))
|
||||
.AccessTo("ceo", "board").ShouldBe(WorkspaceAccess.Write);
|
||||
|
||||
[Fact]
|
||||
public void The_own_unit_s_workspace_comes_first_and_only_visible_ones_are_listed()
|
||||
{
|
||||
var org = Chart(
|
||||
Workspace("articles", "purchasing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }),
|
||||
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
|
||||
Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
|
||||
|
||||
var grants = org.WorkspacesFor("ads");
|
||||
|
||||
grants.Select(g => g.Workspace.Id).ShouldBe(["marketing", "articles"]);
|
||||
grants[0].IsOwnUnit.ShouldBeTrue();
|
||||
org.UnitWorkspace("marketing")!.Id.ShouldBe("marketing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_workspace_carrying_a_unit_s_id_but_owned_by_another_is_not_that_unit_s()
|
||||
=> Chart(Workspace("marketing", "purchasing")).UnitWorkspace("marketing").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Broken_references_and_reserved_names_are_named()
|
||||
{
|
||||
var problems = Chart(
|
||||
Workspace("shared", null),
|
||||
Workspace("orphan", "gone", new WorkspaceMember { UnitId = "nowhere" }, new WorkspaceMember { AgentId = "ghost" })).Problems();
|
||||
|
||||
problems.ShouldContain(p => p.Contains("'shared' has an invalid id"));
|
||||
problems.ShouldContain(p => p.Contains("'gone'"));
|
||||
problems.ShouldContain(p => p.Contains("'nowhere'"));
|
||||
problems.ShouldContain(p => p.Contains("'ghost'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Workspaces_live_next_to_the_one_shared_folder()
|
||||
{
|
||||
var shared = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace");
|
||||
|
||||
WorkspaceLayout.PathOf(shared, "purchasing")
|
||||
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"));
|
||||
WorkspaceLayout.PathOf(shared + Path.DirectorySeparatorChar, "purchasing")
|
||||
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"), "a trailing separator changes nothing");
|
||||
WorkspaceLayout.PathOf(shared, "../escape").ShouldBeNull();
|
||||
WorkspaceLayout.PathOf(null, "purchasing").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_briefing_names_the_workspaces_and_what_the_agent_may_do_there()
|
||||
{
|
||||
var org = Chart(
|
||||
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
|
||||
new WorkspaceConfig
|
||||
{
|
||||
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", Purpose = "One file per article.",
|
||||
Members = [new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }]
|
||||
});
|
||||
|
||||
var section = OrgBriefing.For(org, "ads", DateTime.UtcNow)!;
|
||||
|
||||
section.ShouldContain("\"unit\" — your unit's workspace (also \"marketing\"), read and write");
|
||||
section.ShouldContain("\"articles\" (Article master), read only — One file per article.");
|
||||
OrgBriefing.For(org, "scout", DateTime.UtcNow)!.ShouldNotContain("workspaces", Case.Insensitive,
|
||||
"the scout is in none of them — and must not learn that they exist");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #714: Every task can say what it serves, up to the goal of its unit — and the run of a
|
||||
/// task is told so.
|
||||
/// </summary>
|
||||
public sealed class GoalChainTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "goal-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
|
||||
public GoalChainTests()
|
||||
{
|
||||
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private Task<TaskItem> Save(string id, string title, string parent = "", string assignee = "@ads")
|
||||
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = title, ParentId = parent, Assignee = assignee }, default);
|
||||
|
||||
private static OrgChart Shop(string marketingGoal = "Grow revenue profitably.", string amazonGoal = "") => new(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing", Goal = marketingGoal },
|
||||
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", Goal = amazonGoal }
|
||||
],
|
||||
[new AgentConfig { AgentId = "ads", UnitId = "amazon" }]);
|
||||
|
||||
[Fact]
|
||||
public async Task The_parents_are_walked_nearest_first()
|
||||
{
|
||||
await Save("t-1", "Spring collection");
|
||||
await Save("t-2", "Listings for the collection", "t-1");
|
||||
var leaf = await Save("t-3", "Text for chair A-100", "t-2");
|
||||
|
||||
var parents = await GoalChain.ParentsAsync(leaf, _repo.GetAsync, default);
|
||||
|
||||
parents.Select(p => p.Id).ShouldBe(["t-2", "t-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cycle_in_the_data_does_not_hang_the_walk()
|
||||
{
|
||||
await Save("t-1", "A", "t-2");
|
||||
var b = await Save("t-2", "B", "t-1");
|
||||
|
||||
(await GoalChain.ParentsAsync(b, _repo.GetAsync, default)).Select(p => p.Id).ShouldBe(["t-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_task_cannot_be_put_below_itself()
|
||||
{
|
||||
await Save("t-1", "Top");
|
||||
await Save("t-2", "Middle", "t-1");
|
||||
await Save("t-3", "Bottom", "t-2");
|
||||
|
||||
(await GoalChain.WouldCycleAsync("t-1", "t-3", _repo.GetAsync, default)).ShouldBeTrue();
|
||||
(await GoalChain.WouldCycleAsync("t-1", "t-1", _repo.GetAsync, default)).ShouldBeTrue();
|
||||
(await GoalChain.WouldCycleAsync("t-3", "t-1", _repo.GetAsync, default)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_description_ends_in_the_goal_of_the_nearest_unit_that_has_one()
|
||||
{
|
||||
var top = await Save("t-1", "Spring collection");
|
||||
var leaf = await Save("t-2", "Text for chair A-100", "t-1");
|
||||
|
||||
var text = GoalChain.Describe(leaf, [top], Shop())!;
|
||||
|
||||
text.ShouldContain("t-1 \"Spring collection\"");
|
||||
// amazon has no goal of its own — the one of marketing above it counts.
|
||||
text.ShouldContain("the goal of the unit \"Marketing\": Grow revenue profitably.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_parents_and_without_a_goal_nothing_is_added()
|
||||
{
|
||||
GoalChain.Describe(new TaskItem { Id = "t-9", Assignee = "@ads" }, [], Shop(marketingGoal: "")).ShouldBeNull();
|
||||
GoalChain.Describe(new TaskItem { Id = "t-9", Assignee = "@ads" }, [], null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_parent_survives_the_file_and_a_re_import()
|
||||
{
|
||||
var tasksDir = Path.Combine(_directory, "tasks");
|
||||
var board = new TaskboardService(_repo, tasksDir);
|
||||
await board.CreateAsync(new TaskItem { Id = "t-1", Title = "Top" }, default);
|
||||
await board.CreateAsync(new TaskItem { Id = "t-2", Title = "Below", ParentId = "t-1" }, default);
|
||||
|
||||
Directory.GetFiles(tasksDir).Select(File.ReadAllText).Single(text => text.Contains("id: t-2"))
|
||||
.ShouldContain("parent: t-1");
|
||||
await board.ImportAllAsync(default);
|
||||
|
||||
(await _repo.GetAsync("t-2", default))!.ParentId.ShouldBe("t-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_run_of_a_task_is_told_why()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("ads");
|
||||
agent.UnitId = "amazon";
|
||||
fixture.Engine.OrganisationProvider = () => new OrgChart(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing", Goal = "Grow revenue profitably." },
|
||||
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing" }
|
||||
],
|
||||
fixture.Agents);
|
||||
fixture.Client.RespondsWithText("ERGEBNIS: ok");
|
||||
|
||||
await Save("t-1", "Spring collection", assignee: "@human");
|
||||
var leaf = await Save("t-2", "Text for chair A-100", "t-1");
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore, TestLogging.Factory,
|
||||
tasks: _repo);
|
||||
await dispatcher.DispatchAsync(leaf, default);
|
||||
|
||||
var message = fixture.Client.ReceivedRequests[0].Messages.Last(m => m.Role == "user").Content!;
|
||||
message.ShouldContain("Why this task exists");
|
||||
message.ShouldContain("t-1 \"Spring collection\"");
|
||||
message.ShouldContain("Grow revenue profitably.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #714: A task tree that has stopped moving while its purpose is not reached gets a review
|
||||
/// — once per state of rest, by the lead of its unit.
|
||||
/// </summary>
|
||||
public sealed class TaskTreeWatchTests
|
||||
{
|
||||
private static TaskItem T(string id, TaskItemStatus status, string parent = "", string assignee = "@ads")
|
||||
=> new() { Id = id, Title = id, Status = status, ParentId = parent, Assignee = assignee };
|
||||
|
||||
// ═══════════════════ what rests ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void An_open_task_whose_sub_tasks_all_stopped_rests()
|
||||
{
|
||||
var trees = TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("goal", TaskItemStatus.Blocked),
|
||||
T("a", TaskItemStatus.Done, "goal"),
|
||||
T("b", TaskItemStatus.Blocked, "goal"),
|
||||
T("c", TaskItemStatus.InReview, "goal")
|
||||
]);
|
||||
|
||||
trees.ShouldHaveSingleItem().Root.Id.ShouldBe("goal");
|
||||
trees[0].Below.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TaskItemStatus.InProgress)]
|
||||
[InlineData(TaskItemStatus.Todo)]
|
||||
public void A_tree_with_anything_running_or_about_to_run_does_not_rest(TaskItemStatus moving)
|
||||
=> TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("goal", TaskItemStatus.Blocked),
|
||||
T("a", TaskItemStatus.Done, "goal"),
|
||||
T("b", moving, "goal")
|
||||
]).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void A_task_waiting_for_a_human_does_not_move_by_itself()
|
||||
=> TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("goal", TaskItemStatus.Todo, assignee: "@human"),
|
||||
T("a", TaskItemStatus.Done, "goal")
|
||||
]).ShouldHaveSingleItem();
|
||||
|
||||
[Fact]
|
||||
public void A_finished_tree_is_not_a_standstill()
|
||||
=> TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("goal", TaskItemStatus.Done),
|
||||
T("a", TaskItemStatus.Done, "goal")
|
||||
]).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void A_single_task_without_sub_tasks_is_not_a_tree()
|
||||
=> TaskTreeWatch.FindResting([T("alone", TaskItemStatus.Blocked)]).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Only_the_topmost_resting_tree_is_reported()
|
||||
{
|
||||
var trees = TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("goal", TaskItemStatus.Blocked),
|
||||
T("part", TaskItemStatus.Blocked, "goal"),
|
||||
T("leaf", TaskItemStatus.Done, "part")
|
||||
]);
|
||||
|
||||
trees.ShouldHaveSingleItem().Root.Id.ShouldBe("goal");
|
||||
trees[0].Below.Select(t => t.Id).ShouldBe(["part", "leaf"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cycle_in_the_data_does_not_hang_the_search()
|
||||
=> Should.NotThrow(() => TaskTreeWatch.FindResting(
|
||||
[
|
||||
T("a", TaskItemStatus.Blocked, "b"),
|
||||
T("b", TaskItemStatus.Blocked, "a")
|
||||
]));
|
||||
|
||||
// ═══════════════════ who reviews ═══════════════════
|
||||
|
||||
private static OrgChart Shop(bool amazonHasLead = true) => new(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = amazonHasLead ? "amazon-lead" : null }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
|
||||
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||
new AgentConfig { AgentId = "ads", UnitId = "amazon" }
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void The_lead_of_the_tree_s_unit_reviews_or_the_lead_above()
|
||||
{
|
||||
TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), Shop()).ShouldBe("@amazon-lead");
|
||||
TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), Shop(amazonHasLead: false)).ShouldBe("@marketing-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_units_or_a_lead_a_human_reviews()
|
||||
{
|
||||
TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), null).ShouldBe("@human");
|
||||
TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked, assignee: "@human"), Shop()).ShouldBe("@human");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_review_stands_beside_the_tree_and_may_only_send_work_back()
|
||||
{
|
||||
var tree = new RestingTree(T("goal", TaskItemStatus.Blocked), [T("a", TaskItemStatus.Done, "goal")]);
|
||||
|
||||
var review = TaskTreeWatch.BuildReview(tree, "@amazon-lead");
|
||||
|
||||
review.ParentId.ShouldBeEmpty("inside the tree it would change what it reviews");
|
||||
review.Assignee.ShouldBe("@amazon-lead");
|
||||
review.Body.ShouldContain("a [done]");
|
||||
review.Body.ShouldContain("Do not do the work yourself");
|
||||
}
|
||||
|
||||
// ═══════════════════ once per state of rest ═══════════════════
|
||||
|
||||
private sealed class MemoryState : IStateStore
|
||||
{
|
||||
private readonly Dictionary<string, string> _values = new();
|
||||
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult(_values.GetValueOrDefault(key));
|
||||
public Task SetAsync(string key, string value, CancellationToken ct) { _values[key] = value; return Task.CompletedTask; }
|
||||
public Task DeleteAsync(string key, CancellationToken ct) { _values.Remove(key); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private static async Task<(SqliteTaskRepository Repo, string Directory)> BoardWith(params TaskItem[] tasks)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "watch-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(directory, "state.db")));
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
await repo.UpsertAsync(task, default);
|
||||
await repo.SetStatusAsync(task.Id, task.Status, DateTime.UtcNow, default);
|
||||
}
|
||||
return (repo, directory);
|
||||
}
|
||||
|
||||
private static void CleanUp(string directory)
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(directory);
|
||||
try { Directory.Delete(directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rest_is_reviewed_once_and_again_only_after_the_tree_moved()
|
||||
{
|
||||
var (repo, directory) = await BoardWith(T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal"));
|
||||
try
|
||||
{
|
||||
var created = new List<TaskItem>();
|
||||
var watch = new TaskTreeWatch(repo, (t, _) => { created.Add(t); return Task.CompletedTask; },
|
||||
new MemoryState(), () => null, NullLogger.Instance);
|
||||
var now = new DateTime(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await watch.CheckAsync(now, default);
|
||||
created.ShouldHaveSingleItem();
|
||||
|
||||
await watch.CheckAsync(now + TaskTreeWatch.Interval, default);
|
||||
created.Count.ShouldBe(1, "nothing moved — a reviewer who accepted the rest is not asked again");
|
||||
|
||||
// Reopened, worked on, stuck again: a different state of rest.
|
||||
await repo.SetStatusAsync("a", TaskItemStatus.Blocked, DateTime.UtcNow, default);
|
||||
await watch.CheckAsync(now + 2 * TaskTreeWatch.Interval, default);
|
||||
created.Count.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanUp(directory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_board_is_looked_at_only_every_interval()
|
||||
{
|
||||
var (repo, directory) = await BoardWith(T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal"));
|
||||
try
|
||||
{
|
||||
var created = new List<TaskItem>();
|
||||
var watch = new TaskTreeWatch(repo, (t, _) => { created.Add(t); return Task.CompletedTask; },
|
||||
new MemoryState(), () => null, NullLogger.Instance);
|
||||
var now = new DateTime(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await watch.CheckAsync(now, default);
|
||||
await repo.SetStatusAsync("a", TaskItemStatus.Canceled, DateTime.UtcNow, default);
|
||||
await watch.CheckAsync(now + TimeSpan.FromMinutes(1), default);
|
||||
|
||||
created.Count.ShouldBe(1, "the next look is due after the interval, not on every tick");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanUp(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// #684: Named workspaces with member lists. Purchasing files its things in its own
|
||||
/// workspace, marketing reads the article master that purchasing owns, and accounting's
|
||||
/// workspace does not exist for anyone outside accounting — not even as a name.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceAreasTests : IDisposable
|
||||
{
|
||||
private readonly string _agents;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
private readonly List<UnitConfig> _units =
|
||||
[
|
||||
new() { Id = "purchasing", Name = "Purchasing" },
|
||||
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
|
||||
new() { Id = "marketing", Name = "Marketing" },
|
||||
new() { Id = "accounting", Name = "Accounting" }
|
||||
];
|
||||
|
||||
private readonly List<AgentConfig> _people =
|
||||
[
|
||||
new() { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new() { AgentId = "scout", UnitId = "suppliers" },
|
||||
new() { AgentId = "ads", UnitId = "marketing" },
|
||||
new() { AgentId = "copywriter", UnitId = "marketing" },
|
||||
new() { AgentId = "bookkeeper", UnitId = "accounting" },
|
||||
new() { AgentId = "auditor" }
|
||||
];
|
||||
|
||||
private readonly List<WorkspaceConfig> _workspaces =
|
||||
[
|
||||
Own("purchasing"),
|
||||
Own("suppliers"),
|
||||
Own("marketing"),
|
||||
Own("accounting"),
|
||||
new()
|
||||
{
|
||||
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing",
|
||||
Members =
|
||||
[
|
||||
new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage },
|
||||
new() { UnitId = "marketing", Access = WorkspaceAccess.Read },
|
||||
new() { AgentId = "auditor", Access = WorkspaceAccess.Read },
|
||||
new() { AgentId = "copywriter", Access = WorkspaceAccess.Write }
|
||||
],
|
||||
ProtectedPaths = ["prices"]
|
||||
}
|
||||
];
|
||||
|
||||
private static WorkspaceConfig Own(string unit) => new()
|
||||
{
|
||||
Id = unit, Name = unit, OwnerUnitId = unit,
|
||||
Members = [new() { UnitId = unit, Access = WorkspaceAccess.Write }]
|
||||
};
|
||||
|
||||
public WorkspaceAreasTests()
|
||||
{
|
||||
_agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
|
||||
Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id);
|
||||
|
||||
private AgentToolContext Context(string agentId, string sharedLevel = "Denied", bool withUnits = true)
|
||||
{
|
||||
var personal = Path.Combine(_agents, agentId, "Workspace");
|
||||
Directory.CreateDirectory(personal);
|
||||
return new AgentToolContext(
|
||||
agentId, "inst",
|
||||
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
|
||||
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||
WorkspacePath: personal,
|
||||
SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
|
||||
{
|
||||
Organisation = withUnits ? new OrgChart(_units, _people, _workspaces) : new OrgChart([], _people)
|
||||
};
|
||||
}
|
||||
|
||||
private Task<ToolResult> Run(string agentId, object input, string sharedLevel = "Denied", bool withUnits = true)
|
||||
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agentId, sharedLevel, withUnits), CancellationToken.None);
|
||||
|
||||
// ═══════════════════ the own unit ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_writes_to_its_unit_without_knowing_the_unit_id()
|
||||
{
|
||||
var result = await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
File.ReadAllText(Path.Combine(Area("purchasing"), "orders.md")).ShouldBe("3 pallets");
|
||||
result.Content.ShouldContain("purchasing:/orders.md", Case.Sensitive, "the answer names the workspace by its id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_colleague_finds_it_under_the_same_name()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "purchasing", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
// The scout sits in suppliers, below purchasing — the purchasing entry includes sub-units.
|
||||
var read = await Run("scout", new { action = "read", workspace = "purchasing", path = "orders.md" });
|
||||
|
||||
read.Success.ShouldBeTrue(read.ErrorMessage);
|
||||
read.Content.ShouldContain("3 pallets");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_unit_s_workspace_does_not_exist_for_other_units()
|
||||
{
|
||||
await Run("bookkeeper", new { action = "write", workspace = "unit", path = "salaries.md", content = "secret" });
|
||||
|
||||
var probe = await Run("ads", new { action = "read", workspace = "accounting", path = "salaries.md" });
|
||||
var unknown = await Run("ads", new { action = "read", workspace = "does-not-exist", path = "x.md" });
|
||||
|
||||
probe.Success.ShouldBeFalse();
|
||||
probe.ErrorMessage!.ShouldStartWith("Unknown workspace 'accounting'");
|
||||
// The same answer as for a name that does not exist — nothing tells marketing it is there.
|
||||
probe.ErrorMessage.Replace("accounting", "X").ShouldBe(unknown.ErrorMessage!.Replace("does-not-exist", "X"));
|
||||
probe.ErrorMessage.ShouldNotContain("accounting,");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_answer_to_an_unknown_name_lists_what_the_agent_can_use()
|
||||
{
|
||||
var result = await Run("ads", new { action = "list", workspace = "team" }, sharedLevel: "Read");
|
||||
|
||||
result.ErrorMessage!.ShouldContain("personal, shared, unit (= marketing), articles");
|
||||
}
|
||||
|
||||
// ═══════════════════ shared across units ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Marketing_reads_the_article_master_but_cannot_write_it()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "articles", path = "a-100.md", content = "Chair" });
|
||||
|
||||
(await Run("ads", new { action = "read", workspace = "articles", path = "a-100.md" })).Content.ShouldContain("Chair");
|
||||
|
||||
var write = await Run("ads", new { action = "write", workspace = "articles", path = "a-100.md", content = "Sofa" });
|
||||
write.Success.ShouldBeFalse();
|
||||
write.ErrorMessage!.ShouldContain("not allowed in workspace 'articles'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_agent_can_be_a_member_without_its_unit()
|
||||
{
|
||||
var result = await Run("auditor", new { action = "list", workspace = "articles" });
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Protected_paths_hold_for_writers_but_not_for_managers()
|
||||
{
|
||||
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "10" })).Success.ShouldBeTrue();
|
||||
|
||||
// The copywriter may write here, but prices are append-only for writers…
|
||||
var overwrite = await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "1" });
|
||||
overwrite.Success.ShouldBeFalse();
|
||||
overwrite.ErrorMessage!.ShouldContain("Protected area");
|
||||
(await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-101.md", content = "5" })).Success
|
||||
.ShouldBeTrue("adding a new file is what protected paths allow");
|
||||
|
||||
// …while purchasing manages the workspace, which includes changing them, as Admin did.
|
||||
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "12" })).Success.ShouldBeTrue();
|
||||
File.ReadAllText(Path.Combine(Area("articles"), "prices", "a-100.md")).ShouldBe("12");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writing_is_not_deleting()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "draft.md", content = "x" });
|
||||
|
||||
var delete = await Run("buyer", new { action = "delete", workspace = "unit", path = "draft.md" });
|
||||
|
||||
delete.Success.ShouldBeFalse("deleting needs Manage, as it needed Admin before");
|
||||
File.Exists(Path.Combine(Area("purchasing"), "draft.md")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Copy_goes_from_the_own_unit_to_a_shared_workspace()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "a-200.md", content = "Table" });
|
||||
|
||||
var copy = await Run("buyer", new { action = "copy", workspace = "unit", path = "a-200.md", destinationWorkspace = "articles", destinationPath = "a-200.md" });
|
||||
|
||||
copy.Success.ShouldBeTrue(copy.ErrorMessage);
|
||||
File.ReadAllText(Path.Combine(Area("articles"), "a-200.md")).ShouldBe("Table");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_hint_points_to_the_workspace_where_the_file_is()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
var result = await Run("buyer", new { action = "read", path = "orders.md" });
|
||||
|
||||
result.ErrorMessage!.ShouldContain("exists in the purchasing workspace");
|
||||
}
|
||||
|
||||
// ═══════════════════ the one shared folder ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Shared_works_as_before_by_the_agent_s_own_level()
|
||||
{
|
||||
(await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "x" }, sharedLevel: "ReadWrite"))
|
||||
.Success.ShouldBeTrue();
|
||||
|
||||
var denied = await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "y" }, sharedLevel: "Read");
|
||||
denied.ErrorMessage!.ShouldContain("not allowed in workspace 'shared'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_units_the_task_files_are_closed_to_FileRW()
|
||||
{
|
||||
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
|
||||
Directory.CreateDirectory(tasks);
|
||||
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "---\nassignee: bookkeeper\n---");
|
||||
|
||||
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Admin");
|
||||
var write = await Run("ads", new { action = "write", workspace = "shared", path = "tasks/t-2.md", content = "sneaky" }, sharedLevel: "Admin");
|
||||
var list = await Run("ads", new { action = "list", workspace = "shared" }, sharedLevel: "Admin");
|
||||
|
||||
read.Success.ShouldBeFalse();
|
||||
read.ErrorMessage!.ShouldContain("Taskboard tool");
|
||||
write.Success.ShouldBeFalse();
|
||||
File.Exists(Path.Combine(tasks, "t-2.md")).ShouldBeFalse();
|
||||
list.Content.ShouldNotContain("tasks/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_units_the_task_files_stay_as_they_were()
|
||||
{
|
||||
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
|
||||
Directory.CreateDirectory(tasks);
|
||||
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "task");
|
||||
|
||||
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Read", withUnits: false);
|
||||
|
||||
read.Success.ShouldBeTrue(read.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_organisation_only_personal_and_shared_exist()
|
||||
{
|
||||
var context = new AgentToolContext(
|
||||
"solo", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||
WorkspacePath: Path.Combine(_agents, "solo"), SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"));
|
||||
Directory.CreateDirectory(context.WorkspacePath!);
|
||||
|
||||
var result = await _tool.ExecuteAsync(
|
||||
JsonSerializer.SerializeToElement(new { action = "list", workspace = "unit" }), context, CancellationToken.None);
|
||||
|
||||
result.ErrorMessage!.ShouldBe("Unknown workspace 'unit'. You can use: personal.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.Taskboard;
|
||||
using ClawdDotNet.Tools.Tests.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.Taskboard;
|
||||
|
||||
/// <summary>#714: Sub-tasks under the task they serve — through the tool, with its checks.</summary>
|
||||
public sealed class ParentTaskTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly TaskboardTool _tool = new();
|
||||
|
||||
private readonly OrgChart _org = new(
|
||||
[
|
||||
new UnitConfig { Id = "marketing", Name = "Marketing" },
|
||||
new UnitConfig { Id = "accounting", Name = "Accounting" }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "ads", UnitId = "marketing" },
|
||||
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||
]);
|
||||
|
||||
public ParentTaskTests()
|
||||
{
|
||||
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
private Task<ToolResult> Run(string agentId, object input)
|
||||
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext(
|
||||
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
|
||||
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
|
||||
{
|
||||
Organisation = _org
|
||||
}, CancellationToken.None);
|
||||
|
||||
private async Task<string> Create(string agentId, string title, string? parent = null)
|
||||
{
|
||||
var result = await Run(agentId, new { action = "task_create", title, parent, force = true });
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
return result.Content.Split(' ', 4)[2];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_sub_task_is_created_under_the_task_it_serves_and_listed_so()
|
||||
{
|
||||
var top = await Create("ads", "Spring collection");
|
||||
var sub = await Create("ads", "Listings for the collection", top);
|
||||
|
||||
(await _repo.GetAsync(sub, default))!.ParentId.ShouldBe(top);
|
||||
(await Run("ads", new { action = "task_list" })).Content.ShouldContain($"Serves: {top}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_parent_that_does_not_exist_is_refused()
|
||||
{
|
||||
var result = await Run("ads", new { action = "task_create", title = "Orphan", parent = "t-nowhere" });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage!.ShouldContain("No task found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_parent_on_another_board_is_answered_like_one_that_does_not_exist()
|
||||
{
|
||||
var foreign = await Create("bookkeeper", "Close the books");
|
||||
|
||||
var result = await Run("ads", new { action = "task_create", title = "Sneaky", parent = foreign });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage.ShouldBe($"No task found with ID '{foreign}' to serve as parent.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_task_cannot_be_moved_below_its_own_sub_task()
|
||||
{
|
||||
var top = await Create("ads", "Spring collection");
|
||||
var sub = await Create("ads", "Listings", top);
|
||||
|
||||
var result = await Run("ads", new { action = "task_update", id = top, parent = sub });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.ErrorMessage!.ShouldContain("cannot serve itself");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_parent_removes_it()
|
||||
{
|
||||
var top = await Create("ads", "Spring collection");
|
||||
var sub = await Create("ads", "Listings", top);
|
||||
|
||||
(await Run("ads", new { action = "task_update", id = sub, parent = "" })).Success.ShouldBeTrue();
|
||||
|
||||
(await _repo.GetAsync(sub, default))!.ParentId.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.Taskboard;
|
||||
using ClawdDotNet.Tools.Tests.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.Taskboard;
|
||||
|
||||
/// <summary>
|
||||
/// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not
|
||||
/// hold up a marketing task as a "duplicate", and cannot be commented on from outside.
|
||||
///
|
||||
/// <code>
|
||||
/// marketing (lead: marketing-lead)
|
||||
/// └── amazon (lead: amazon-lead) · ads
|
||||
/// accounting (lead: accounting-lead) · bookkeeper
|
||||
/// ceo on the top level
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public sealed class UnitBoardTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly TaskboardTool _tool = new();
|
||||
|
||||
private readonly List<AgentConfig> _agents =
|
||||
[
|
||||
new() { AgentId = "ceo" },
|
||||
new() { AgentId = "marketing-lead", UnitId = "marketing" },
|
||||
new() { AgentId = "amazon-lead", UnitId = "amazon" },
|
||||
new() { AgentId = "ads", UnitId = "amazon" },
|
||||
new() { AgentId = "accounting-lead", UnitId = "accounting" },
|
||||
new() { AgentId = "bookkeeper", UnitId = "accounting" }
|
||||
];
|
||||
|
||||
private readonly List<UnitConfig> _units =
|
||||
[
|
||||
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
|
||||
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
|
||||
new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
|
||||
];
|
||||
|
||||
public UnitBoardTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks");
|
||||
|
||||
private async Task<ToolResult> Run(string agentId, object input, bool withUnits = true)
|
||||
{
|
||||
var context = new AgentToolContext(
|
||||
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
|
||||
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
|
||||
{
|
||||
Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents)
|
||||
};
|
||||
return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<string> Create(string agentId, string title, string assignee = "@human")
|
||||
{
|
||||
var result = await Run(agentId, new { action = "task_create", title, assignee, force = true });
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
return result.Content.Split(' ', 4)[2]; // "Task created: <id> — …"
|
||||
}
|
||||
|
||||
private async Task<string> List(string agentId, bool withUnits = true)
|
||||
=> (await Run(agentId, new { action = "task_list" }, withUnits)).Content;
|
||||
|
||||
[Fact]
|
||||
public async Task Each_unit_sees_its_own_board()
|
||||
{
|
||||
await Create("bookkeeper", "Book the invoices of September");
|
||||
await Create("ads", "Raise the bid for chairs");
|
||||
|
||||
var marketing = await List("ads");
|
||||
var accounting = await List("bookkeeper");
|
||||
|
||||
marketing.ShouldContain("Raise the bid");
|
||||
marketing.ShouldNotContain("invoices");
|
||||
accounting.ShouldContain("invoices");
|
||||
accounting.ShouldNotContain("Raise the bid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lead_also_sees_the_boards_of_the_units_below()
|
||||
{
|
||||
await Create("ads", "Raise the bid for chairs");
|
||||
await Create("bookkeeper", "Book the invoices of September");
|
||||
|
||||
var lead = await List("marketing-lead");
|
||||
|
||||
lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing");
|
||||
lead.ShouldNotContain("invoices");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up()
|
||||
{
|
||||
// amazon-lead's lead is marketing-lead — the task lands on marketing's board …
|
||||
await Create("amazon-lead", "Approve the new budget", "@marketing-lead");
|
||||
|
||||
(await List("marketing-lead")).ShouldContain("Approve the new budget");
|
||||
// … and the one who created it still sees it, although amazon's board does not hold it.
|
||||
(await List("amazon-lead")).ShouldContain("Approve the new budget");
|
||||
(await List("ads")).ShouldNotContain("Approve the new budget");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_similar_task_on_another_board_is_no_duplicate()
|
||||
{
|
||||
await Create("bookkeeper", "Check the supplier invoice");
|
||||
|
||||
var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" });
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
result.Content.ShouldNotContain("Observed again");
|
||||
(await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content
|
||||
.ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_task_on_another_board_cannot_be_changed_or_commented_on()
|
||||
{
|
||||
var id = await Create("bookkeeper", "Book the invoices of September");
|
||||
|
||||
var update = await Run("ads", new { action = "task_update", id, status = "canceled" });
|
||||
var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" });
|
||||
var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" });
|
||||
|
||||
update.Success.ShouldBeFalse();
|
||||
comment.Success.ShouldBeFalse();
|
||||
comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"),
|
||||
"the same answer as for a task that does not exist");
|
||||
(await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_task_moves_with_its_agent()
|
||||
{
|
||||
await Create("amazon-lead", "Prepare the listing", "@ads");
|
||||
(await List("bookkeeper")).ShouldNotContain("Prepare the listing");
|
||||
|
||||
_agents.Single(a => a.AgentId == "ads").UnitId = "accounting";
|
||||
|
||||
(await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_units_there_is_one_board_for_everyone()
|
||||
{
|
||||
await Create("bookkeeper", "Book the invoices of September");
|
||||
|
||||
(await List("ads", withUnits: false)).ShouldContain("invoices");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import()
|
||||
{
|
||||
var id = await Create("ads", "Raise the bid for chairs");
|
||||
var file = Directory.GetFiles(TasksDir).Single();
|
||||
File.ReadAllText(file).ShouldContain("created_by: ads");
|
||||
|
||||
// A file written by hand without the field keeps what the database knows.
|
||||
File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", ""));
|
||||
await new TaskboardService(_repo, TasksDir).ImportAllAsync(default);
|
||||
|
||||
(await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user