f7c23fb325
Add ts-worker/ with the Bun/TypeScript worker that replaces claw-profile-worker. The Dockerfile now builds a single image containing both the Rust gateway (claw-telegram) and the TS worker. The image defaults to worker mode (bun run ts-worker/main.ts). The gateway Unraid XML overrides with --entrypoint claw-telegram. Worker containers use the same image with the default CMD. - Add ts-worker/ (12 files): HTTP/SSE server, Anthropic SDK engine, approval broker, event translator, state stores - Add package.json with @anthropic-ai/sdk dependency - Rewrite Dockerfile: three-stage build (Rust + Bun + runtime) - Revert CLAW_GATEWAY_WORKER_IMAGE to claw-telegram:latest - Remove image pull from docker_worker_manager (same image, already local) - Add ts-worker paths to CI trigger Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
9.2 KiB
TypeScript
335 lines
9.2 KiB
TypeScript
/**
|
|
* AppLibraryStore — git-backed app library.
|
|
*
|
|
* Each app is a git repo under {stateRoot}/claw/apps/{app_id}/.
|
|
* The manifest and bundle are stored in the repo root.
|
|
*
|
|
* Operations:
|
|
* - packageFromFeedItem: create or update an app from a feed item
|
|
* - openWorkspace: return the worktree state for editing
|
|
* - publish: commit + snapshot as published version
|
|
* - archive: mark app as archived
|
|
* - launch: bump last_launched_at
|
|
* - history: list git commits
|
|
*/
|
|
|
|
import { randomUUID } from 'crypto'
|
|
import { readFile, writeFile, mkdir, readdir, stat, access } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { execSync } from 'child_process'
|
|
import type {
|
|
LibraryAppRecord,
|
|
LibraryAppManifestV2,
|
|
AppBundleManifestV1,
|
|
LibraryAppVersionRecord,
|
|
AppWorkspaceRecord,
|
|
AppHistoryEntry,
|
|
AppHistoryResponse,
|
|
AppPackageRequest,
|
|
AppPackageResult,
|
|
AppPublishResult,
|
|
AppKind,
|
|
} from './records.js'
|
|
|
|
export class AppLibraryStore {
|
|
private apps = new Map<string, LibraryAppRecord>()
|
|
private stateRoot: string
|
|
private appsDir: string
|
|
|
|
constructor(stateRoot: string) {
|
|
this.stateRoot = stateRoot
|
|
this.appsDir = join(stateRoot, 'claw', 'apps')
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
await mkdir(this.appsDir, { recursive: true })
|
|
// Scan existing app directories
|
|
try {
|
|
const entries = await readdir(this.appsDir, { withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
try {
|
|
const manifestPath = join(this.appsDir, entry.name, 'manifest.json')
|
|
const data = await readFile(manifestPath, 'utf8')
|
|
const manifest = JSON.parse(data) as LibraryAppManifestV2
|
|
|
|
const bundlePath = join(this.appsDir, entry.name, 'bundle.json')
|
|
const bundleData = await readFile(bundlePath, 'utf8')
|
|
const bundle = JSON.parse(bundleData) as AppBundleManifestV1
|
|
|
|
const versionPath = join(this.appsDir, entry.name, 'version.json')
|
|
const versionData = await readFile(versionPath, 'utf8')
|
|
const version = JSON.parse(versionData) as LibraryAppVersionRecord
|
|
|
|
this.apps.set(manifest.app_id, { manifest, bundle_manifest: bundle, current_version: version })
|
|
} catch {
|
|
// Skip malformed app directories
|
|
}
|
|
}
|
|
} catch {
|
|
// No apps dir yet
|
|
}
|
|
}
|
|
|
|
getApp(appId: string): LibraryAppRecord | undefined {
|
|
return this.apps.get(appId)
|
|
}
|
|
|
|
listApps(): LibraryAppRecord[] {
|
|
return Array.from(this.apps.values())
|
|
.filter((a) => a.manifest.visibility !== 'archived')
|
|
}
|
|
|
|
getVersion(appId: string): LibraryAppVersionRecord | undefined {
|
|
return this.apps.get(appId)?.current_version
|
|
}
|
|
|
|
async packageFromFeedItem(
|
|
req: AppPackageRequest,
|
|
feedPayload: Uint8Array | null,
|
|
feedTitle: string,
|
|
feedKind: string,
|
|
): Promise<AppPackageResult> {
|
|
const appId = req.requested_app_id || randomUUID()
|
|
const existing = this.apps.get(appId)
|
|
const now = Date.now()
|
|
const versionId = randomUUID()
|
|
|
|
const appDir = join(this.appsDir, appId)
|
|
await mkdir(appDir, { recursive: true })
|
|
|
|
// Init git repo if needed
|
|
try {
|
|
await access(join(appDir, '.git'))
|
|
} catch {
|
|
execSync('git init', { cwd: appDir })
|
|
}
|
|
|
|
// Determine app kind from feed item kind
|
|
const kind: AppKind =
|
|
feedKind === 'html_preview' ? 'html_page' : 'html_page'
|
|
|
|
const entryFile = 'index.html'
|
|
if (feedPayload) {
|
|
await writeFile(join(appDir, entryFile), feedPayload)
|
|
}
|
|
|
|
const bundleManifest: AppBundleManifestV1 = {
|
|
schema_version: '1',
|
|
app_id: appId,
|
|
name: req.title || feedTitle,
|
|
title: req.title || feedTitle,
|
|
description: req.description,
|
|
kind,
|
|
entry: entryFile,
|
|
files: [entryFile],
|
|
capabilities: [],
|
|
tags: [],
|
|
}
|
|
|
|
const manifest: LibraryAppManifestV2 = existing?.manifest
|
|
? {
|
|
...existing.manifest,
|
|
title: req.title || existing.manifest.title,
|
|
description: req.description || existing.manifest.description,
|
|
updated_at: now,
|
|
current_version_id: versionId,
|
|
}
|
|
: {
|
|
schema_version: '2',
|
|
app_id: appId,
|
|
title: req.title || feedTitle,
|
|
name: (req.title || feedTitle).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
description: req.description,
|
|
visibility: 'family',
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_launched_at: 0,
|
|
created_from_feed_item_id: req.feed_item_id,
|
|
current_version_id: versionId,
|
|
default_branch: 'main',
|
|
local_repo_path: appDir,
|
|
kind,
|
|
tags: [],
|
|
capabilities: [],
|
|
source_feed_item_ids: [req.feed_item_id],
|
|
}
|
|
|
|
const version: LibraryAppVersionRecord = {
|
|
version_id: versionId,
|
|
created_at: now,
|
|
entry: entryFile,
|
|
files: [entryFile],
|
|
branch: 'main',
|
|
source_feed_item_ids: [req.feed_item_id],
|
|
}
|
|
|
|
// Persist to disk
|
|
await writeFile(join(appDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
|
await writeFile(join(appDir, 'bundle.json'), JSON.stringify(bundleManifest, null, 2))
|
|
await writeFile(join(appDir, 'version.json'), JSON.stringify(version, null, 2))
|
|
|
|
// Git commit
|
|
try {
|
|
execSync('git add -A && git commit -m "Package from feed item"', {
|
|
cwd: appDir,
|
|
})
|
|
const commit = execSync('git rev-parse HEAD', { cwd: appDir })
|
|
.toString()
|
|
.trim()
|
|
version.commit = commit
|
|
manifest.published_commit = commit
|
|
await writeFile(join(appDir, 'version.json'), JSON.stringify(version, null, 2))
|
|
} catch {
|
|
// git commit may fail if nothing changed
|
|
}
|
|
|
|
const record: LibraryAppRecord = {
|
|
manifest,
|
|
bundle_manifest: bundleManifest,
|
|
current_version: version,
|
|
}
|
|
this.apps.set(appId, record)
|
|
|
|
return { app: record, created: !existing }
|
|
}
|
|
|
|
async getWorkspace(appId: string): Promise<AppWorkspaceRecord | null> {
|
|
const app = this.apps.get(appId)
|
|
if (!app) return null
|
|
|
|
const appDir = join(this.appsDir, appId)
|
|
let headCommit = ''
|
|
let dirty = false
|
|
|
|
try {
|
|
headCommit = execSync('git rev-parse HEAD', { cwd: appDir })
|
|
.toString()
|
|
.trim()
|
|
const status = execSync('git status --porcelain', { cwd: appDir })
|
|
.toString()
|
|
.trim()
|
|
dirty = status.length > 0
|
|
} catch {
|
|
// Not a git repo
|
|
}
|
|
|
|
return {
|
|
app_id: appId,
|
|
cwd: appDir,
|
|
branch: app.manifest.default_branch,
|
|
head_commit: headCommit,
|
|
dirty,
|
|
bundle_manifest: app.bundle_manifest,
|
|
}
|
|
}
|
|
|
|
async publish(appId: string, message: string): Promise<AppPublishResult | null> {
|
|
const app = this.apps.get(appId)
|
|
if (!app) return null
|
|
|
|
const appDir = join(this.appsDir, appId)
|
|
const now = Date.now()
|
|
|
|
try {
|
|
execSync(`git add -A && git commit -m ${JSON.stringify(message)}`, {
|
|
cwd: appDir,
|
|
})
|
|
} catch {
|
|
// Nothing to commit
|
|
}
|
|
|
|
let commit = ''
|
|
try {
|
|
commit = execSync('git rev-parse HEAD', { cwd: appDir })
|
|
.toString()
|
|
.trim()
|
|
} catch {
|
|
// Not a git repo
|
|
}
|
|
|
|
const versionId = randomUUID()
|
|
const version: LibraryAppVersionRecord = {
|
|
...app.current_version,
|
|
version_id: versionId,
|
|
created_at: now,
|
|
published_at: now,
|
|
commit,
|
|
changelog_summary: message,
|
|
}
|
|
|
|
app.manifest.current_version_id = versionId
|
|
app.manifest.published_commit = commit
|
|
app.manifest.updated_at = now
|
|
app.current_version = version
|
|
|
|
await writeFile(
|
|
join(appDir, 'manifest.json'),
|
|
JSON.stringify(app.manifest, null, 2),
|
|
)
|
|
await writeFile(
|
|
join(appDir, 'version.json'),
|
|
JSON.stringify(version, null, 2),
|
|
)
|
|
|
|
const workspace = await this.getWorkspace(appId)
|
|
|
|
return { app, workspace: workspace! }
|
|
}
|
|
|
|
archive(appId: string): boolean {
|
|
const app = this.apps.get(appId)
|
|
if (!app) return false
|
|
app.manifest.visibility = 'archived'
|
|
app.manifest.updated_at = Date.now()
|
|
return true
|
|
}
|
|
|
|
launch(appId: string): LibraryAppRecord | null {
|
|
const app = this.apps.get(appId)
|
|
if (!app) return null
|
|
app.manifest.last_launched_at = Date.now()
|
|
return app
|
|
}
|
|
|
|
async getHistory(appId: string): Promise<AppHistoryResponse> {
|
|
const appDir = join(this.appsDir, appId)
|
|
const entries: AppHistoryEntry[] = []
|
|
|
|
try {
|
|
const log = execSync(
|
|
'git log --format="%H|%s|%at" --max-count=50',
|
|
{ cwd: appDir },
|
|
)
|
|
.toString()
|
|
.trim()
|
|
for (const line of log.split('\n')) {
|
|
if (!line) continue
|
|
const [commit, message, timestamp] = line.split('|')
|
|
entries.push({
|
|
commit: commit!,
|
|
message: message!,
|
|
authored_at: parseInt(timestamp!, 10) * 1000,
|
|
})
|
|
}
|
|
} catch {
|
|
// Not a git repo or empty
|
|
}
|
|
|
|
return { entries }
|
|
}
|
|
|
|
async getAppFile(appId: string, filePath: string): Promise<Uint8Array | null> {
|
|
const appDir = join(this.appsDir, appId)
|
|
try {
|
|
return await readFile(join(appDir, filePath)) as unknown as Uint8Array
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.apps.clear()
|
|
}
|
|
}
|