Compare commits
10 Commits
fdbf52f811
...
b7ce929c50
| Author | SHA1 | Date | |
|---|---|---|---|
| b7ce929c50 | |||
| 4000747641 | |||
| ab36e4b465 | |||
| 8c58210c0c | |||
| b21889c4ab | |||
| cfd61bdee3 | |||
| d3e909d39e | |||
| 9f8d3b975f | |||
| 1121433db8 | |||
| eb11bb9442 |
8
.agents/mcp_config.json
Normal file
8
.agents/mcp_config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"aiw-context-mcp": {
|
||||||
|
"url": "http://127.0.0.1:8765/mcp",
|
||||||
|
"serverUrl": "http://127.0.0.1:8765/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ project-knowledge/.obsidian/cache/
|
|||||||
|
|
||||||
# Antigravity CLI local workspace configuration
|
# Antigravity CLI local workspace configuration
|
||||||
.antigravitycli/
|
.antigravitycli/
|
||||||
|
|
||||||
|
# AI Workspace local service runtime
|
||||||
|
.aiw/runtime/
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ Repeatable working guides for:
|
|||||||
|
|
||||||
Helpers for automation around memory access, context generation, communication drafting, and imports.
|
Helpers for automation around memory access, context generation, communication drafting, and imports.
|
||||||
|
|
||||||
|
- `scripts/aiw/` -> local AI Workspace service manager for profile services such as the Mattermost proxy mirror, Photo Inbox, and context MCP
|
||||||
|
- `scripts/mcp/` -> local MCP servers that expose bounded read-only workspace context to AI clients
|
||||||
- `scripts/memory/` -> project-agnostic interface for canonical memory
|
- `scripts/memory/` -> project-agnostic interface for canonical memory
|
||||||
- `scripts/obsidian/` -> current Obsidian adapter and URI helpers
|
- `scripts/obsidian/` -> current Obsidian adapter and URI helpers
|
||||||
- `scripts/mattermost/` -> live communication connector
|
- `scripts/mattermost/` -> live communication connector
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: agent-integration
|
type: agent-integration
|
||||||
status: active
|
status: active
|
||||||
updated: 2026-05-19
|
updated: 2026-05-20
|
||||||
tags:
|
tags:
|
||||||
- communication
|
- communication
|
||||||
- evidence
|
- evidence
|
||||||
@@ -27,6 +27,8 @@ Mattermost is the current live communication connector.
|
|||||||
- Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact.
|
- Latest-message requests are read-first. The agent may identify a memory update candidate, but should not edit `project-knowledge/` from the latest-message command unless the user explicitly asks to promote the fact.
|
||||||
- Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically.
|
- Standup generation is a separate required-refresh flow: it must fetch Mattermost before drafting, even though general prompts should not sync automatically.
|
||||||
- Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code.
|
- Standup reads should use the focused reader mode, `scripts/mattermost-proxy/read-context.py --mode standup --today YYYY-MM-DD`, which reads date-bucketed previous-workday/today records and should use the active profile's configured `AIW_MATTERMOST_CONTEXT_CHANNELS` when available. Avoid loading broad mirror `latest.md` into standup prompts because it may include stale or unrelated channels and waste tokens. Keep project-specific channel names out of reusable connector code.
|
||||||
|
- If adding MCP support for Mattermost, treat it as a read-only query wrapper over the existing proxy mirror and `read-context.py`, not as a replacement for the capture/mirror pipeline. Keep the mirror's file layout as canonical raw evidence and expose only narrow tools such as latest, standup/date, channel, and thread reads with channel filters and limits.
|
||||||
|
- Do not build a write-capable Mattermost MCP or expose tokens, cookies, raw headers, or broad unfiltered raw dumps through MCP. MCP output should remain evidence for agent reasoning; promotion to `project-knowledge/` still follows normal memory rules.
|
||||||
- If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran.
|
- If the proxy mirror is running, treat it as fresher than legacy `mattermost-latest.md` / generated JSONL. Do not ignore mirror evidence merely because a legacy sync command also ran.
|
||||||
- Do not refresh Mattermost just because a prompt mentions a manager or stakeholder.
|
- Do not refresh Mattermost just because a prompt mentions a manager or stakeholder.
|
||||||
- Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence.
|
- Treat document review, message polishing, translation, and "does this align with Jeff's expectations?" prompts as normal drafting tasks unless the user explicitly asks for the latest message or fresh Mattermost evidence.
|
||||||
|
|||||||
9
apps/mac/AIWorkspace/.gitignore
vendored
Normal file
9
apps/mac/AIWorkspace/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
dist/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj
|
||||||
|
*.xcworkspace
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
.DS_Store
|
||||||
19
apps/mac/AIWorkspace/Package.swift
Normal file
19
apps/mac/AIWorkspace/Package.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "AIWorkspace",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "AIWorkspace", targets: ["AIWorkspace"])
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "AIWorkspace",
|
||||||
|
path: "Sources"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
77
apps/mac/AIWorkspace/README.md
Normal file
77
apps/mac/AIWorkspace/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# AI Workspace macOS Menu Bar App
|
||||||
|
|
||||||
|
Minimal SwiftUI `MenuBarExtra` app for controlling local AI Workspace services.
|
||||||
|
|
||||||
|
The app is intentionally a thin UI over the service manager. It reads live status from:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity --json
|
||||||
|
```
|
||||||
|
|
||||||
|
and sends lifecycle actions through `scripts/aiw/services.py`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift build --package-path apps/mac/AIWorkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package as `.app`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/package-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Install to `~/Applications/AIWorkspace.app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/package-app.sh --install
|
||||||
|
```
|
||||||
|
|
||||||
|
One-step local install, optionally enabling start at login and opening the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Start at login
|
||||||
|
|
||||||
|
After installing the app bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To remove the login item:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run during development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift run --package-path apps/mac/AIWorkspace AIWorkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current actions
|
||||||
|
|
||||||
|
- Refresh status
|
||||||
|
- Start Fidelity services
|
||||||
|
- Stop Fidelity services
|
||||||
|
- Restart Context MCP
|
||||||
|
- Open Mattermost through the service manager
|
||||||
|
- Open Mattermost through the local proxy-managed launcher
|
||||||
|
- Run Doctor
|
||||||
|
- Copy Doctor JSON
|
||||||
|
- Copy Photo Inbox URL
|
||||||
|
- Copy recent logs
|
||||||
|
- Open MCP Health
|
||||||
|
- Open logs folder
|
||||||
|
- Open project knowledge
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is not yet packaged as a signed `.app` bundle.
|
||||||
|
- Start at login should be implemented later through a LaunchAgent or app login item.
|
||||||
|
- The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`.
|
||||||
455
apps/mac/AIWorkspace/Sources/main.swift
Normal file
455
apps/mac/AIWorkspace/Sources/main.swift
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
||||||
|
private let defaultProfile = "fidelity"
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct AIWorkspaceApp: App {
|
||||||
|
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
MenuBarExtra {
|
||||||
|
ServiceMenuView(model: model)
|
||||||
|
.task {
|
||||||
|
await model.refresh()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("AI Workspace", systemImage: model.menuBarSymbol)
|
||||||
|
}
|
||||||
|
.menuBarExtraStyle(.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ServiceStatusModel: ObservableObject {
|
||||||
|
@Published private(set) var report: StatusReport?
|
||||||
|
@Published private(set) var lastError: String?
|
||||||
|
@Published private(set) var lanIP: String?
|
||||||
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
let profile: String
|
||||||
|
|
||||||
|
init(profile: String) {
|
||||||
|
self.profile = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
var menuBarSymbol: String {
|
||||||
|
guard let report else { return "circle.dashed" }
|
||||||
|
if report.services.contains(where: { $0.status == "unhealthy" || ($0.enabled && $0.status == "stopped") }) {
|
||||||
|
return "exclamationmark.triangle"
|
||||||
|
}
|
||||||
|
if report.services.contains(where: { $0.status == "running" }) {
|
||||||
|
return "checkmark.circle"
|
||||||
|
}
|
||||||
|
return "circle"
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
do {
|
||||||
|
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
||||||
|
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
||||||
|
lanIP = await NetworkInfo.primaryLANIP()
|
||||||
|
lastError = nil
|
||||||
|
} catch {
|
||||||
|
lastError = String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProfile() {
|
||||||
|
runAction(["start", "--profile", profile])
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopProfile() {
|
||||||
|
runAction(["stop", "--profile", profile])
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartMCP() {
|
||||||
|
runAction(["restart", "aiw-context-mcp", "--profile", profile])
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDoctor() {
|
||||||
|
runAction(["doctor", "--profile", profile])
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDoctorJSON() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"])
|
||||||
|
copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
|
||||||
|
} catch {
|
||||||
|
lastError = String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPhotoInboxURL() {
|
||||||
|
let host = lanIP ?? "127.0.0.1"
|
||||||
|
copyToPasteboard("http://\(host):8787/upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyRecentLogs() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
var chunks: [String] = []
|
||||||
|
for service in report?.services ?? [] {
|
||||||
|
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"])
|
||||||
|
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
|
||||||
|
chunks.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyToPasteboard(chunks.joined(separator: "\n\n"))
|
||||||
|
} catch {
|
||||||
|
lastError = String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMCPHealth() {
|
||||||
|
if let url = URL(string: "http://127.0.0.1:8765/health") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLogsFolder() {
|
||||||
|
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openProjectKnowledge() {
|
||||||
|
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMattermost() {
|
||||||
|
runAction(["start", "mattermost-desktop", "--profile", profile])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyToPasteboard(_ value: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(value, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAction(_ arguments: [String]) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await ServiceManager.run(arguments)
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
lastError = String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServiceMenuView: View {
|
||||||
|
@ObservedObject var model: ServiceStatusModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("AI Workspace")
|
||||||
|
.font(.title3.bold())
|
||||||
|
Text("Profile: \(model.profile)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await model.refresh() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if let report = model.report {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(report.services) { service in
|
||||||
|
ServiceRow(service: service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let error = model.lastError {
|
||||||
|
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
||||||
|
Text(error)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
} else {
|
||||||
|
Label("Loading status...", systemImage: "hourglass")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lanIP = model.lanIP {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Photo Inbox LAN")
|
||||||
|
Spacer()
|
||||||
|
Text("http://\(lanIP):8787/upload")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
|
||||||
|
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
|
||||||
|
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
|
||||||
|
ActionButton(title: "Mattermost via Proxy", systemImage: "message.badge", action: model.openMattermost)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
|
||||||
|
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
|
||||||
|
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
|
||||||
|
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs)
|
||||||
|
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
|
||||||
|
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
if let error = model.lastError {
|
||||||
|
Label(error, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Quit") {
|
||||||
|
NSApplication.shared.terminate(nil)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("q")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(width: 430)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActionButton: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
var role: ButtonRole?
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(role: role, action: action) {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServiceRow: View {
|
||||||
|
let service: ServiceStatus
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 16, alignment: .center)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(service.displayName)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
Text(service.detail)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
StatusBadge(text: service.compactStatus, color: color)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.help(service.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var symbol: String {
|
||||||
|
switch service.status {
|
||||||
|
case "running": "checkmark.circle"
|
||||||
|
case "launcher": "arrow.up.forward.app"
|
||||||
|
case "externally running": "link.circle"
|
||||||
|
case "unhealthy": "exclamationmark.triangle"
|
||||||
|
case "disabled": "minus.circle"
|
||||||
|
default: "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch service.status {
|
||||||
|
case "running", "launcher", "externally running": .green
|
||||||
|
case "unhealthy": .orange
|
||||||
|
case "disabled": .secondary
|
||||||
|
default: .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(color.opacity(0.14), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ServiceManager {
|
||||||
|
static func run(_ arguments: [String]) async throws -> Data {
|
||||||
|
try await Task.detached(priority: .userInitiated) {
|
||||||
|
let process = Process()
|
||||||
|
process.currentDirectoryURL = workspaceRoot
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||||
|
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
|
||||||
|
|
||||||
|
let output = Pipe()
|
||||||
|
let error = Pipe()
|
||||||
|
process.standardOutput = output
|
||||||
|
process.standardError = error
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let errorData = error.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let message = String(data: errorData.isEmpty ? data : errorData, encoding: .utf8) ?? "service command failed"
|
||||||
|
throw ServiceManagerError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NetworkInfo {
|
||||||
|
static func primaryLANIP() async -> String? {
|
||||||
|
for interface in ["en0", "en1"] {
|
||||||
|
if let value = try? await runIPConfig(interface), !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runIPConfig(_ interface: String) async throws -> String {
|
||||||
|
try await Task.detached(priority: .utility) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/sbin/ipconfig")
|
||||||
|
process.arguments = ["getifaddr", interface]
|
||||||
|
let output = Pipe()
|
||||||
|
process.standardOutput = output
|
||||||
|
process.standardError = Pipe()
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard process.terminationStatus == 0 else { return "" }
|
||||||
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ServiceManagerError: LocalizedError {
|
||||||
|
case commandFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .commandFailed(let message): message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusReport: Decodable {
|
||||||
|
let profile: String
|
||||||
|
let workspace: String
|
||||||
|
let runtime: String
|
||||||
|
let services: [ServiceStatus]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServiceStatus: Decodable, Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
|
||||||
|
let name: String
|
||||||
|
let enabled: Bool
|
||||||
|
let kind: String
|
||||||
|
let status: String
|
||||||
|
let pid: Int?
|
||||||
|
let command: [String]
|
||||||
|
let health: Health
|
||||||
|
let state: [String: JSONValue]
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch name {
|
||||||
|
case "aiw-context-mcp": "Context MCP"
|
||||||
|
case "mattermost-proxy": "Mattermost Proxy"
|
||||||
|
case "mattermost-desktop": "Mattermost Desktop via Proxy"
|
||||||
|
case "photo-inbox": "Photo Inbox"
|
||||||
|
default: name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String {
|
||||||
|
if name == "mattermost-desktop" {
|
||||||
|
return "Launches Mattermost through local proxy 127.0.0.1:8080"
|
||||||
|
}
|
||||||
|
return health.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
var compactStatus: String {
|
||||||
|
switch status {
|
||||||
|
case "externally running": "external"
|
||||||
|
default: status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Health: Decodable {
|
||||||
|
let ok: Bool?
|
||||||
|
let detail: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JSONValue: Decodable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case object([String: JSONValue])
|
||||||
|
case array([JSONValue])
|
||||||
|
case null
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
self = .null
|
||||||
|
} else if let value = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(value)
|
||||||
|
} else if let value = try? container.decode(Double.self) {
|
||||||
|
self = .number(value)
|
||||||
|
} else if let value = try? container.decode(String.self) {
|
||||||
|
self = .string(value)
|
||||||
|
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||||
|
self = .object(value)
|
||||||
|
} else {
|
||||||
|
self = .array(try container.decode([JSONValue].self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal file
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_PATH="${APP_PATH:-$HOME/Applications/AIWorkspace.app}"
|
||||||
|
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
|
||||||
|
|
||||||
|
if [[ ! -d "$APP_PATH" ]]; then
|
||||||
|
echo "App not found: $APP_PATH" >&2
|
||||||
|
echo "Run apps/mac/AIWorkspace/scripts/package-app.sh --install first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents"
|
||||||
|
|
||||||
|
cat > "$PLIST_PATH" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.aiworkspace.menu</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/open</string>
|
||||||
|
<string>-a</string>
|
||||||
|
<string>$APP_PATH</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>$HOME/Library/Logs/AIWorkspace-menu.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>$HOME/Library/Logs/AIWorkspace-menu.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
|
||||||
|
launchctl load "$PLIST_PATH"
|
||||||
|
echo "Installed and loaded LaunchAgent: $PLIST_PATH"
|
||||||
35
apps/mac/AIWorkspace/scripts/install.sh
Normal file
35
apps/mac/AIWorkspace/scripts/install.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
INSTALL_LOGIN_ITEM=0
|
||||||
|
OPEN_APP=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--start-at-login)
|
||||||
|
INSTALL_LOGIN_ITEM=1
|
||||||
|
;;
|
||||||
|
--open)
|
||||||
|
OPEN_APP=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $arg" >&2
|
||||||
|
echo "Usage: $0 [--start-at-login] [--open]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
"$SCRIPT_DIR/package-app.sh" --install
|
||||||
|
|
||||||
|
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
|
||||||
|
"$SCRIPT_DIR/install-start-at-login.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$OPEN_APP" == "1" ]]; then
|
||||||
|
open "$HOME/Applications/AIWorkspace.app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "AI Workspace app install complete."
|
||||||
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal file
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
WORKSPACE_ROOT="$(cd "$APP_ROOT/../../.." && pwd)"
|
||||||
|
|
||||||
|
CONFIGURATION="${CONFIGURATION:-release}"
|
||||||
|
APP_NAME="AIWorkspace"
|
||||||
|
BUILD_DIR="$APP_ROOT/.build/$CONFIGURATION"
|
||||||
|
OUTPUT_DIR="$APP_ROOT/dist"
|
||||||
|
APP_BUNDLE="$OUTPUT_DIR/$APP_NAME.app"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-$HOME/Applications}"
|
||||||
|
|
||||||
|
INSTALL=0
|
||||||
|
if [[ "${1:-}" == "--install" ]]; then
|
||||||
|
INSTALL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
swift build --package-path "$APP_ROOT" -c "$CONFIGURATION"
|
||||||
|
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
|
||||||
|
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
|
||||||
|
|
||||||
|
cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$APP_NAME</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.aiworkspace.menu</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>AI Workspace</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Local AI Workspace utility.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
echo "Built $APP_BUNDLE"
|
||||||
|
|
||||||
|
if [[ "$INSTALL" == "1" ]]; then
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR/$APP_NAME.app"
|
||||||
|
cp -R "$APP_BUNDLE" "$INSTALL_DIR/$APP_NAME.app"
|
||||||
|
echo "Installed $INSTALL_DIR/$APP_NAME.app"
|
||||||
|
fi
|
||||||
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal file
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
|
||||||
|
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
|
||||||
|
rm -f "$PLIST_PATH"
|
||||||
|
echo "Removed LaunchAgent: $PLIST_PATH"
|
||||||
47
core/services/macos-installation-model.md
Normal file
47
core/services/macos-installation-model.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# macOS Installation Model
|
||||||
|
|
||||||
|
## How production macOS utilities commonly do it
|
||||||
|
|
||||||
|
Apps such as Cloudflare WARP, VPN clients, Docker Desktop, and device agents usually separate:
|
||||||
|
|
||||||
|
- a user-facing app or menu bar app;
|
||||||
|
- one or more background services;
|
||||||
|
- launchd configuration for automatic startup;
|
||||||
|
- privileged helpers only when system-level networking, drivers, packet filtering, or protected paths are required.
|
||||||
|
|
||||||
|
Common mechanisms:
|
||||||
|
|
||||||
|
- `LaunchAgent` in `~/Library/LaunchAgents` for per-user background/login startup.
|
||||||
|
- `LaunchDaemon` in `/Library/LaunchDaemons` for root/system services.
|
||||||
|
- `SMAppService` / login items for sandboxed or App Store-aligned apps.
|
||||||
|
- Privileged helper tools via `SMJobBless` when admin-level installation is required.
|
||||||
|
- `.pkg` installers when the install needs privileged locations, daemons, receipts, or managed deployment.
|
||||||
|
|
||||||
|
## Recommended AI Workspace approach
|
||||||
|
|
||||||
|
Use a staged model:
|
||||||
|
|
||||||
|
1. **Current local developer install**
|
||||||
|
- Build a real `.app` bundle into `apps/mac/AIWorkspace/dist/`.
|
||||||
|
- Install to `~/Applications/AIWorkspace.app`.
|
||||||
|
- Install a per-user `LaunchAgent` for start at login.
|
||||||
|
|
||||||
|
2. **Production-ready local install**
|
||||||
|
- Keep using a per-user LaunchAgent because services are local user tools and do not require root.
|
||||||
|
- Add a one-step installer script that builds, installs, optionally enables start at login, and opens the app.
|
||||||
|
- Avoid privileged helpers until a real system-level requirement appears.
|
||||||
|
|
||||||
|
3. **Future polished distribution**
|
||||||
|
- Create a signed/notarized `.app` or `.pkg`.
|
||||||
|
- Consider `SMAppService` for login item management from inside the app.
|
||||||
|
- Add a small daemon API if the UI needs richer lifecycle control than shelling out to `services.py`.
|
||||||
|
|
||||||
|
## Why not LaunchDaemon now
|
||||||
|
|
||||||
|
The current services are user-context services:
|
||||||
|
|
||||||
|
- Mattermost Desktop launching must happen in the user's GUI session.
|
||||||
|
- Photo Inbox writes to user-owned folders and uses clipboard/notifications.
|
||||||
|
- The MCP and proxy bind localhost ports and do not require root.
|
||||||
|
|
||||||
|
A root daemon would add unnecessary permission prompts and security risk. A per-user LaunchAgent is the correct production-leaning step for this stage.
|
||||||
81
core/services/menu-bar-app-design.md
Normal file
81
core/services/menu-bar-app-design.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# AI Workspace Menu Bar App Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Provide a small native macOS control surface for the AI Workspace Service Manager.
|
||||||
|
|
||||||
|
The app should not reimplement service logic. It should call the service manager/daemon API or CLI and display status, actions, and diagnostics.
|
||||||
|
|
||||||
|
## Recommended Shape
|
||||||
|
|
||||||
|
- SwiftUI `MenuBarExtra` app.
|
||||||
|
- Local-only, no cloud dependency.
|
||||||
|
- Start at login optional through `LaunchAgent` later.
|
||||||
|
- Read-only status by default; explicit user actions for start/stop/restart.
|
||||||
|
- The UI should not own MCP profile selection. AI clients pass profiles to MCP tools/resources; the menu bar app is only a local operations surface.
|
||||||
|
|
||||||
|
## Initial UI
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI Workspace ▾
|
||||||
|
Profile: Fidelity
|
||||||
|
|
||||||
|
Services
|
||||||
|
✓ Context MCP Running
|
||||||
|
✓ Mattermost Proxy Running
|
||||||
|
✓ Mattermost Desktop Launched
|
||||||
|
✓ Photo Inbox Running
|
||||||
|
|
||||||
|
Actions
|
||||||
|
Start Fidelity
|
||||||
|
Stop Fidelity
|
||||||
|
Restart Context MCP
|
||||||
|
Open Mattermost
|
||||||
|
Open Photo Inbox Folder
|
||||||
|
Copy Photo Inbox Upload URL
|
||||||
|
Copy Recent Logs
|
||||||
|
Open Project Knowledge
|
||||||
|
Open Logs
|
||||||
|
|
||||||
|
Diagnostics
|
||||||
|
Run Doctor
|
||||||
|
Copy Doctor JSON
|
||||||
|
Show Recent Errors
|
||||||
|
|
||||||
|
Settings
|
||||||
|
Start at Login
|
||||||
|
Open Config Folder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Contract
|
||||||
|
|
||||||
|
The first version can shell out to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity --json
|
||||||
|
python3 scripts/aiw/services.py doctor --profile fidelity --json
|
||||||
|
python3 scripts/aiw/services.py start --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py stop --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py restart aiw-context-mcp --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `status --json` for frequent UI refreshes and `doctor --json` for explicit diagnostics. Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not parse terminal text.
|
||||||
|
|
||||||
|
## Production-Ready Rules
|
||||||
|
|
||||||
|
- Do not store secrets in the app bundle.
|
||||||
|
- Do not expose services beyond localhost unless explicitly configured.
|
||||||
|
- Show whether a process is managed or externally running.
|
||||||
|
- Surface missing dependencies from doctor checks.
|
||||||
|
- Never let the app promote project memory automatically.
|
||||||
|
- Keep capture services and context MCP separate; the app only orchestrates lifecycle.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
1. CLI-backed SwiftUI menu bar app using `status --json` for live status and `doctor --json` for diagnostics.
|
||||||
|
2. Add profile selector and service action buttons.
|
||||||
|
3. Add LaunchAgent support for start at login.
|
||||||
|
4. Replace shell parsing with a daemon API if daily use proves stable.
|
||||||
|
|
||||||
|
See `multi-profile-runtime-model.md` for how this should evolve when multiple profiles run in parallel.
|
||||||
49
core/services/multi-profile-runtime-model.md
Normal file
49
core/services/multi-profile-runtime-model.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Multi-Profile Runtime Model
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
Profiles are selected by clients and services, not by the menu bar UI.
|
||||||
|
|
||||||
|
The menu bar app is a local operations surface for the current operator machine. It can monitor the local services that are enabled now, but it should not become the source of truth for which project an AI client is allowed to query.
|
||||||
|
|
||||||
|
## Desired Model
|
||||||
|
|
||||||
|
- MCP clients choose the profile at call time, for example `{"profile":"fidelity"}`.
|
||||||
|
- Multiple profiles may run in parallel when their service ports and inbox paths do not conflict.
|
||||||
|
- Capture services can be profile-specific or shared:
|
||||||
|
- shared service: one Mattermost mirror with profile-scoped query filters;
|
||||||
|
- profile-specific service: separate mirror/output path/port per profile.
|
||||||
|
- The MCP query layer should remain profile-aware and read from profile manifests/config.
|
||||||
|
- The menu bar app should show local runtime health, not force a single global profile selection.
|
||||||
|
|
||||||
|
## Current Fidelity Setup
|
||||||
|
|
||||||
|
The first menu bar version targets the Fidelity service set because that is the active local workflow. This does not prevent MCP clients from querying other profiles when those profiles have canonical memory or context-source config.
|
||||||
|
|
||||||
|
## Parallel Profile Requirements
|
||||||
|
|
||||||
|
Before running another profile in parallel, define unique values for any conflicting service:
|
||||||
|
|
||||||
|
- MCP HTTP port if using separate MCP instances.
|
||||||
|
- Mattermost proxy listen port if using separate proxy instances.
|
||||||
|
- Photo Inbox port if using separate upload receivers.
|
||||||
|
- Mirror/inbox output directory.
|
||||||
|
- Profile-specific context channels.
|
||||||
|
|
||||||
|
Example future split:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fidelity:
|
||||||
|
aiw-context-mcp: 127.0.0.1:8765
|
||||||
|
mattermost-proxy: 127.0.0.1:8080
|
||||||
|
photo-inbox: 0.0.0.0:8787
|
||||||
|
|
||||||
|
it-support:
|
||||||
|
aiw-context-mcp: same shared MCP, profile argument selects context
|
||||||
|
mattermost-proxy: 127.0.0.1:8081 if a separate capture session is needed
|
||||||
|
photo-inbox: 0.0.0.0:8788 if a separate receiver is needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menu Bar Direction
|
||||||
|
|
||||||
|
The menu bar app should evolve toward showing service groups or all running profiles, but not a manual profile selector for MCP query behavior. Profile selection belongs in the MCP tool/resource arguments and client prompts.
|
||||||
28
core/services/service-manager.md
Normal file
28
core/services/service-manager.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# AI Workspace Service Manager
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
The AI Workspace should unify local service lifecycle without collapsing service responsibilities.
|
||||||
|
|
||||||
|
- Service manager: starts, stops, checks, and logs local services.
|
||||||
|
- Context MCP: exposes bounded read-only context to AI clients.
|
||||||
|
- Capture services: produce local evidence such as Mattermost mirror records or photo inbox files.
|
||||||
|
- Canonical memory remains under `project-knowledge/` and is updated by the agent using memory rules.
|
||||||
|
|
||||||
|
## Service Types
|
||||||
|
|
||||||
|
- `process`: long-running local command with PID, logs, and optional health check.
|
||||||
|
- `app-launcher`: one-shot command that opens an application or helper.
|
||||||
|
- `mcp`: a process service that exposes an MCP-compatible context interface.
|
||||||
|
|
||||||
|
## Profile Manifests
|
||||||
|
|
||||||
|
Project-specific services should be declared under `profiles/<profile>/services.json`.
|
||||||
|
|
||||||
|
Manifests should avoid project facts in reusable code. Profile-specific channel names, paths, ports, and enabled services belong in the profile manifest or local `.env` files.
|
||||||
|
|
||||||
|
## Responsibility Boundaries
|
||||||
|
|
||||||
|
Do not put capture lifecycle inside the context MCP. The MCP should query local evidence produced by capture services. The service manager may start both the MCP and capture services as one profile-level operation.
|
||||||
|
|
||||||
|
This keeps the same core usable for Fidelity, IT support, or another project with different communication sources.
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
"OBSIDIAN_HOST": "127.0.0.1",
|
"OBSIDIAN_HOST": "127.0.0.1",
|
||||||
"OBSIDIAN_PORT": "27124"
|
"OBSIDIAN_PORT": "27124"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"aiw-context-mcp": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "http://127.0.0.1:8765/mcp",
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
|
|||||||
15
profiles/fidelity/context-sources.json
Normal file
15
profiles/fidelity/context-sources.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"profile": "fidelity",
|
||||||
|
"communication_sources": {
|
||||||
|
"mattermost": {
|
||||||
|
"type": "mattermost_mirror",
|
||||||
|
"context_channels": [
|
||||||
|
"fidelity-preguntas",
|
||||||
|
"fidelity-standup",
|
||||||
|
"fidelity-code-review",
|
||||||
|
"fidelity-interface-meetings-on-calendar-outlook-team-etc",
|
||||||
|
"dm-david--jeff"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
profiles/fidelity/services.json
Normal file
69
profiles/fidelity/services.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"profile": "fidelity",
|
||||||
|
"description": "Local AI Workspace services for the Fidelity profile.",
|
||||||
|
"services": {
|
||||||
|
"aiw-context-mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "mcp",
|
||||||
|
"description": "Read-only AI Workspace context MCP server.",
|
||||||
|
"command": ["python3", "scripts/mcp/aiw-context-mcp/server.py", "--transport", "http"],
|
||||||
|
"groups": ["mcp", "context"],
|
||||||
|
"restart": "on-failure",
|
||||||
|
"doctor": {
|
||||||
|
"required_commands": ["python3"],
|
||||||
|
"required_paths": ["scripts/mcp/aiw-context-mcp/server.py"]
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8765/health"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mattermost-proxy": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "process",
|
||||||
|
"description": "Local mitmproxy mirror that captures normalized Mattermost evidence.",
|
||||||
|
"command": ["scripts/mattermost-proxy/run-mirror.sh"],
|
||||||
|
"groups": ["communication", "mattermost", "capture"],
|
||||||
|
"restart": "on-failure",
|
||||||
|
"doctor": {
|
||||||
|
"required_commands": ["mitmdump"],
|
||||||
|
"optional_paths": ["scripts/mattermost-proxy/.env"]
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"type": "tcp",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mattermost-desktop": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "app-launcher",
|
||||||
|
"description": "Launch Mattermost Desktop through the local proxy.",
|
||||||
|
"command": ["scripts/mattermost-proxy/launch-mattermost.sh"],
|
||||||
|
"groups": ["communication", "mattermost"],
|
||||||
|
"depends_on": ["mattermost-proxy"],
|
||||||
|
"restart": "never",
|
||||||
|
"doctor": {
|
||||||
|
"required_paths": ["/Applications/Mattermost.app"],
|
||||||
|
"optional_paths": ["scripts/mattermost-proxy/.env"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photo-inbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "process",
|
||||||
|
"description": "macOS HTTP receiver for iPhone photo uploads into the local inbox.",
|
||||||
|
"command": ["scripts/iphone-photo-inbox/run.sh"],
|
||||||
|
"groups": ["inbox", "photos", "capture"],
|
||||||
|
"restart": "on-failure",
|
||||||
|
"doctor": {
|
||||||
|
"required_commands": ["python3"],
|
||||||
|
"optional_commands": ["swiftc"],
|
||||||
|
"optional_paths": ["scripts/iphone-photo-inbox/.env"]
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:8787/health"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
This directory contains helpers that automate:
|
This directory contains helpers that automate:
|
||||||
|
|
||||||
|
- AI Workspace local service lifecycle
|
||||||
- context aggregation
|
- context aggregation
|
||||||
- canonical memory access
|
- canonical memory access
|
||||||
- standup generation
|
- standup generation
|
||||||
@@ -15,6 +16,25 @@ The project-agnostic memory interface lives in:
|
|||||||
|
|
||||||
Recommended commands:
|
Recommended commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py doctor --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py start --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
The service manager reads `profiles/<profile>/services.json` and manages local
|
||||||
|
services such as the Mattermost proxy mirror and Photo Inbox. Runtime PID/state
|
||||||
|
and logs stay under `.aiw/runtime/`.
|
||||||
|
|
||||||
|
The local context MCP server lives in:
|
||||||
|
|
||||||
|
- `scripts/mcp/aiw-context-mcp/`
|
||||||
|
|
||||||
|
It exposes read-only workspace context to local AI clients and is started as the
|
||||||
|
`aiw-context-mcp` service for profiles that enable it.
|
||||||
|
|
||||||
|
Recommended memory commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/memory/memory.sh health
|
bash scripts/memory/memory.sh health
|
||||||
bash scripts/memory/memory.sh search "PDIAP-15765"
|
bash scripts/memory/memory.sh search "PDIAP-15765"
|
||||||
|
|||||||
50
scripts/aiw/README.md
Normal file
50
scripts/aiw/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# AI Workspace Service Manager
|
||||||
|
|
||||||
|
The service manager is the local lifecycle layer for AI Workspace services.
|
||||||
|
|
||||||
|
It reads `profiles/<profile>/services.json`, starts/stops enabled services, records logs under `.aiw/runtime/logs/`, and keeps PID/state files under `.aiw/runtime/`.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py status --profile fidelity --json
|
||||||
|
python3 scripts/aiw/services.py doctor --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py doctor --profile fidelity --json
|
||||||
|
python3 scripts/aiw/services.py start --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py stop --profile fidelity
|
||||||
|
python3 scripts/aiw/services.py logs mattermost-proxy --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
Start a subset by group:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py start --profile fidelity --group communication
|
||||||
|
python3 scripts/aiw/services.py start --profile fidelity --group inbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Fidelity services
|
||||||
|
|
||||||
|
- `mattermost-proxy`: runs the local Mattermost proxy mirror.
|
||||||
|
- `mattermost-desktop`: launches Mattermost Desktop through the proxy.
|
||||||
|
- `photo-inbox`: runs the local HTTP photo receiver.
|
||||||
|
- `aiw-context-mcp`: read-only context MCP server for local AI clients.
|
||||||
|
|
||||||
|
The service manager unifies startup and status. It does not move capture behavior into the MCP.
|
||||||
|
|
||||||
|
## Robustness features
|
||||||
|
|
||||||
|
- Manifest validation before lifecycle actions.
|
||||||
|
- Dependency-aware startup through `depends_on`.
|
||||||
|
- Managed PID/state files under `.aiw/runtime/`.
|
||||||
|
- Per-service logs under `.aiw/runtime/logs/`.
|
||||||
|
- Simple log rotation before service start.
|
||||||
|
- TCP/HTTP health checks.
|
||||||
|
- Doctor checks for required commands and paths declared in the profile manifest.
|
||||||
|
- Protection against starting duplicate services when a matching health check is already passing externally.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/test_services.py
|
||||||
|
```
|
||||||
507
scripts/aiw/services.py
Normal file
507
scripts/aiw/services.py
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AI Workspace local service manager.
|
||||||
|
|
||||||
|
This is the profile-aware lifecycle layer for local capture/query services. It is
|
||||||
|
intentionally small and dependency-free so it can run before the future desktop
|
||||||
|
UI or MCP server exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RUNTIME_DIR = ROOT / ".aiw" / "runtime"
|
||||||
|
PID_DIR = RUNTIME_DIR / "pids"
|
||||||
|
LOG_DIR = RUNTIME_DIR / "logs"
|
||||||
|
STATE_DIR = RUNTIME_DIR / "state"
|
||||||
|
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
DEFAULT_LOG_BACKUPS = 3
|
||||||
|
DEFAULT_STOP_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServiceRef:
|
||||||
|
name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime() -> None:
|
||||||
|
for path in [PID_DIR, LOG_DIR, STATE_DIR]:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_path(profile: str) -> Path:
|
||||||
|
return ROOT / "profiles" / profile / "services.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(profile: str) -> dict[str, Any]:
|
||||||
|
path = manifest_path(profile)
|
||||||
|
if not path.is_file():
|
||||||
|
raise SystemExit(f"services manifest not found: {path}")
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manifest(manifest: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
services = manifest.get("services")
|
||||||
|
if not isinstance(services, dict):
|
||||||
|
return ["manifest must contain a services object"]
|
||||||
|
for name, config in services.items():
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
errors.append(f"{name}: service config must be an object")
|
||||||
|
continue
|
||||||
|
command = config.get("command")
|
||||||
|
if config.get("enabled", True) and (not isinstance(command, list) or not command):
|
||||||
|
errors.append(f"{name}: enabled services require a non-empty command list")
|
||||||
|
kind = config.get("kind", "process")
|
||||||
|
if kind not in {"process", "app-launcher", "mcp"}:
|
||||||
|
errors.append(f"{name}: unsupported kind {kind!r}")
|
||||||
|
restart = config.get("restart", "never")
|
||||||
|
if restart not in {"never", "on-failure", "always"}:
|
||||||
|
errors.append(f"{name}: unsupported restart policy {restart!r}")
|
||||||
|
for dependency in config.get("depends_on") or []:
|
||||||
|
if dependency not in services:
|
||||||
|
errors.append(f"{name}: depends on unknown service {dependency!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def service_items(manifest: dict[str, Any], include_disabled: bool = False) -> list[ServiceRef]:
|
||||||
|
services = manifest.get("services") or {}
|
||||||
|
refs: list[ServiceRef] = []
|
||||||
|
for name, config in services.items():
|
||||||
|
if not include_disabled and not config.get("enabled", True):
|
||||||
|
continue
|
||||||
|
refs.append(ServiceRef(name, config))
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def select_services(manifest: dict[str, Any], names: list[str], group: str | None, include_disabled: bool = False) -> list[ServiceRef]:
|
||||||
|
refs = service_items(manifest, include_disabled=include_disabled)
|
||||||
|
by_name = {ref.name: ref for ref in refs}
|
||||||
|
if names:
|
||||||
|
selected: list[ServiceRef] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
for name in names:
|
||||||
|
ref = by_name.get(name)
|
||||||
|
if ref is None:
|
||||||
|
missing.append(name)
|
||||||
|
else:
|
||||||
|
selected.append(ref)
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("unknown or disabled service(s): " + ", ".join(missing))
|
||||||
|
return selected
|
||||||
|
if group:
|
||||||
|
return [ref for ref in refs if group in (ref.config.get("groups") or [])]
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def pid_path(profile: str, service: str) -> Path:
|
||||||
|
return PID_DIR / profile / f"{service}.pid"
|
||||||
|
|
||||||
|
|
||||||
|
def state_path(profile: str, service: str) -> Path:
|
||||||
|
return STATE_DIR / profile / f"{service}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def log_path(profile: str, service: str) -> Path:
|
||||||
|
return LOG_DIR / profile / f"{service}.log"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workspace_path(raw: str) -> Path:
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def command_exists(command: str) -> bool:
|
||||||
|
if not command:
|
||||||
|
return False
|
||||||
|
path = Path(command)
|
||||||
|
if path.is_absolute() or "/" in command:
|
||||||
|
resolved = resolve_workspace_path(command)
|
||||||
|
return resolved.exists() and os.access(resolved, os.X_OK)
|
||||||
|
return shutil_which(command) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None:
|
||||||
|
if max_bytes <= 0 or backups <= 0 or not path.exists() or path.stat().st_size < max_bytes:
|
||||||
|
return
|
||||||
|
oldest = path.with_suffix(path.suffix + f".{backups}")
|
||||||
|
oldest.unlink(missing_ok=True)
|
||||||
|
for index in range(backups - 1, 0, -1):
|
||||||
|
src = path.with_suffix(path.suffix + f".{index}")
|
||||||
|
dst = path.with_suffix(path.suffix + f".{index + 1}")
|
||||||
|
if src.exists():
|
||||||
|
src.replace(dst)
|
||||||
|
path.replace(path.with_suffix(path.suffix + ".1"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_pid(profile: str, service: str) -> int | None:
|
||||||
|
path = pid_path(profile, service)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(path.read_text(encoding="utf-8").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_running(pid: int | None) -> bool:
|
||||||
|
if not pid or pid <= 0:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], check=False, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
state = result.stdout.strip()
|
||||||
|
if state.startswith("Z"):
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(profile: str, service: str, state: dict[str, Any]) -> None:
|
||||||
|
path = state_path(profile, service)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(profile: str, service: str) -> dict[str, Any]:
|
||||||
|
path = state_path(profile, service)
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def health_ok(config: dict[str, Any], timeout: float = 1.0) -> tuple[bool | None, str]:
|
||||||
|
health = config.get("health") or {}
|
||||||
|
kind = health.get("type")
|
||||||
|
if not kind:
|
||||||
|
return None, "no health check"
|
||||||
|
if kind == "tcp":
|
||||||
|
host = str(health.get("host") or "127.0.0.1")
|
||||||
|
port = int(health.get("port") or 0)
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True, f"tcp {host}:{port} ok"
|
||||||
|
except OSError as error:
|
||||||
|
return False, f"tcp {host}:{port} failed: {error}"
|
||||||
|
if kind == "http":
|
||||||
|
url = str(health.get("url") or "")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||||
|
ok = 200 <= int(response.status) < 400
|
||||||
|
return ok, f"http {url} status {response.status}"
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||||
|
return False, f"http {url} failed: {error}"
|
||||||
|
return None, f"unknown health type: {kind}"
|
||||||
|
|
||||||
|
|
||||||
|
def service_status(profile: str, ref: ServiceRef) -> dict[str, Any]:
|
||||||
|
enabled = ref.config.get("enabled", True)
|
||||||
|
kind = ref.config.get("kind", "process")
|
||||||
|
command = ref.config.get("command") or []
|
||||||
|
pid = read_pid(profile, ref.name) if enabled and kind != "app-launcher" else None
|
||||||
|
running = is_running(pid)
|
||||||
|
ok, detail = health_ok(ref.config) if enabled else (None, "health skipped")
|
||||||
|
if not enabled:
|
||||||
|
label = "disabled"
|
||||||
|
elif kind == "app-launcher":
|
||||||
|
label = "launcher"
|
||||||
|
elif running and ok is not False:
|
||||||
|
label = "running"
|
||||||
|
elif running:
|
||||||
|
label = "unhealthy"
|
||||||
|
elif ok is True:
|
||||||
|
label = "externally running"
|
||||||
|
else:
|
||||||
|
label = "stopped"
|
||||||
|
return {
|
||||||
|
"name": ref.name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"kind": kind,
|
||||||
|
"status": label,
|
||||||
|
"pid": pid,
|
||||||
|
"command": command,
|
||||||
|
"health": {"ok": ok, "detail": detail},
|
||||||
|
"state": read_state(profile, ref.name),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_health(config: dict[str, Any], seconds: float = 8.0) -> tuple[bool | None, str]:
|
||||||
|
deadline = time.time() + seconds
|
||||||
|
last: tuple[bool | None, str] = (None, "no health check")
|
||||||
|
while time.time() <= deadline:
|
||||||
|
last = health_ok(config)
|
||||||
|
if last[0] is True or last[0] is None:
|
||||||
|
return last
|
||||||
|
time.sleep(0.4)
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], started: set[str]) -> None:
|
||||||
|
if ref.name in started:
|
||||||
|
return
|
||||||
|
for dependency in ref.config.get("depends_on") or []:
|
||||||
|
dep_config = (manifest.get("services") or {}).get(dependency)
|
||||||
|
if not dep_config or not dep_config.get("enabled", True):
|
||||||
|
raise SystemExit(f"{ref.name} depends on missing/disabled service: {dependency}")
|
||||||
|
start_service(profile, ServiceRef(dependency, dep_config), manifest, started)
|
||||||
|
|
||||||
|
kind = ref.config.get("kind", "process")
|
||||||
|
command = ref.config.get("command") or []
|
||||||
|
if not command:
|
||||||
|
raise SystemExit(f"{ref.name} has no command")
|
||||||
|
if not command_exists(str(command[0])):
|
||||||
|
raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}")
|
||||||
|
|
||||||
|
if kind != "app-launcher":
|
||||||
|
pid = read_pid(profile, ref.name)
|
||||||
|
if is_running(pid):
|
||||||
|
ok, detail = health_ok(ref.config)
|
||||||
|
status = "running" if ok is not False else "running unhealthy"
|
||||||
|
print(f"{ref.name}: {status} ({detail})")
|
||||||
|
started.add(ref.name)
|
||||||
|
return
|
||||||
|
ok, detail = health_ok(ref.config)
|
||||||
|
if ok is True:
|
||||||
|
print(f"{ref.name}: externally running ({detail}); not starting duplicate")
|
||||||
|
started.add(ref.name)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = log_path(profile, ref.name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rotate_log_if_needed(path, int(ref.config.get("log_max_bytes", DEFAULT_LOG_MAX_BYTES)), int(ref.config.get("log_backups", DEFAULT_LOG_BACKUPS)))
|
||||||
|
with path.open("ab") as log_file:
|
||||||
|
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
|
||||||
|
if kind == "app-launcher":
|
||||||
|
result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False)
|
||||||
|
write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()})
|
||||||
|
print(f"{ref.name}: launched (exit {result.returncode})")
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
|
||||||
|
pid_file = pid_path(profile, ref.name)
|
||||||
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_file.write_text(str(process.pid) + "\n", encoding="utf-8")
|
||||||
|
ok, detail = wait_for_health(ref.config)
|
||||||
|
state = "started" if ok is not False else "started but health check failed"
|
||||||
|
write_state(profile, ref.name, {"pid": process.pid, "started_at": time.time(), "health": detail})
|
||||||
|
print(f"{ref.name}: {state} pid={process.pid} ({detail})")
|
||||||
|
started.add(ref.name)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_service(profile: str, ref: ServiceRef) -> None:
|
||||||
|
kind = ref.config.get("kind", "process")
|
||||||
|
if kind == "app-launcher":
|
||||||
|
print(f"{ref.name}: launcher service has no managed process")
|
||||||
|
return
|
||||||
|
pid = read_pid(profile, ref.name)
|
||||||
|
if not is_running(pid):
|
||||||
|
ok, detail = health_ok(ref.config)
|
||||||
|
if ok is True:
|
||||||
|
print(f"{ref.name}: externally running ({detail}); no managed pid to stop")
|
||||||
|
return
|
||||||
|
print(f"{ref.name}: not running")
|
||||||
|
pid_path(profile, ref.name).unlink(missing_ok=True)
|
||||||
|
return
|
||||||
|
assert pid is not None
|
||||||
|
try:
|
||||||
|
os.killpg(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except PermissionError:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
deadline = time.time() + float(ref.config.get("stop_timeout_seconds", DEFAULT_STOP_TIMEOUT_SECONDS))
|
||||||
|
while time.time() < deadline and is_running(pid):
|
||||||
|
time.sleep(0.2)
|
||||||
|
if is_running(pid):
|
||||||
|
try:
|
||||||
|
os.killpg(pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
print(f"{ref.name}: killed pid={pid}")
|
||||||
|
else:
|
||||||
|
print(f"{ref.name}: stopped")
|
||||||
|
pid_path(profile, ref.name).unlink(missing_ok=True)
|
||||||
|
write_state(profile, ref.name, {"stopped_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def status_service(profile: str, ref: ServiceRef) -> None:
|
||||||
|
status = service_status(profile, ref)
|
||||||
|
if not status["enabled"]:
|
||||||
|
print(f"{ref.name}: disabled")
|
||||||
|
return
|
||||||
|
if status["kind"] == "app-launcher":
|
||||||
|
state = status["state"]
|
||||||
|
launched = state.get("launched_at")
|
||||||
|
suffix = f"last launched {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(launched))}" if launched else "not launched by manager"
|
||||||
|
print(f"{ref.name}: launcher ({suffix})")
|
||||||
|
return
|
||||||
|
print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})")
|
||||||
|
|
||||||
|
|
||||||
|
def status_report(profile: str, refs: list[ServiceRef]) -> dict[str, Any]:
|
||||||
|
"""Return lightweight machine-readable live service state."""
|
||||||
|
return {
|
||||||
|
"profile": profile,
|
||||||
|
"workspace": str(ROOT),
|
||||||
|
"runtime": str(RUNTIME_DIR),
|
||||||
|
"services": [service_status(profile, ref) for ref in refs],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tail_log(profile: str, service: str, lines: int) -> None:
|
||||||
|
path = log_path(profile, service)
|
||||||
|
if not path.is_file():
|
||||||
|
print(f"no log file: {path}")
|
||||||
|
return
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
for line in content[-lines:]:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
|
||||||
|
def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
errors = validate_manifest(manifest)
|
||||||
|
service_reports = []
|
||||||
|
for ref in service_items(manifest, include_disabled=True):
|
||||||
|
command = ref.config.get("command") or []
|
||||||
|
first = command[0] if command else ""
|
||||||
|
doctor = ref.config.get("doctor") or {}
|
||||||
|
checks = []
|
||||||
|
for command_name in doctor.get("required_commands") or []:
|
||||||
|
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)})
|
||||||
|
for command_name in doctor.get("optional_commands") or []:
|
||||||
|
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)})
|
||||||
|
for raw_path in doctor.get("required_paths") or []:
|
||||||
|
checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
|
||||||
|
for raw_path in doctor.get("optional_paths") or []:
|
||||||
|
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
|
||||||
|
status = service_status(profile, ref)
|
||||||
|
status["command_ok"] = command_exists(first) if first else False
|
||||||
|
status["checks"] = checks
|
||||||
|
service_reports.append(status)
|
||||||
|
return {
|
||||||
|
"profile": profile,
|
||||||
|
"workspace": str(ROOT),
|
||||||
|
"manifest": str(manifest_path(profile)),
|
||||||
|
"runtime": str(RUNTIME_DIR),
|
||||||
|
"manifest_ok": not errors,
|
||||||
|
"manifest_errors": errors,
|
||||||
|
"services": service_reports,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False) -> None:
|
||||||
|
report = doctor_report(profile, manifest)
|
||||||
|
if json_output:
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(f"AI Workspace doctor profile={profile}")
|
||||||
|
print(f"workspace: {ROOT}")
|
||||||
|
print(f"manifest: {manifest_path(profile)}")
|
||||||
|
ensure_runtime()
|
||||||
|
print(f"runtime: {RUNTIME_DIR}")
|
||||||
|
errors = report["manifest_errors"]
|
||||||
|
if errors:
|
||||||
|
print("manifest: invalid")
|
||||||
|
for error in errors:
|
||||||
|
print(f" ! {error}")
|
||||||
|
else:
|
||||||
|
print("manifest: ok")
|
||||||
|
for service in report["services"]:
|
||||||
|
enabled_text = "enabled" if service["enabled"] else "disabled"
|
||||||
|
if not service["enabled"]:
|
||||||
|
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; health skipped")
|
||||||
|
continue
|
||||||
|
health_text = service["health"]["detail"] if service["health"]["ok"] is not None else "no health check"
|
||||||
|
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; {health_text}")
|
||||||
|
for check in service["checks"]:
|
||||||
|
label = check["type"].replace("_", " ")
|
||||||
|
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
|
||||||
|
|
||||||
|
|
||||||
|
def shutil_which(command: str) -> str | None:
|
||||||
|
paths = os.environ.get("PATH", "").split(os.pathsep)
|
||||||
|
for directory in paths:
|
||||||
|
candidate = Path(directory) / command
|
||||||
|
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||||
|
return str(candidate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("action", choices=["start", "stop", "restart", "status", "logs", "doctor"])
|
||||||
|
parser.add_argument("services", nargs="*", help="Optional service names for start/stop/restart/status/logs.")
|
||||||
|
parser.add_argument("--profile", default=os.getenv("AIW_PROJECT_PROFILE", "fidelity"))
|
||||||
|
parser.add_argument("--group", default="", help="Start/stop/status services in a group, e.g. communication or inbox.")
|
||||||
|
parser.add_argument("--lines", type=int, default=80, help="Number of log lines for logs action.")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON for supported actions such as doctor.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ensure_runtime()
|
||||||
|
manifest = load_manifest(args.profile)
|
||||||
|
|
||||||
|
if args.action == "doctor":
|
||||||
|
run_doctor(args.profile, manifest, json_output=args.json)
|
||||||
|
return
|
||||||
|
|
||||||
|
errors = validate_manifest(manifest)
|
||||||
|
if errors:
|
||||||
|
raise SystemExit("invalid services manifest:\n" + "\n".join(f"- {error}" for error in errors))
|
||||||
|
|
||||||
|
include_disabled = args.action == "status"
|
||||||
|
refs = select_services(manifest, args.services, args.group or None, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
if args.action == "start":
|
||||||
|
started: set[str] = set()
|
||||||
|
for ref in refs:
|
||||||
|
start_service(args.profile, ref, manifest, started)
|
||||||
|
elif args.action == "stop":
|
||||||
|
for ref in reversed(refs):
|
||||||
|
stop_service(args.profile, ref)
|
||||||
|
elif args.action == "restart":
|
||||||
|
for ref in reversed(refs):
|
||||||
|
stop_service(args.profile, ref)
|
||||||
|
started = set()
|
||||||
|
for ref in refs:
|
||||||
|
start_service(args.profile, ref, manifest, started)
|
||||||
|
elif args.action == "status":
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(status_report(args.profile, refs), ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
for ref in refs:
|
||||||
|
status_service(args.profile, ref)
|
||||||
|
elif args.action == "logs":
|
||||||
|
if not args.services:
|
||||||
|
raise SystemExit("logs requires at least one service name")
|
||||||
|
for service in args.services:
|
||||||
|
print(f"==> {service} <==")
|
||||||
|
tail_log(args.profile, service, args.lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
197
scripts/aiw/test_services.py
Normal file
197
scripts/aiw/test_services.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SERVICES_PATH = Path(__file__).with_name("services.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("aiw_services", SERVICES_PATH)
|
||||||
|
services = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
sys.modules[SPEC.name] = services
|
||||||
|
SPEC.loader.exec_module(services)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_manifest() -> dict:
|
||||||
|
return {
|
||||||
|
"services": {
|
||||||
|
"alpha": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "process",
|
||||||
|
"command": ["python3", "-c", "import time; time.sleep(60)"],
|
||||||
|
"groups": ["core"],
|
||||||
|
},
|
||||||
|
"beta": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "process",
|
||||||
|
"command": ["python3", "-c", "import time; time.sleep(60)"],
|
||||||
|
"groups": ["capture"],
|
||||||
|
"depends_on": ["alpha"],
|
||||||
|
},
|
||||||
|
"disabled": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "process",
|
||||||
|
"command": ["python3", "-c", "import time; time.sleep(60)"],
|
||||||
|
"groups": ["core"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceManagerTests(unittest.TestCase):
|
||||||
|
def test_select_services_excludes_disabled_by_default(self) -> None:
|
||||||
|
selected = services.select_services(sample_manifest(), names=[], group=None)
|
||||||
|
|
||||||
|
self.assertEqual([item.name for item in selected], ["alpha", "beta"])
|
||||||
|
|
||||||
|
def test_select_services_can_include_disabled_for_status(self) -> None:
|
||||||
|
selected = services.select_services(sample_manifest(), names=[], group=None, include_disabled=True)
|
||||||
|
|
||||||
|
self.assertEqual([item.name for item in selected], ["alpha", "beta", "disabled"])
|
||||||
|
|
||||||
|
def test_select_services_filters_by_group(self) -> None:
|
||||||
|
selected = services.select_services(sample_manifest(), names=[], group="capture")
|
||||||
|
|
||||||
|
self.assertEqual([item.name for item in selected], ["beta"])
|
||||||
|
|
||||||
|
def test_health_ok_tcp_reports_open_port(self) -> None:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
|
||||||
|
server.bind(("127.0.0.1", 0))
|
||||||
|
server.listen(1)
|
||||||
|
port = server.getsockname()[1]
|
||||||
|
|
||||||
|
ok, detail = services.health_ok({"health": {"type": "tcp", "host": "127.0.0.1", "port": port}})
|
||||||
|
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertIn(f"127.0.0.1:{port}", detail)
|
||||||
|
|
||||||
|
def test_validate_manifest_reports_bad_dependencies(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"services": {
|
||||||
|
"bad": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "process",
|
||||||
|
"command": ["python3", "-c", "pass"],
|
||||||
|
"depends_on": ["missing"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = services.validate_manifest(manifest)
|
||||||
|
|
||||||
|
self.assertTrue(any("depends on unknown service" in error for error in errors))
|
||||||
|
|
||||||
|
def test_validate_manifest_reports_missing_command_for_enabled_service(self) -> None:
|
||||||
|
manifest = {"services": {"bad": {"enabled": True, "kind": "process"}}}
|
||||||
|
|
||||||
|
errors = services.validate_manifest(manifest)
|
||||||
|
|
||||||
|
self.assertTrue(any("non-empty command" in error for error in errors))
|
||||||
|
|
||||||
|
def test_command_exists_supports_relative_executable_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
script = root / "bin" / "tool.sh"
|
||||||
|
script.parent.mkdir(parents=True)
|
||||||
|
script.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||||
|
script.chmod(0o755)
|
||||||
|
|
||||||
|
with patch.object(services, "ROOT", root):
|
||||||
|
self.assertTrue(services.command_exists("bin/tool.sh"))
|
||||||
|
|
||||||
|
def test_rotate_log_if_needed_keeps_backups(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
log = Path(tmp) / "service.log"
|
||||||
|
log.write_text("old", encoding="utf-8")
|
||||||
|
|
||||||
|
services.rotate_log_if_needed(log, max_bytes=1, backups=2)
|
||||||
|
|
||||||
|
self.assertFalse(log.exists())
|
||||||
|
self.assertEqual(log.with_suffix(".log.1").read_text(encoding="utf-8"), "old")
|
||||||
|
|
||||||
|
def test_doctor_report_is_machine_readable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
with patch.object(services, "ROOT", root), \
|
||||||
|
patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \
|
||||||
|
patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \
|
||||||
|
patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \
|
||||||
|
patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"):
|
||||||
|
report = services.doctor_report("test", sample_manifest())
|
||||||
|
|
||||||
|
self.assertTrue(report["manifest_ok"])
|
||||||
|
self.assertEqual(report["services"][0]["name"], "alpha")
|
||||||
|
self.assertIn("health", report["services"][0])
|
||||||
|
|
||||||
|
def test_status_report_is_lightweight_machine_readable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
refs = services.service_items(sample_manifest(), include_disabled=True)
|
||||||
|
with patch.object(services, "ROOT", root), \
|
||||||
|
patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \
|
||||||
|
patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \
|
||||||
|
patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \
|
||||||
|
patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"):
|
||||||
|
report = services.status_report("test", refs)
|
||||||
|
|
||||||
|
self.assertEqual(report["profile"], "test")
|
||||||
|
self.assertEqual(len(report["services"]), 3)
|
||||||
|
self.assertIn("status", report["services"][0])
|
||||||
|
self.assertNotIn("checks", report["services"][0])
|
||||||
|
|
||||||
|
def test_read_pid_ignores_invalid_pid_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
pid_dir = Path(tmp) / "pids"
|
||||||
|
target = pid_dir / "fidelity"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
(target / "alpha.pid").write_text("not-a-pid\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(services, "PID_DIR", pid_dir):
|
||||||
|
self.assertIsNone(services.read_pid("fidelity", "alpha"))
|
||||||
|
|
||||||
|
def test_start_and_stop_managed_process(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"services": {
|
||||||
|
"sleeper": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "process",
|
||||||
|
"command": [sys.executable, "-c", "import time; time.sleep(60)"],
|
||||||
|
"restart": "never",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ref = services.ServiceRef("sleeper", manifest["services"]["sleeper"])
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
with patch.object(services, "PID_DIR", root / "pids"), \
|
||||||
|
patch.object(services, "LOG_DIR", root / "logs"), \
|
||||||
|
patch.object(services, "STATE_DIR", root / "state"):
|
||||||
|
started: set[str] = set()
|
||||||
|
services.ensure_runtime()
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", ResourceWarning)
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
services.start_service("test", ref, manifest, started)
|
||||||
|
|
||||||
|
pid = services.read_pid("test", "sleeper")
|
||||||
|
self.assertTrue(services.is_running(pid))
|
||||||
|
self.assertIn("sleeper", started)
|
||||||
|
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
services.stop_service("test", ref)
|
||||||
|
self.assertFalse(services.is_running(pid))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
68
scripts/mcp/aiw-context-mcp/README.md
Normal file
68
scripts/mcp/aiw-context-mcp/README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# AIW Context MCP
|
||||||
|
|
||||||
|
Read-only local Model Context Protocol server for AI Workspace context.
|
||||||
|
|
||||||
|
The server exposes bounded local evidence and canonical Markdown context to MCP clients. It does not capture traffic, send messages, mutate files, or promote memory.
|
||||||
|
|
||||||
|
## HTTP transport
|
||||||
|
|
||||||
|
The service manager starts the HTTP transport by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aiw/services.py start aiw-context-mcp --profile fidelity
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Health:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## stdio transport
|
||||||
|
|
||||||
|
For clients that require stdio, launch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- `context_profiles`
|
||||||
|
- `communication_latest`
|
||||||
|
- `communication_date_context`
|
||||||
|
- `communication_standup_context`
|
||||||
|
- `communication_channel_context`
|
||||||
|
- `communication_thread_context`
|
||||||
|
- `project_current_context`
|
||||||
|
- `project_search_memory`
|
||||||
|
- `photos_latest`
|
||||||
|
|
||||||
|
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
|
||||||
|
|
||||||
|
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
The server also exposes profile resources such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aiw://profiles/fidelity/current-work
|
||||||
|
aiw://profiles/fidelity/work-items
|
||||||
|
aiw://profiles/fidelity/mattermost/latest
|
||||||
|
aiw://profiles/fidelity/photos/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
See `client-configs.md` for client setup snippets and verification prompts.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/mcp/aiw-context-mcp/test_server.py
|
||||||
|
```
|
||||||
69
scripts/mcp/aiw-context-mcp/client-configs.md
Normal file
69
scripts/mcp/aiw-context-mcp/client-configs.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# AIW Context MCP Client Config Notes
|
||||||
|
|
||||||
|
The AIW Context MCP server supports both local HTTP and stdio.
|
||||||
|
|
||||||
|
## Preferred HTTP endpoint
|
||||||
|
|
||||||
|
When the Service Manager is running:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Health check:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for clients that support MCP Streamable HTTP/local HTTP servers.
|
||||||
|
|
||||||
|
## stdio command
|
||||||
|
|
||||||
|
Use this for clients that launch MCP servers as subprocesses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"aiw-context-mcp": {
|
||||||
|
"command": "python3",
|
||||||
|
"args": [
|
||||||
|
"/Users/david/Developer/fidelity-ai-workspace/scripts/mcp/aiw-context-mcp/server.py",
|
||||||
|
"--transport",
|
||||||
|
"stdio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Antigravity verification prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use the AI Workspace MCP server. List available tools and resources. Then read the resource aiw://profiles/fidelity/current-work and call communication_latest with {"profile":"fidelity","limit":100}. Report the channel_scope and unique returned channel names.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Codex/OpenAI-style verification prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
Check whether the AIW Context MCP server is available. If available, list its tools/resources, then use project_current_context for profile fidelity. State which MCP calls were used. If unavailable, say so explicitly.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected tool names
|
||||||
|
|
||||||
|
- `context_profiles`
|
||||||
|
- `communication_latest`
|
||||||
|
- `communication_date_context`
|
||||||
|
- `communication_standup_context`
|
||||||
|
- `communication_channel_context`
|
||||||
|
- `communication_thread_context`
|
||||||
|
- `project_current_context`
|
||||||
|
- `project_search_memory`
|
||||||
|
- `photos_latest`
|
||||||
|
|
||||||
|
## Expected resource URIs
|
||||||
|
|
||||||
|
- `aiw://profiles/fidelity/current-work`
|
||||||
|
- `aiw://profiles/fidelity/work-items`
|
||||||
|
- `aiw://profiles/fidelity/mattermost/latest`
|
||||||
|
- `aiw://profiles/fidelity/photos/latest`
|
||||||
541
scripts/mcp/aiw-context-mcp/server.py
Normal file
541
scripts/mcp/aiw-context-mcp/server.py
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only AI Workspace context MCP server.
|
||||||
|
|
||||||
|
This server intentionally exposes bounded local evidence and canonical memory. It
|
||||||
|
does not capture traffic, send messages, or promote memory. Capture lifecycle is
|
||||||
|
owned by the AI Workspace Service Manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
PROTOCOL_VERSION = "2025-06-18"
|
||||||
|
SERVER_NAME = "aiw-context-mcp"
|
||||||
|
SERVER_VERSION = "0.1.0"
|
||||||
|
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[len("export ") :].strip()
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("'\"")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def profile_dir(profile: str) -> Path:
|
||||||
|
if profile == "fidelity":
|
||||||
|
return ROOT
|
||||||
|
candidate = ROOT / "profiles" / profile
|
||||||
|
return candidate if candidate.exists() else ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def knowledge_dir(profile: str) -> Path:
|
||||||
|
base = profile_dir(profile)
|
||||||
|
candidate = base / "project-knowledge"
|
||||||
|
return candidate if candidate.exists() else ROOT / "project-knowledge"
|
||||||
|
|
||||||
|
|
||||||
|
def inbox_dir(profile: str) -> Path:
|
||||||
|
base = profile_dir(profile)
|
||||||
|
candidate = base / "ai" / "inbox"
|
||||||
|
return candidate if candidate.exists() else ROOT / "ai" / "inbox"
|
||||||
|
|
||||||
|
|
||||||
|
def mattermost_mirror_dir(profile: str) -> Path:
|
||||||
|
configured = os.getenv("MATTERMOST_MIRROR_DIR", "").strip()
|
||||||
|
if configured:
|
||||||
|
path = Path(configured).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
return inbox_dir(profile) / "mattermost-mirror"
|
||||||
|
|
||||||
|
|
||||||
|
def profile_context_channels(profile: str, source: str = "mattermost") -> set[str]:
|
||||||
|
path = ROOT / "profiles" / profile / "context-sources.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
config = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return set()
|
||||||
|
channels = (((config.get("communication_sources") or {}).get(source) or {}).get("context_channels") or [])
|
||||||
|
return {str(item).strip().lower() for item in channels if str(item).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def photo_inbox_dir(profile: str) -> Path:
|
||||||
|
configured = os.getenv("PHOTO_INBOX_DIR", "").strip()
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser()
|
||||||
|
linked = inbox_dir(profile) / "photos"
|
||||||
|
if linked.exists():
|
||||||
|
return linked
|
||||||
|
return Path.home() / "Pictures" / "Photo Inbox"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_channels(raw: str | None, profile: str | None = None) -> set[str]:
|
||||||
|
if not raw:
|
||||||
|
raw = os.getenv("AIW_MATTERMOST_CONTEXT_CHANNELS", "") or os.getenv("AIW_MATTERMOST_PROJECT_CHANNELS", "")
|
||||||
|
channels = {item.strip().lower() for item in (raw or "").split(",") if item.strip()}
|
||||||
|
if channels:
|
||||||
|
return channels
|
||||||
|
return profile_context_channels(profile or "fidelity") if profile else set()
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_all_channels(args: dict[str, Any]) -> bool:
|
||||||
|
return bool(args.get("include_all_channels") or args.get("all_channels"))
|
||||||
|
|
||||||
|
|
||||||
|
def previous_workday(today: date) -> date:
|
||||||
|
day = today - timedelta(days=1)
|
||||||
|
while day.weekday() >= 5:
|
||||||
|
day -= timedelta(days=1)
|
||||||
|
return day
|
||||||
|
|
||||||
|
|
||||||
|
def read_jsonl(path: Path, limit: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
records.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if limit and limit > 0:
|
||||||
|
return records[-limit:]
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def filter_channels(records: list[dict[str, Any]], channels: set[str]) -> list[dict[str, Any]]:
|
||||||
|
if not channels:
|
||||||
|
return records
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in records
|
||||||
|
if str(item.get("channel_name", "")).lower() in channels
|
||||||
|
or str(item.get("channel_id", "")).lower() in channels
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def daily_by_date_path(profile: str, day: date) -> Path:
|
||||||
|
return mattermost_mirror_dir(profile) / "by-date" / f"{day:%Y}" / f"{day:%m}" / f"{day:%Y-%m-%d}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def daily_channel_path(profile: str, channel: str, day: date) -> Path:
|
||||||
|
return mattermost_mirror_dir(profile) / "channels" / channel / f"{day:%Y}" / f"{day:%m}" / f"{day:%Y-%m-%d}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def tool_result(data: Any, text_prefix: str | None = None) -> dict[str, Any]:
|
||||||
|
text = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
if text_prefix:
|
||||||
|
text = f"{text_prefix}\n{text}"
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"structuredContent": data,
|
||||||
|
"isError": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_error(message: str, data: Any | None = None) -> dict[str, Any]:
|
||||||
|
payload = {"error": message, "data": data}
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)}],
|
||||||
|
"structuredContent": payload,
|
||||||
|
"isError": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def as_date(raw: str | None) -> date:
|
||||||
|
return datetime.strptime(raw, "%Y-%m-%d").date() if raw else date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_limit(value: Any, default: int = 80, maximum: int = 300) -> int:
|
||||||
|
try:
|
||||||
|
limit = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = default
|
||||||
|
if limit <= 0:
|
||||||
|
return default
|
||||||
|
return min(limit, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles(_: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profiles = sorted(path.name for path in (ROOT / "profiles").iterdir() if (path / "profile.md").is_file())
|
||||||
|
return tool_result({"profiles": profiles, "active_default": os.getenv("AIW_PROJECT_PROFILE", "fidelity")})
|
||||||
|
|
||||||
|
|
||||||
|
def communication_latest(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
limit = clamp_limit(args.get("limit"), default=50)
|
||||||
|
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
|
||||||
|
records = filter_channels(read_jsonl(mattermost_mirror_dir(profile) / "latest.jsonl", limit=None), channels)[-limit:]
|
||||||
|
return tool_result({"profile": profile, "source": "mattermost", "evidence_type": "communication", "canonical": False, "channel_scope": "all" if not channels else "profile", "channels": sorted(channels), "records": records})
|
||||||
|
|
||||||
|
|
||||||
|
def communication_date_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
day = as_date(args.get("date"))
|
||||||
|
limit = clamp_limit(args.get("limit"), default=100)
|
||||||
|
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
|
||||||
|
records = filter_channels(read_jsonl(daily_by_date_path(profile, day), limit=None), channels)[-limit:]
|
||||||
|
return tool_result({"profile": profile, "source": "mattermost", "date": day.isoformat(), "channels": sorted(channels), "records": records, "canonical": False})
|
||||||
|
|
||||||
|
|
||||||
|
def communication_standup_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
today = as_date(args.get("today") or args.get("date"))
|
||||||
|
previous = previous_workday(today)
|
||||||
|
limit = clamp_limit(args.get("limit"), default=80)
|
||||||
|
channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile)
|
||||||
|
previous_records = filter_channels(read_jsonl(daily_by_date_path(profile, previous)), channels)[-limit:]
|
||||||
|
today_records = filter_channels(read_jsonl(daily_by_date_path(profile, today)), channels)[-limit:]
|
||||||
|
return tool_result({
|
||||||
|
"profile": profile,
|
||||||
|
"source": "mattermost",
|
||||||
|
"mode": "standup",
|
||||||
|
"canonical": False,
|
||||||
|
"channels": sorted(channels),
|
||||||
|
"previous_workday": previous.isoformat(),
|
||||||
|
"today": today.isoformat(),
|
||||||
|
"previous_records": previous_records,
|
||||||
|
"today_records": today_records,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def communication_channel_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
channel = str(args.get("channel") or "").strip()
|
||||||
|
if not channel:
|
||||||
|
return tool_error("channel is required")
|
||||||
|
day = as_date(args.get("date"))
|
||||||
|
limit = clamp_limit(args.get("limit"), default=100)
|
||||||
|
records = read_jsonl(daily_channel_path(profile, channel, day), limit=limit)
|
||||||
|
return tool_result({"profile": profile, "source": "mattermost", "channel": channel, "date": day.isoformat(), "records": records, "canonical": False})
|
||||||
|
|
||||||
|
|
||||||
|
def communication_thread_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
thread_id = str(args.get("thread_id") or "").strip()
|
||||||
|
if not thread_id:
|
||||||
|
return tool_error("thread_id is required")
|
||||||
|
limit = clamp_limit(args.get("limit"), default=100)
|
||||||
|
path = mattermost_mirror_dir(profile) / "threads" / f"{thread_id}.jsonl"
|
||||||
|
records = read_jsonl(path, limit=limit)
|
||||||
|
return tool_result({"profile": profile, "source": "mattermost", "thread_id": thread_id, "records": records, "canonical": False})
|
||||||
|
|
||||||
|
|
||||||
|
def project_current_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
base = knowledge_dir(profile)
|
||||||
|
files = [base / "01-current" / "current-work.md", base / "01-current" / "work-items.md"]
|
||||||
|
result = []
|
||||||
|
for path in files:
|
||||||
|
if path.is_file():
|
||||||
|
result.append({"path": str(path.relative_to(ROOT)), "text": path.read_text(encoding="utf-8", errors="replace")})
|
||||||
|
return tool_result({"profile": profile, "canonical": True, "files": result})
|
||||||
|
|
||||||
|
|
||||||
|
def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
query = str(args.get("query") or "").strip().lower()
|
||||||
|
if not query:
|
||||||
|
return tool_error("query is required")
|
||||||
|
limit = clamp_limit(args.get("limit"), default=10, maximum=50)
|
||||||
|
base = knowledge_dir(profile)
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for path in sorted(base.rglob("*.md")):
|
||||||
|
rel = path.relative_to(base)
|
||||||
|
if str(rel).startswith("09-templates/"):
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
lowered = text.lower()
|
||||||
|
index = lowered.find(query)
|
||||||
|
if index < 0:
|
||||||
|
continue
|
||||||
|
start = max(0, index - 220)
|
||||||
|
end = min(len(text), index + len(query) + 220)
|
||||||
|
matches.append({"path": str(path.relative_to(ROOT)), "snippet": text[start:end].strip()})
|
||||||
|
if len(matches) >= limit:
|
||||||
|
break
|
||||||
|
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
||||||
|
|
||||||
|
|
||||||
|
def photos_latest(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = str(args.get("profile") or "fidelity")
|
||||||
|
limit = clamp_limit(args.get("limit"), default=20, maximum=100)
|
||||||
|
base = photo_inbox_dir(profile)
|
||||||
|
photos = []
|
||||||
|
if base.exists():
|
||||||
|
candidates = [path for path in base.iterdir() if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".heic"}]
|
||||||
|
for path in sorted(candidates, key=lambda item: item.stat().st_mtime)[-limit:]:
|
||||||
|
photos.append({"path": str(path), "modified_at": datetime.fromtimestamp(path.stat().st_mtime).isoformat(), "bytes": path.stat().st_size})
|
||||||
|
return tool_result({"profile": profile, "source": "photo-inbox", "canonical": False, "photos": photos})
|
||||||
|
|
||||||
|
|
||||||
|
def resource_definitions() -> list[dict[str, Any]]:
|
||||||
|
resources: list[dict[str, Any]] = []
|
||||||
|
for profile in sorted(path.name for path in (ROOT / "profiles").iterdir() if (path / "profile.md").is_file()):
|
||||||
|
resources.extend([
|
||||||
|
{
|
||||||
|
"uri": f"aiw://profiles/{profile}/current-work",
|
||||||
|
"name": f"{profile}-current-work",
|
||||||
|
"title": f"{profile} Current Work",
|
||||||
|
"description": "Canonical current work context.",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"annotations": {"audience": ["assistant"], "priority": 0.95},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": f"aiw://profiles/{profile}/work-items",
|
||||||
|
"name": f"{profile}-work-items",
|
||||||
|
"title": f"{profile} Active Work Items",
|
||||||
|
"description": "Canonical active work-item summary.",
|
||||||
|
"mimeType": "text/markdown",
|
||||||
|
"annotations": {"audience": ["assistant"], "priority": 0.9},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": f"aiw://profiles/{profile}/mattermost/latest",
|
||||||
|
"name": f"{profile}-mattermost-latest",
|
||||||
|
"title": f"{profile} Mattermost Latest",
|
||||||
|
"description": "Profile-filtered latest Mattermost mirror evidence as JSON.",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
"annotations": {"audience": ["assistant"], "priority": 0.75},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": f"aiw://profiles/{profile}/photos/latest",
|
||||||
|
"name": f"{profile}-photos-latest",
|
||||||
|
"title": f"{profile} Photo Inbox Latest",
|
||||||
|
"description": "Latest local Photo Inbox file metadata as JSON; image data is not embedded.",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
"annotations": {"audience": ["assistant"], "priority": 0.45},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return resources
|
||||||
|
|
||||||
|
|
||||||
|
def read_resource(uri: str) -> dict[str, Any] | None:
|
||||||
|
parsed = urllib.parse.urlparse(uri)
|
||||||
|
if parsed.scheme != "aiw":
|
||||||
|
return None
|
||||||
|
parts = [part for part in parsed.path.split("/") if part]
|
||||||
|
if parsed.netloc != "profiles" or len(parts) < 2:
|
||||||
|
return None
|
||||||
|
profile = parts[0]
|
||||||
|
resource = "/".join(parts[1:])
|
||||||
|
base = knowledge_dir(profile)
|
||||||
|
if resource == "current-work":
|
||||||
|
path = base / "01-current" / "current-work.md"
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")}
|
||||||
|
if resource == "work-items":
|
||||||
|
path = base / "01-current" / "work-items.md"
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")}
|
||||||
|
if resource == "mattermost/latest":
|
||||||
|
data = communication_latest({"profile": profile, "limit": 80})["structuredContent"]
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)}
|
||||||
|
if resource == "photos/latest":
|
||||||
|
data = photos_latest({"profile": profile, "limit": 30})["structuredContent"]
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS: dict[str, dict[str, Any]] = {
|
||||||
|
"context_profiles": {"handler": list_profiles, "description": "List AI Workspace project profiles.", "properties": {}},
|
||||||
|
"communication_latest": {"handler": communication_latest, "description": "Read bounded latest Mattermost mirror evidence, filtered to the profile's context channels by default.", "properties": {"profile": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
|
||||||
|
"communication_date_context": {"handler": communication_date_context, "description": "Read Mattermost mirror evidence for one date, filtered to profile context channels by default unless include_all_channels is true.", "properties": {"profile": {"type": "string"}, "date": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
|
||||||
|
"communication_standup_context": {"handler": communication_standup_context, "description": "Read previous-workday and today Mattermost evidence for standup drafting, filtered to profile context channels by default.", "properties": {"profile": {"type": "string"}, "today": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}},
|
||||||
|
"communication_channel_context": {"handler": communication_channel_context, "description": "Read Mattermost mirror evidence for a channel and date.", "properties": {"profile": {"type": "string"}, "channel": {"type": "string"}, "date": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
|
"communication_thread_context": {"handler": communication_thread_context, "description": "Read Mattermost mirror evidence for a thread id.", "properties": {"profile": {"type": "string"}, "thread_id": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
|
"project_current_context": {"handler": project_current_context, "description": "Read canonical current-work and work-items context.", "properties": {"profile": {"type": "string"}}},
|
||||||
|
"project_search_memory": {"handler": project_search_memory, "description": "Search canonical project-knowledge Markdown files.", "properties": {"profile": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
|
"photos_latest": {"handler": photos_latest, "description": "List recent local Photo Inbox files without embedding image data.", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_definitions() -> list[dict[str, Any]]:
|
||||||
|
definitions = []
|
||||||
|
for name, item in TOOLS.items():
|
||||||
|
definitions.append({
|
||||||
|
"name": name,
|
||||||
|
"title": name.replace("_", " ").title(),
|
||||||
|
"description": item["description"],
|
||||||
|
"inputSchema": {"type": "object", "properties": item["properties"], "additionalProperties": False},
|
||||||
|
"annotations": {"readOnlyHint": True, "destructiveHint": False, "openWorldHint": False},
|
||||||
|
})
|
||||||
|
return definitions
|
||||||
|
|
||||||
|
|
||||||
|
def jsonrpc_response(request_id: Any, result: Any) -> dict[str, Any]:
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
def jsonrpc_error(request_id: Any, code: int, message: str, data: Any | None = None) -> dict[str, Any]:
|
||||||
|
error: dict[str, Any] = {"code": code, "message": message}
|
||||||
|
if data is not None:
|
||||||
|
error["data"] = data
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(message: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
method = message.get("method")
|
||||||
|
request_id = message.get("id")
|
||||||
|
params = message.get("params") or {}
|
||||||
|
if request_id is None:
|
||||||
|
return None
|
||||||
|
if method == "initialize":
|
||||||
|
requested = str(params.get("protocolVersion") or PROTOCOL_VERSION)
|
||||||
|
return jsonrpc_response(request_id, {
|
||||||
|
"protocolVersion": requested if requested in {PROTOCOL_VERSION, "2025-03-26"} else PROTOCOL_VERSION,
|
||||||
|
"capabilities": {"tools": {"listChanged": False}, "resources": {"listChanged": False}},
|
||||||
|
"serverInfo": {"name": SERVER_NAME, "title": "AI Workspace Context MCP", "version": SERVER_VERSION},
|
||||||
|
"instructions": "Read-only local AI Workspace context. Evidence is not canonical memory unless promoted by the agent/user.",
|
||||||
|
})
|
||||||
|
if method == "ping":
|
||||||
|
return jsonrpc_response(request_id, {})
|
||||||
|
if method == "tools/list":
|
||||||
|
return jsonrpc_response(request_id, {"tools": tool_definitions()})
|
||||||
|
if method == "resources/list":
|
||||||
|
return jsonrpc_response(request_id, {"resources": resource_definitions()})
|
||||||
|
if method == "resources/read":
|
||||||
|
uri = str(params.get("uri") or "")
|
||||||
|
content = read_resource(uri)
|
||||||
|
if content is None:
|
||||||
|
return jsonrpc_error(request_id, -32002, "Resource not found", {"uri": uri})
|
||||||
|
return jsonrpc_response(request_id, {"contents": [content]})
|
||||||
|
if method == "resources/templates/list":
|
||||||
|
return jsonrpc_response(request_id, {"resourceTemplates": []})
|
||||||
|
if method == "tools/call":
|
||||||
|
name = str(params.get("name") or "")
|
||||||
|
arguments = params.get("arguments") or {}
|
||||||
|
tool = TOOLS.get(name)
|
||||||
|
if tool is None:
|
||||||
|
return jsonrpc_error(request_id, -32602, f"Unknown tool: {name}")
|
||||||
|
try:
|
||||||
|
return jsonrpc_response(request_id, tool["handler"](arguments))
|
||||||
|
except Exception as error: # Keep protocol alive; report tool failure.
|
||||||
|
return jsonrpc_response(request_id, tool_error(str(error)))
|
||||||
|
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
|
||||||
|
|
||||||
|
|
||||||
|
class MCPHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "AIWContextMCP/0.1"
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health":
|
||||||
|
self.send_json(HTTPStatus.OK, {"status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION})
|
||||||
|
return
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path == "/mcp":
|
||||||
|
self.send_error(HTTPStatus.METHOD_NOT_ALLOWED, "SSE GET stream is not implemented")
|
||||||
|
return
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path != "/mcp":
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
if not self.origin_allowed():
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN, "origin not allowed")
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if length <= 0 or length > 2_000_000:
|
||||||
|
self.send_error(HTTPStatus.BAD_REQUEST, "invalid body length")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
message = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST, jsonrpc_error(None, -32700, "Parse error"))
|
||||||
|
return
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
self.send_json(HTTPStatus.BAD_REQUEST, jsonrpc_error(None, -32600, "Invalid Request"))
|
||||||
|
return
|
||||||
|
response = handle_request(message)
|
||||||
|
if response is None:
|
||||||
|
self.send_response(HTTPStatus.ACCEPTED)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self.send_json(HTTPStatus.OK, response, mcp=True)
|
||||||
|
|
||||||
|
def origin_allowed(self) -> bool:
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if not origin:
|
||||||
|
return True
|
||||||
|
parsed = urllib.parse.urlparse(origin)
|
||||||
|
return parsed.hostname in {"127.0.0.1", "localhost", "::1"}
|
||||||
|
|
||||||
|
def send_json(self, status: HTTPStatus, payload: dict[str, Any], mcp: bool = False) -> None:
|
||||||
|
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
if mcp:
|
||||||
|
self.send_header("MCP-Protocol-Version", PROTOCOL_VERSION)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
print(f"{self.address_string()} - {format % args}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_http(host: str, port: int) -> None:
|
||||||
|
load_local_env()
|
||||||
|
server = ThreadingHTTPServer((host, port), MCPHandler)
|
||||||
|
print(f"{SERVER_NAME} listening on http://{host}:{port}/mcp", file=sys.stderr, flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def run_stdio() -> None:
|
||||||
|
load_local_env()
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
response = handle_request(message)
|
||||||
|
except Exception as error:
|
||||||
|
response = jsonrpc_error(None, -32603, str(error))
|
||||||
|
if response is not None:
|
||||||
|
print(json.dumps(response, ensure_ascii=False, separators=(",", ":")), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--transport", choices=["http", "stdio"], default="http")
|
||||||
|
parser.add_argument("--host", default=os.getenv("AIW_CONTEXT_MCP_HOST", "127.0.0.1"))
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.getenv("AIW_CONTEXT_MCP_PORT", "8765")))
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.transport == "stdio":
|
||||||
|
run_stdio()
|
||||||
|
else:
|
||||||
|
run_http(args.host, args.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
168
scripts/mcp/aiw-context-mcp/test_server.py
Normal file
168
scripts/mcp/aiw-context-mcp/test_server.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_PATH = Path(__file__).with_name("server.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("aiw_context_mcp", SERVER_PATH)
|
||||||
|
server = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
sys.modules[SPEC.name] = server
|
||||||
|
SPEC.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextMCPTests(unittest.TestCase):
|
||||||
|
def test_initialize_response_declares_tools(self) -> None:
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": server.PROTOCOL_VERSION}})
|
||||||
|
|
||||||
|
self.assertEqual(response["result"]["protocolVersion"], server.PROTOCOL_VERSION)
|
||||||
|
self.assertIn("tools", response["result"]["capabilities"])
|
||||||
|
|
||||||
|
def test_tools_list_includes_project_search(self) -> None:
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||||
|
|
||||||
|
names = {tool["name"] for tool in response["result"]["tools"]}
|
||||||
|
self.assertIn("project_search_memory", names)
|
||||||
|
self.assertIn("communication_latest", names)
|
||||||
|
|
||||||
|
def test_initialize_response_declares_resources(self) -> None:
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": server.PROTOCOL_VERSION}})
|
||||||
|
|
||||||
|
self.assertIn("resources", response["result"]["capabilities"])
|
||||||
|
|
||||||
|
def test_resources_list_includes_current_work(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
profile = root / "profiles" / "fidelity" / "profile.md"
|
||||||
|
profile.parent.mkdir(parents=True)
|
||||||
|
profile.write_text("# Fidelity", encoding="utf-8")
|
||||||
|
with patch.object(server, "ROOT", root):
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/list"})
|
||||||
|
|
||||||
|
uris = {item["uri"] for item in response["result"]["resources"]}
|
||||||
|
self.assertIn("aiw://profiles/fidelity/current-work", uris)
|
||||||
|
|
||||||
|
def test_resources_read_current_work(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
profile = root / "profiles" / "fidelity" / "profile.md"
|
||||||
|
current = root / "project-knowledge" / "01-current" / "current-work.md"
|
||||||
|
profile.parent.mkdir(parents=True)
|
||||||
|
current.parent.mkdir(parents=True)
|
||||||
|
profile.write_text("# Fidelity", encoding="utf-8")
|
||||||
|
current.write_text("# Current\nImportant", encoding="utf-8")
|
||||||
|
with patch.object(server, "ROOT", root):
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "aiw://profiles/fidelity/current-work"}})
|
||||||
|
|
||||||
|
self.assertEqual(response["result"]["contents"][0]["text"], "# Current\nImportant")
|
||||||
|
|
||||||
|
def test_unknown_tool_returns_protocol_error(self) -> None:
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "missing", "arguments": {}}})
|
||||||
|
|
||||||
|
self.assertEqual(response["error"]["code"], -32602)
|
||||||
|
|
||||||
|
def test_notification_returns_none(self) -> None:
|
||||||
|
response = server.handle_request({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||||
|
|
||||||
|
self.assertIsNone(response)
|
||||||
|
|
||||||
|
def test_communication_latest_reads_bounded_records(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
|
||||||
|
latest.parent.mkdir(parents=True)
|
||||||
|
for index in range(3):
|
||||||
|
latest.write_text("", encoding="utf-8") if index == 0 else None
|
||||||
|
latest.write_text(
|
||||||
|
"\n".join(json.dumps({"post_id": str(index), "message": f"m{index}"}) for index in range(3)) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root):
|
||||||
|
result = server.communication_latest({"profile": "fidelity", "limit": 2})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertEqual([item["message"] for item in result["records"]], ["m1", "m2"])
|
||||||
|
|
||||||
|
def test_communication_latest_filters_to_profile_channels_by_default(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
|
||||||
|
profile_config = root / "profiles" / "fidelity" / "context-sources.json"
|
||||||
|
latest.parent.mkdir(parents=True)
|
||||||
|
profile_config.parent.mkdir(parents=True)
|
||||||
|
profile_config.write_text(json.dumps({
|
||||||
|
"communication_sources": {
|
||||||
|
"mattermost": {"context_channels": ["fidelity-code-review", "dm-david--jeff"]}
|
||||||
|
}
|
||||||
|
}), encoding="utf-8")
|
||||||
|
latest.write_text(
|
||||||
|
"\n".join([
|
||||||
|
json.dumps({"post_id": "1", "channel_name": "design-team", "message": "ignore"}),
|
||||||
|
json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}),
|
||||||
|
]) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False):
|
||||||
|
result = server.communication_latest({"profile": "fidelity", "limit": 10})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertEqual([item["message"] for item in result["records"]], ["keep"])
|
||||||
|
self.assertEqual(result["channel_scope"], "profile")
|
||||||
|
|
||||||
|
def test_communication_latest_can_include_all_channels(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl"
|
||||||
|
profile_config = root / "profiles" / "fidelity" / "context-sources.json"
|
||||||
|
latest.parent.mkdir(parents=True)
|
||||||
|
profile_config.parent.mkdir(parents=True)
|
||||||
|
profile_config.write_text(json.dumps({
|
||||||
|
"communication_sources": {"mattermost": {"context_channels": ["fidelity-code-review"]}}
|
||||||
|
}), encoding="utf-8")
|
||||||
|
latest.write_text(
|
||||||
|
"\n".join([
|
||||||
|
json.dumps({"post_id": "1", "channel_name": "design-team", "message": "include"}),
|
||||||
|
json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}),
|
||||||
|
]) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False):
|
||||||
|
result = server.communication_latest({"profile": "fidelity", "include_all_channels": True, "limit": 10})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertEqual([item["message"] for item in result["records"]], ["include", "keep"])
|
||||||
|
self.assertEqual(result["channel_scope"], "all")
|
||||||
|
|
||||||
|
def test_project_search_skips_templates(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
real = root / "project-knowledge" / "03-context" / "project.md"
|
||||||
|
template = root / "project-knowledge" / "09-templates" / "daily.md"
|
||||||
|
real.parent.mkdir(parents=True)
|
||||||
|
template.parent.mkdir(parents=True)
|
||||||
|
real.write_text("Important XFlow context", encoding="utf-8")
|
||||||
|
template.write_text("Important XFlow template", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root):
|
||||||
|
result = server.project_search_memory({"profile": "fidelity", "query": "XFlow"})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertEqual(len(result["matches"]), 1)
|
||||||
|
self.assertIn("03-context/project.md", result["matches"][0]["path"])
|
||||||
|
|
||||||
|
def test_previous_workday_skips_weekend(self) -> None:
|
||||||
|
monday = date(2026, 5, 18)
|
||||||
|
|
||||||
|
self.assertEqual(server.previous_workday(monday), date(2026, 5, 15))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user