Files
claw-code-parity/ts-worker/state/AppLibraryStore.ts
T
Wylabb f7c23fb325
Build Claw Telegram / build (push) Successful in 5m26s
Build Claw Telegram / cleanup (push) Successful in 0s
Replace Rust worker with TS worker in single image
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>
2026-04-07 23:44:57 +02:00

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()
}
}